feat(chronos): i18n — component langs catalog + route all UI strings

Add src/uix/langs/components/chronos.ts (~41 keys, es+en) and register it
in the componentLangs barrel. Route every hardcoded string in the provider
and the eidos wrappers (view / when-editor / day-peek) through
soma.langs.ts(ref) / .t(ref, params), reusing common.calendar.* for month
nav and common.buttons.* for cancel/save/close instead of duplicating.

Covers morfo texts, toolbar/views, the editor dialog, the when-editor
(modes, dates, time bands with interpolated "Band {{n}}"), the week badge
("Semana {{n}}"), and the draft validation messages.

Verified: translations:check 0 chronos errors; npm run check 0 chronos/langs
errors; browser under locale es renders Spanish (Hoy / Evento / Tipo /
Fechas / Todo el día·Único·Dividido), console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 6c55e092b5
commit 2742e4e6de

@ -29,6 +29,9 @@
const provider = ChronosProvider.require();
const tz = getLocalTimeZone();
// i18n — resolve component strings (reactive to the active locale).
const tr = (ref: string) => provider.soma.langs.ts(ref);
const untitled = $derived(tr('#?components.chronos.untitled|(untitled)'));
// Bridge the eidos context across the popover's portal boundary.
ActiveEidos.set(ActiveEidos.require());
@ -163,7 +166,7 @@
aria-label={provider.eventAriaLabel(ev)}
onclick={() => edit(ev)}
>
<span class="chronos-chip-title">{ev.title || '(untitled)'}</span>
<span class="chronos-chip-title">{ev.title || untitled}</span>
</button>
{/each}
</div>
@ -189,7 +192,7 @@
aria-label={provider.eventAriaLabel(b.event)}
onclick={() => edit(b.event)}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-title">{b.event.title || untitled}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</button>
{/each}
@ -207,8 +210,8 @@
class="chronos-peek-resize"
role="separator"
aria-orientation="vertical"
aria-label="Resize width"
title="Drag to resize"
aria-label={tr('#?components.chronos.resize-width|Resize width')}
title={tr('#?components.chronos.drag-to-resize|Drag to resize')}
onpointerdown={resizeStart}
onpointermove={resizeMove}
onpointerup={resizeEnd}

