From 6143331662d088291a4944a7cc07b547ce05f232 Mon Sep 17 00:00:00 2001 From: Tristan Mouchet Date: Wed, 8 Apr 2026 10:31:25 +0200 Subject: [PATCH] feat: add granular date/time part format presets - Add "Date parts" section (weekday, month, day, year with variants) - Add "Time parts" section (hour 12h/24h, minute, AM/PM) - Use fixed sample date for consistent format previews - Make format dropdown scrollable for long lists - Fix SSR rendering to use raw ISO values for custom formats --- .../components/VariableFormatSelector.tsx | 13 +- lib/inline-variables.ts | 10 +- lib/page-fetcher.ts | 12 +- lib/variable-format-utils.ts | 143 ++++++++++++++++-- 4 files changed, 161 insertions(+), 17 deletions(-) diff --git a/app/(builder)/ycode/components/VariableFormatSelector.tsx b/app/(builder)/ycode/components/VariableFormatSelector.tsx index 893c2cff..fad89e7a 100644 --- a/app/(builder)/ycode/components/VariableFormatSelector.tsx +++ b/app/(builder)/ycode/components/VariableFormatSelector.tsx @@ -25,6 +25,10 @@ import { type NumberFormatPreset, } from '@/lib/variable-format-utils'; +function getDetail(preset: DateFormatPreset | NumberFormatPreset): string | undefined { + return 'detail' in preset ? (preset as DateFormatPreset).detail : undefined; +} + interface VariableFormatSelectorProps { fieldType: string | null | undefined; currentFormat?: string; @@ -104,7 +108,7 @@ export default function VariableFormatSelector({ onClick={(e) => e.stopPropagation()} onPointerDownOutside={(e) => e.stopPropagation()} > -
+
{sections.map((section) => (

@@ -119,7 +123,12 @@ export default function VariableFormatSelector({ )} onClick={() => handleSelect(preset.id)} > - {getPreview(preset)} + + {getPreview(preset)} + {getDetail(preset) && ( + {getDetail(preset)} + )} + {currentFormat === preset.id && ( ([\s\S]*?)<\/ycode- export function resolveInlineVariables( text: string, collectionItem: CollectionItemWithValues | null | undefined, - timezone: string = 'UTC' + timezone: string = 'UTC', + rawValues?: Record ): string { if (!collectionItem || !collectionItem.values) { return text; @@ -34,7 +35,12 @@ export function resolveInlineVariables( if (parsed.type === 'field' && parsed.data?.field_id) { const fieldValue = collectionItem.values[parsed.data.field_id]; - return formatFieldValue(fieldValue, parsed.data.field_type, timezone, parsed.data.format); + // Use raw (unformatted ISO) values for custom format presets, + // since values may be pre-formatted by formatDateFieldsInItemValues on SSR + const value = (parsed.data.format && rawValues) + ? (rawValues[parsed.data.field_id] ?? fieldValue) + : fieldValue; + return formatFieldValue(value, parsed.data.field_type, timezone, parsed.data.format); } } catch { // Invalid JSON or not a field variable, leave as is diff --git a/lib/page-fetcher.ts b/lib/page-fetcher.ts index 52f51176..8190c5b7 100644 --- a/lib/page-fetcher.ts +++ b/lib/page-fetcher.ts @@ -1060,7 +1060,7 @@ async function injectCollectionData( content_hash: null, values: enhancedValues, }; - const resolved = resolveInlineVariablesWithRelationships(textContent, mockItem, timezone); + const resolved = resolveInlineVariablesWithRelationships(textContent, mockItem, timezone, rawItemValues); resolvedVars.text = { type: 'dynamic_text', @@ -1176,7 +1176,8 @@ async function injectCollectionData( function resolveInlineVariablesWithRelationships( text: string, collectionItem: CollectionItemWithValues, - timezone: string = 'UTC' + timezone: string = 'UTC', + rawValues?: Record ): string { if (!collectionItem || !collectionItem.values) { return text; @@ -1198,7 +1199,10 @@ function resolveInlineVariablesWithRelationships( const fieldValue = collectionItem.values[fullPath]; if (parsed.data.format && fieldValue) { - return formatFieldValue(fieldValue, parsed.data.field_type, timezone, parsed.data.format); + // Use raw (unformatted ISO) values for custom format presets, + // since itemValues are pre-formatted by formatDateFieldsInItemValues + const rawValue = rawValues?.[fullPath] ?? fieldValue; + return formatFieldValue(rawValue, parsed.data.field_type, timezone, parsed.data.format); } return fieldValue || ''; } @@ -2744,7 +2748,7 @@ async function injectCollectionDataForHtml( content_hash: null, values: enhancedValues, }; - const resolved = resolveInlineVariables(textContent, mockItem, timezone); + const resolved = resolveInlineVariables(textContent, mockItem, timezone, rawItemValues); resolvedVars.text = { type: 'dynamic_text', data: { content: resolved }, diff --git a/lib/variable-format-utils.ts b/lib/variable-format-utils.ts index 7651f2d5..14c7d129 100644 --- a/lib/variable-format-utils.ts +++ b/lib/variable-format-utils.ts @@ -15,6 +15,12 @@ export interface DateFormatPreset { label: string; options: Intl.DateTimeFormatOptions; locale?: string; + /** Helper text shown next to the preview (e.g. "Hour (12h)") */ + detail?: string; + /** Strip leading zero from the formatted output (for Intl quirks like 24h numeric hours) */ + stripLeadingZero?: boolean; + /** Extract a single part from formatToParts instead of using the full formatted string */ + extractPart?: Intl.DateTimeFormatPartTypes; } export interface FormatPresetSection { @@ -107,6 +113,59 @@ export const DATE_FORMAT_SECTIONS: FormatPresetSection[] = [ }, ], }, + { + title: 'Date parts', + presets: [ + { + id: 'part-weekday-long', + label: 'Tuesday', + detail: 'Day name', + options: { weekday: 'long' }, + }, + { + id: 'part-weekday-short', + label: 'Tue', + detail: 'Day name (short)', + options: { weekday: 'short' }, + }, + { + id: 'part-month-long', + label: 'April', + detail: 'Month name', + options: { month: 'long' }, + }, + { + id: 'part-month-short', + label: 'Apr', + detail: 'Month name (short)', + options: { month: 'short' }, + }, + { + id: 'part-day', + label: '7', + detail: 'Day', + options: { day: 'numeric' }, + }, + { + id: 'part-day-padded', + label: '07', + detail: 'Day (zero-padded)', + options: { day: '2-digit' }, + }, + { + id: 'part-year', + label: '2026', + detail: 'Year', + options: { year: 'numeric' }, + }, + { + id: 'part-year-short', + label: '26', + detail: 'Year (short)', + options: { year: '2-digit' }, + }, + ], + }, { title: 'Time', presets: [ @@ -122,11 +181,67 @@ export const DATE_FORMAT_SECTIONS: FormatPresetSection[] = [ }, ], }, + { + title: 'Time parts', + presets: [ + { + id: 'part-hour-12', + label: '9', + detail: 'Hour (12h)', + options: { hour: 'numeric', hour12: true }, + extractPart: 'hour', + }, + { + id: 'part-hour-12-padded', + label: '09', + detail: 'Hour (12h, zero-padded)', + options: { hour: '2-digit', hour12: true }, + extractPart: 'hour', + }, + { + id: 'part-hour-24-short', + label: '9', + detail: 'Hour (24h)', + options: { hour: 'numeric', hour12: false }, + extractPart: 'hour', + stripLeadingZero: true, + }, + { + id: 'part-hour-24', + label: '09', + detail: 'Hour (24h, zero-padded)', + options: { hour: '2-digit', hour12: false }, + extractPart: 'hour', + }, + { + id: 'part-ampm', + label: 'AM', + detail: 'AM / PM', + options: { hour: 'numeric', hour12: true }, + extractPart: 'dayPeriod', + }, + { + id: 'part-minute', + label: '8', + detail: 'Minute', + options: { hour: 'numeric', minute: 'numeric', hour12: false }, + extractPart: 'minute', + stripLeadingZero: true, + }, + { + id: 'part-minute-padded', + label: '08', + detail: 'Minute (zero-padded)', + options: { hour: 'numeric', minute: '2-digit', hour12: false }, + extractPart: 'minute', + }, + ], + }, ]; -/** Sections for date_only fields (excludes datetime and time presets) */ +/** Sections for date_only fields (excludes datetime, time, and time parts) */ export const DATE_ONLY_FORMAT_SECTIONS: FormatPresetSection[] = - DATE_FORMAT_SECTIONS.filter(s => s.title === 'Date'); + DATE_FORMAT_SECTIONS.filter(s => s.title === 'Date' || s.title === 'Date parts'); /** Flat list of all date presets (used for lookup by ID) */ export const DATE_FORMAT_PRESETS: DateFormatPreset[] = @@ -284,10 +399,14 @@ export function formatDateWithPreset( } try { - return new Intl.DateTimeFormat(preset.locale || 'en-US', { + const formatter = new Intl.DateTimeFormat(preset.locale || 'en-US', { timeZone: timezone, ...preset.options, - }).format(dateObj); + }); + const result = preset.extractPart + ? (formatter.formatToParts(dateObj).find(p => p.type === preset.extractPart)?.value ?? '') + : formatter.format(dateObj); + return preset.stripLeadingZero ? result.replace(/^0/, '') : result; } catch { return ''; } @@ -311,14 +430,20 @@ export function formatNumberWithPreset( } } -/** - * Generate a live preview label for a date format preset using the current date - */ +// Fixed sample date for all format previews so the dropdown is consistent +// Tue Apr 7 2026 09:08 — uses single-digit components to show zero-padding differences +const PREVIEW_DATE = new Date(2026, 3, 7, 9, 8); + +/** Generate a preview label for a date format preset */ export function getDateFormatPreview(preset: DateFormatPreset): string { try { - return new Intl.DateTimeFormat(preset.locale || 'en-US', { + const formatter = new Intl.DateTimeFormat(preset.locale || 'en-US', { ...preset.options, - }).format(new Date()); + }); + const result = preset.extractPart + ? (formatter.formatToParts(PREVIEW_DATE).find(p => p.type === preset.extractPart)?.value ?? '') + : formatter.format(PREVIEW_DATE); + return preset.stripLeadingZero ? result.replace(/^0/, '') : result; } catch { return preset.label; }