Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions app/(builder)/ycode/components/VariableFormatSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -104,7 +108,7 @@ export default function VariableFormatSelector({
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={(e) => e.stopPropagation()}
>
<div className="flex flex-col">
<div className="flex flex-col max-h-[min(60vh)] overflow-y-auto">
{sections.map((section) => (
<div key={section.title}>
<p className="text-[10px] font-medium text-muted-foreground px-2 py-1.5 uppercase tracking-wider">
Expand All @@ -119,7 +123,12 @@ export default function VariableFormatSelector({
)}
onClick={() => handleSelect(preset.id)}
>
<span className="flex-1 truncate">{getPreview(preset)}</span>
<span className="flex-1 truncate">
{getPreview(preset)}
{getDetail(preset) && (
<span className="text-muted-foreground ml-1.5">{getDetail(preset)}</span>
)}
</span>
{currentFormat === preset.id && (
<Icon
name="check"
Expand Down
10 changes: 8 additions & 2 deletions lib/inline-variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export const INLINE_VARIABLE_REGEX = /<ycode-inline-variable>([\s\S]*?)<\/ycode-
export function resolveInlineVariables(
text: string,
collectionItem: CollectionItemWithValues | null | undefined,
timezone: string = 'UTC'
timezone: string = 'UTC',
rawValues?: Record<string, string>
): string {
if (!collectionItem || !collectionItem.values) {
return text;
Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions lib/page-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -1176,7 +1176,8 @@ async function injectCollectionData(
function resolveInlineVariablesWithRelationships(
text: string,
collectionItem: CollectionItemWithValues,
timezone: string = 'UTC'
timezone: string = 'UTC',
rawValues?: Record<string, string>
): string {
if (!collectionItem || !collectionItem.values) {
return text;
Expand All @@ -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 || '';
}
Expand Down Expand Up @@ -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 },
Expand Down
143 changes: 134 additions & 9 deletions lib/variable-format-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
Expand Down Expand Up @@ -107,6 +113,59 @@ export const DATE_FORMAT_SECTIONS: FormatPresetSection<DateFormatPreset>[] = [
},
],
},
{
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: [
Expand All @@ -122,11 +181,67 @@ export const DATE_FORMAT_SECTIONS: FormatPresetSection<DateFormatPreset>[] = [
},
],
},
{
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<DateFormatPreset>[] =
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[] =
Expand Down Expand Up @@ -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 '';
}
Expand All @@ -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;
}
Expand Down