@ -48,6 +48,11 @@
const provider = ChronosProvider.require();
const tz = getLocalTimeZone();
// i18n — resolve component strings (reactive to the active locale).
const tr = (ref: string) => provider.soma.langs.ts(ref);
const untitled = $derived(tr('#?components.chronos.untitled|(untitled)'));
const trn = (ref: string, n: number) => provider.soma.langs.t(ref, { n });
// Editor context exposed to a consumer-provided `editor` snippet. Actions are
// wired to the engine so a custom dialog drives create/update/delete exactly
// like the built-in form.
@ -97,12 +102,12 @@
);
const weekOfMonth = $derived(Math.ceil(todayDate.day / 7));
const VIEW_LABELS: Record<ChronosView, string> = {
month: 'Month view',
week: 'Week view',
day: 'Day view',
agenda: 'Agenda'
};
const VIEW_LABELS = $derived<Record<ChronosView, string>>({
month: tr('#?components.chronos.view-month|Month view'),
week: tr('#?components.chronos.view-week|Week view'),
day: tr('#?components.chronos.view-day|Day view'),
agenda: tr('#?components.chronos.view-agenda|Agenda')
});
const hours = Array.from({ length: 24 }, (_, i) => i);
@ -186,20 +191,25 @@
<div class="chronos-title-block">
<div class="chronos-title-row">
<span data-chronos-heading>{provider.headingValue}</span>
<span class="chronos-week-badge">Week {weekOfMonth}</span>
<span class="chronos-week-badge">{trn('#?components.chronos.week-n|Week {{n}}', weekOfMonth)}</span
>
</div>
<span class="chronos-range">{provider.subtitle}</span>
</div>
<span class="chronos-toolbar-spacer"></span>
<IconButton aria-label="Search" variant="surface" data-chronos-search-button>
<IconButton
aria-label={tr('#?components.chronos.search|Search')}
variant="surface"
data-chronos-search-button
>
<Search size="sm" />
</IconButton>
<div class="chronos-nav-group">
<IconButton
aria-label="Previous"
aria-label={tr('#?common.calendar.prev-month|Previous month')}
variant="ghost"
data-chronos-prev-button
onclick={(e: MouseEvent) => provider.prev(e.currentTarget as HTMLElement)}
@ -211,10 +221,10 @@
data-chronos-today-button
onclick={(e: MouseEvent) => provider.goToday(e.currentTarget as HTMLElement)}
>
Today
{tr('#?components.chronos.today|Today')}
</Button>
<IconButton
aria-label="Next"
aria-label={tr('#?common.calendar.next-month|Next month')}
variant="ghost"
data-chronos-next-button
onclick={(e: MouseEvent) => provider.next(e.currentTarget as HTMLElement)}
@ -248,7 +258,8 @@
</div>
<Button variant="solid" color="primary" onclick={() => provider.openCreate(todayDate)}>
<Plus size="sm" /> Add event
<Plus size="sm" />
{tr('#?components.chronos.add-event|Add event')}
</Button>
</div>
@ -262,7 +273,7 @@
{@render eventTooltip(ctx)}
{:else}
<div class="chronos-tooltip">
<span class="chronos-tooltip-title">{ctx.event.title || '(untitled)'}</span>
<span class="chronos-tooltip-title">{ctx.event.title || untitled}</span>
<span class="chronos-tooltip-meta"
>{ctx.timeLabel}{ctx.typeLabel ? ` · ${ctx.typeLabel}` : ''}</span
>
@ -326,7 +337,7 @@
longer captures clicks (drag/drop owns the cell surface). -->
<IconButton
data-chronos-day-add
aria-label="New event"
aria-label={tr('#?components.chronos.new-event|New event')}
variant="ghost"
size="xs"
onclick={(e: MouseEvent) => {
@ -373,7 +384,7 @@
provider.openEdit(seg.event);
}}
>
<span class="chronos-chip-title">{seg.event.title || '(untitled)'}</span>
<span class="chronos-chip-title">{seg.event.title || untitled}</span>
{#if !seg.event.allDay}
<span class="chronos-chip-time">{eventTime(seg.event)}</span>
{/if}
@ -423,7 +434,7 @@
</div>
<div class="chronos-tg-allday">
<div class="chronos-tg-railcell">All day</div>
<div class="chronos-tg-railcell">{tr('#?components.chronos.all-day|All day')}</div>
{#each provider.visibleDays as day (day.toString())}
<DragDrop.Droppable
textValue={provider.dayAriaLabel(day)}
@ -456,7 +467,7 @@
provider.openEdit(ev);
}}
>
<span class="chronos-chip-title">{ev.title || '(untitled)'}</span>
<span class="chronos-chip-title">{ev.title || untitled}</span>
</DragDrop.Draggable>
{/snippet}
</Tooltip.Trigger>
@ -520,7 +531,7 @@
provider.openEdit(b.event);
}}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-title">{b.event.title || untitled}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</button>
{/snippet}
@ -550,7 +561,7 @@
provider.openEdit(b.event);
}}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-title">{b.event.title || untitled}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</DragDrop.Draggable>
{/snippet}
@ -596,9 +607,11 @@
onclick={() => provider.openEdit(ev)}
>
<span class="chronos-agenda-dot"></span>
<span class="chronos-agenda-title">{ev.title || '(untitled)'}</span>
<span class="chronos-agenda-title">{ev.title || untitled}</span>
<span class="chronos-agenda-time"
>{ev.allDay ? 'All day' : provider.eventTimeLabel(ev)}</span
>{ev.allDay
? tr('#?components.chronos.all-day|All day')
: provider.eventTimeLabel(ev)}</span
>
</button>
{/each}
@ -611,7 +624,7 @@
<!-- Legend from the schema -->
{#if provider.schema.eventTypes.length > 0}
<div class="chronos-legend" aria-label="Event types">
<div class="chronos-legend" aria-label={tr('#?components.chronos.event-types|Event types')}>
{#each provider.eventTypes as t (t.id)}
<span class="chronos-legend-item">
<span class="chronos-legend-swatch" style={swatchStyle(t.color)}></span>
@ -636,7 +649,11 @@
{@const editable = provider.draftEditable}
<Dialog.Header>
<Dialog.Title>
{editing.mode === 'create' ? 'New event' : editable ? 'Edit event' : 'Event'}
{editing.mode === 'create'
? tr('#?components.chronos.new-event|New event')
: editable
? tr('#?components.chronos.edit-event|Edit event')
: tr('#?components.chronos.event|Event')}
</Dialog.Title>
</Dialog.Header>
@ -646,7 +663,7 @@
{#if provider.showEditAction}
<IconButton
data-chronos-edit-action
aria-label="Edit event"
aria-label={tr('#?components.chronos.edit-event|Edit event')}
variant="ghost"
size="sm"
onclick={() => provider.enableEditing()}
@ -663,19 +680,19 @@
{@render editor(editorCtx)}
{:else}
<Field>
<Field.Label>Title</Field.Label>
<Field.Label>{tr('#?components.chronos.title-label|Title')}</Field.Label>
<Field.Control>
<Field.Input
type="text"
bind:value={editing.draft.title}
placeholder="Event title"
placeholder={tr('#?components.chronos.title-placeholder|Event title')}
readonly={!editable}
/>
</Field.Control>
</Field>
<div class="chronos-field">
<span class="chronos-field-label">Type</span>
<span class="chronos-field-label">{tr('#?components.chronos.type|Type')}</span>
<Select
selectionMode="single"
readonly={!editable}
@ -683,7 +700,7 @@
onValueChange={(v) => (editing.draft.typeId = v[0])}
>
<Select.Trigger>
<Select.Value placeholder="Type" />
<Select.Value placeholder={tr('#?components.chronos.type|Type')} />
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
@ -705,7 +722,7 @@
{#if editable && editing.draft.typeId === 'otros' && provider.schema.predefinedColors.length > 0}
<div class="chronos-field">
<span class="chronos-field-label">Color</span>
<span class="chronos-field-label">{tr('#?components.chronos.color|Color')}</span>
<div class="chronos-swatches">
{#each provider.schema.predefinedColors as c (c)}
<button
@ -736,16 +753,18 @@
<Dialog.Footer>
{#if editable && editing.mode === 'edit'}
<Button variant="ghost" intent="loss" onclick={removeEvent}>Delete</Button>
<Button variant="ghost" intent="loss" onclick={removeEvent}
>{tr('#?components.chronos.delete|Delete')}</Button
>
{/if}
<span class="chronos-editor-spacer"></span>
{#if editable}
<Dialog.Close>Cancel</Dialog.Close>
<Dialog.Close>{tr('#?common.buttons.cancel|Cancel')}</Dialog.Close>
<Button variant="solid" color="primary" disabled={!!provider.draftError} onclick={save}>
Save
{tr('#?common.buttons.save|Save')}
</Button>
{:else}
<Dialog.Close>Close</Dialog.Close>
<Dialog.Close>{tr('#?common.buttons.close|Close')}</Dialog.Close>
{/if}
</Dialog.Footer>
{/if}

@ -26,6 +26,12 @@
// View mode → every control readonly/disabled (the dialog owns the view/edit flip).
const editable = $derived(provider.draftEditable);
// i18n — resolve component strings (reactive to the active locale).
const tr = (ref: string) => provider.soma.langs.ts(ref);
const trn = (ref: string, n: number) => provider.soma.langs.t(ref, { n });
const startLabel = $derived(tr('#?components.chronos.start|Start'));
const endLabel = $derived(tr('#?components.chronos.end|End'));
// Context bridge: the Dialog injects ActiveEidos via `mount(..., { context })`,
// which `getAllContexts()` (used by the pickers' Portal) does NOT enumerate —
// so the portaled date/time popovers, nested inside the dialog's portal, lose
@ -34,11 +40,11 @@
// resolve the (same) instance and float cleanly above the dialog.
ActiveEidos.set(ActiveEidos.require());
const MODES: { value: ChronosTimeMode; label: string }[] = [
{ value: 'all-day', label: 'All day' },
{ value: 'single', label: 'Single' },
{ value: 'split', label: 'Split' }
];
const MODES = $derived<{ value: ChronosTimeMode; label: string }[]>([
{ value: 'all-day', label: tr('#?components.chronos.mode-all-day|All day') },
{ value: 'single', label: tr('#?components.chronos.mode-single|Single') },
{ value: 'split', label: tr('#?components.chronos.mode-split|Split') }
]);
const asTime = (t: { hour: number; minute: number }) => new Time(t.hour, t.minute);
</script>
@ -59,7 +65,7 @@
onStartValueChange={(t) => t && onStart(asTime(t))}
onEndValueChange={(t) => t && onEnd(asTime(t))}
>
<TimeRangePicker.Input type="start" aria-label={`${labelPrefix} start`}>
<TimeRangePicker.Input type="start" aria-label={`${labelPrefix} ${startLabel}`}>
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${labelPrefix}-s-${part}-${i}`)}
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
@ -67,7 +73,7 @@
{/snippet}
</TimeRangePicker.Input>
<span class="chronos-pair-sep" aria-hidden="true">-></span>
<TimeRangePicker.Input type="end" aria-label={`${labelPrefix} end`}>
<TimeRangePicker.Input type="end" aria-label={`${labelPrefix} ${endLabel}`}>
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${labelPrefix}-e-${part}-${i}`)}
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
@ -80,12 +86,12 @@
<PickerShell.Body>
<TimeRangePicker.Clock>
<div class="chronos-clock-endpoint">
<span class="chronos-when-label">Start</span>
<span class="chronos-when-label">{startLabel}</span>
<TimeRangePicker.HourSlider type="start" />
<TimeRangePicker.MinuteSlider type="start" />
</div>
<div class="chronos-clock-endpoint">
<span class="chronos-when-label">End</span>
<span class="chronos-when-label">{endLabel}</span>
<TimeRangePicker.HourSlider type="end" />
<TimeRangePicker.MinuteSlider type="end" />
</div>
@ -106,7 +112,7 @@
block
color="primary"
readonly={!editable}
aria-label="Time mode"
aria-label={tr('#?components.chronos.time-mode|Time mode')}
value={provider.draftMode ? [provider.draftMode] : []}
onValueChange={(v) => v[0] && provider.setDraftTimeMode(v[0] as ChronosTimeMode)}
>
@ -117,17 +123,20 @@
<!-- Date span (date-only picker; start + end compact, on one line) -->
<div class="chronos-when-field">
<span class="chronos-when-label">Dates</span>
<span class="chronos-when-label">{tr('#?components.chronos.dates|Dates')}</span>
<DateRangePicker
numberOfMonths={1}
readonly={!editable}
value={provider.draftDateRange ?? undefined}
onStartValueChange={(d) => d && provider.setDraftDateRange({ start: d })}
onEndValueChange={(d) => d && provider.setDraftDateRange({ end: d })}
calendarLabel="Event dates"
calendarLabel={tr('#?components.chronos.event-dates|Event dates')}
>
<div class="chronos-pair">
<DateRangePicker.Input type="start" aria-label="Start date">
<DateRangePicker.Input
type="start"
aria-label={tr('#?components.chronos.start-date|Start date')}
>
{#snippet children({ segments })}
{#each segments as { part, value }, i (`s-${part}-${i}`)}
<DateRangePicker.Segment {part}>{value}</DateRangePicker.Segment>
@ -135,7 +144,7 @@
{/snippet}
</DateRangePicker.Input>
<span class="chronos-pair-sep" aria-hidden="true">-></span>
<DateRangePicker.Input type="end" aria-label="End date">
<DateRangePicker.Input type="end" aria-label={tr('#?components.chronos.end-date|End date')}>
{#snippet children({ segments })}
{#each segments as { part, value }, i (`e-${part}-${i}`)}
<DateRangePicker.Segment {part}>{value}</DateRangePicker.Segment>
@ -149,11 +158,15 @@
<DateRangePicker.Calendar>
{#snippet children({ months, weekdays })}
<DateRangePicker.Header>
<DateRangePicker.PrevButton aria-label="Previous month">
<DateRangePicker.PrevButton
aria-label={tr('#?common.calendar.prev-month|Previous month')}
>
<ChevronLeft size="sm" />
</DateRangePicker.PrevButton>
<DateRangePicker.Heading />
<DateRangePicker.NextButton aria-label="Next month">
<DateRangePicker.NextButton
aria-label={tr('#?common.calendar.next-month|Next month')}
>
<ChevronRight size="sm" />
</DateRangePicker.NextButton>
</DateRangePicker.Header>
@ -191,19 +204,19 @@
<!-- Time controls — depend on the mode -->
{#if provider.draftMode === 'single'}
<div class="chronos-when-field">
<span class="chronos-when-label">Hours</span>
<span class="chronos-when-label">{tr('#?components.chronos.hours|Hours')}</span>
{@render timeRange(
provider.draftStartTime,
provider.draftEndTime,
(t) => provider.setDraftStartTime(t),
(t) => provider.setDraftEndTime(t),
'Event time',
tr('#?components.chronos.event-time|Event time'),
undefined
)}
</div>
{:else if provider.draftMode === 'split'}
<div class="chronos-when-field">
<span class="chronos-when-label">Time bands</span>
<span class="chronos-when-label">{tr('#?components.chronos.time-bands|Time bands')}</span>
<div class="chronos-bands">
{#each provider.draftBands as band, i (i)}
<div class="chronos-band-row">
@ -212,12 +225,12 @@
band.end,
(t) => provider.setDraftBandStart(i, t),
(t) => provider.setDraftBandEnd(i, t),
`Band ${i + 1}`,
trn('#?components.chronos.band-n|Band {{n}}', i + 1),
i > 0 ? provider.draftBands[i - 1].end : undefined
)}
{#if editable}
<IconButton
aria-label={`Remove band ${i + 1}`}
aria-label={trn('#?components.chronos.remove-band|Remove band {{n}}', i + 1)}
variant="ghost"
size="sm"
disabled={provider.draftBands.length <= 1}
@ -230,12 +243,15 @@
{/each}
{#if editable}
<Button variant="ghost" onclick={() => provider.addDraftBand()}>
<Plus /> Add band
<Plus />
{tr('#?components.chronos.add-band|Add band')}
</Button>
{/if}
</div>
{#if editable}
<span class="chronos-when-hint">Bands repeat on each day of the date range.</span>
<span class="chronos-when-hint"
>{tr('#?components.chronos.bands-hint|Bands repeat on each day of the date range.')}</span
>
{/if}
</div>
{/if}

@ -0,0 +1,87 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the chronos component. Merged under
* `components.chronos.*` by `ActiveUix` (via the `componentLangs` barrel).
*
* Morfo references entries by absolute idlangref
* (`'#?components.chronos.{key}|fallback'`); the eidos wrappers + provider
* resolve UI strings the same way via `soma.langs.ts(...)`. Shared actions
* (cancel / close) and month navigation reuse `common.*` instead.
*/
export const chronosLangs = {
// ── Morfo texts (grid + toolbar) ──────────────────────────────────────────
label: { es: 'Calendario', en: 'Calendar' },
'prev-month': { es: 'Mes anterior', en: 'Previous month' },
'next-month': { es: 'Mes siguiente', en: 'Next month' },
today: { es: 'Hoy', en: 'Today' },
'more-events': { es: 'Ver más eventos', en: 'Show more events' },
// ── Toolbar + views ───────────────────────────────────────────────────────
search: { es: 'Buscar', en: 'Search' },
'week-n': { es: 'Semana {{n}}', en: 'Week {{n}}' },
'add-event': { es: 'Añadir evento', en: 'Add event' },
'new-event': { es: 'Nuevo evento', en: 'New event' },
'view-month': { es: 'Vista de mes', en: 'Month view' },
'view-week': { es: 'Vista de semana', en: 'Week view' },
'view-day': { es: 'Vista de día', en: 'Day view' },
'view-agenda': { es: 'Agenda', en: 'Agenda' },
'all-day': { es: 'Todo el día', en: 'All day' },
untitled: { es: '(sin título)', en: '(untitled)' },
'event-types': { es: 'Tipos de evento', en: 'Event types' },
'resize-width': { es: 'Redimensionar ancho', en: 'Resize width' },
'drag-to-resize': { es: 'Arrastra para redimensionar', en: 'Drag to resize' },
// ── Editor dialog ─────────────────────────────────────────────────────────
'edit-event': { es: 'Editar evento', en: 'Edit event' },
event: { es: 'Evento', en: 'Event' },
'title-label': { es: 'Título', en: 'Title' },
'title-placeholder': { es: 'Título del evento', en: 'Event title' },
type: { es: 'Tipo', en: 'Type' },
color: { es: 'Color', en: 'Color' },
delete: { es: 'Eliminar', en: 'Delete' },
// ── "When" editor ─────────────────────────────────────────────────────────
'time-mode': { es: 'Modo de tiempo', en: 'Time mode' },
'mode-all-day': { es: 'Todo el día', en: 'All day' },
'mode-single': { es: 'Único', en: 'Single' },
'mode-split': { es: 'Dividido', en: 'Split' },
dates: { es: 'Fechas', en: 'Dates' },
'event-dates': { es: 'Fechas del evento', en: 'Event dates' },
'start-date': { es: 'Fecha de inicio', en: 'Start date' },
'end-date': { es: 'Fecha de fin', en: 'End date' },
start: { es: 'Inicio', en: 'Start' },
end: { es: 'Fin', en: 'End' },
hours: { es: 'Horas', en: 'Hours' },
'event-time': { es: 'Hora del evento', en: 'Event time' },
'time-bands': { es: 'Franjas horarias', en: 'Time bands' },
'add-band': { es: 'Añadir franja', en: 'Add band' },
'bands-hint': {
es: 'Las franjas se repiten en cada día del rango.',
en: 'Bands repeat on each day of the date range.'
},
'band-n': { es: 'Franja {{n}}', en: 'Band {{n}}' },
'remove-band': { es: 'Quitar franja {{n}}', en: 'Remove band {{n}}' },
// ── Validation ────────────────────────────────────────────────────────────
'err-end-before-start': {
es: 'La fecha de fin es anterior a la de inicio.',
en: 'End date is before the start date.'
},
'err-end-time': {
es: 'La hora de fin debe ser posterior a la de inicio.',
en: 'End time must be after the start time.'
},
'err-no-bands': {
es: 'Añade al menos una franja horaria.',
en: 'Add at least one time band.'
},
'err-band-end': {
es: 'Franja {{n}}: el fin debe ser posterior al inicio.',
en: 'Band {{n}}: end must be after start.'
},
'err-band-overlap': {
es: 'La franja {{n}} solapa la anterior.',
en: 'Band {{n}} overlaps the previous band.'
}
} satisfies LangNode;

@ -13,6 +13,7 @@ import { carouselLangs } from './carousel';
import { cascadeLangs } from './cascade';
import { chartLangs } from './chart';
import { checkboxLangs } from './checkbox';
import { chronosLangs } from './chronos';
import { clipboardLangs } from './clipboard';
import { collapsibleLangs } from './collapsible';
import { colorFieldLangs } from './color-field';
@ -119,6 +120,7 @@ export const componentLangs = {
cascade: cascadeLangs,
chart: chartLangs,
checkbox: checkboxLangs,
chronos: chronosLangs,
clipboard: clipboardLangs,
collapsible: collapsibleLangs,
'color-field': colorFieldLangs,

@ -199,7 +199,8 @@ export class ChronosProvider {
const when = getCachedDateFormat(this.effectiveLocale, { dateStyle: 'medium' }).format(
event.start.toDate(getLocalTimeZone())
);
return `${event.title || '(untitled)'}, ${when}`;
const untitled = this.soma.langs.ts('#?components.chronos.untitled|(untitled)');
return `${event.title || untitled}, ${when}`;
}
get isReadonly(): boolean {
@ -264,7 +265,7 @@ export class ChronosProvider {
/** Time label for tooltips: all-day, a single start–end range, or the bands. */
eventRangeLabel(event: ChronosEvent): string {
if (event.allDay) return 'All day';
if (event.allDay) return this.soma.langs.ts('#?components.chronos.all-day|All day');
const f = (h: number, m: number) =>
getCachedDateFormat(this.effectiveLocale, {
hour: 'numeric',
@ -547,20 +548,36 @@ export class ChronosProvider {
if (!e) return null;
const d = e.draft;
if (toCalendarDate(d.end).compare(toCalendarDate(d.start)) < 0) {
return 'End date is before the start date.';
return this.soma.langs.ts(
'#?components.chronos.err-end-before-start|End date is before the start date.'
);
}
const mode = eventTimeMode(d);
if (mode === 'single') {
if (d.end.compare(d.start) <= 0) return 'End time must be after the start time.';
if (d.end.compare(d.start) <= 0)
return this.soma.langs.ts(
'#?components.chronos.err-end-time|End time must be after the start time.'
);
} else if (mode === 'split') {
const bands = d.parts ?? [];
if (bands.length === 0) return 'Add at least one time band.';
if (bands.length === 0)
return this.soma.langs.ts('#?components.chronos.err-no-bands|Add at least one time band.');
for (let i = 0; i < bands.length; i++) {
if (bands[i].end.compare(bands[i].start) <= 0) {
return `Band ${i + 1}: end must be after start.`;
return this.soma.langs.t(
'#?components.chronos.err-band-end|Band {{n}}: end must be after start.',
{
n: i + 1
}
);
}
if (i > 0 && bands[i].start.compare(bands[i - 1].end) < 0) {
return `Band ${i + 1} overlaps the previous band.`;
return this.soma.langs.t(
'#?components.chronos.err-band-overlap|Band {{n}} overlaps the previous band.',
{
n: i + 1
}
);
}
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.