feat(immich): multi-time-point scheduling for scheduled/periodic/memory

Replace the single comma-separated text box with an add/remove list of
native HH:MM pickers for the Immich scheduled-assets, periodic-summary,
and memory slots. The backend already stored comma-separated *_times and
scheduled one cron job per time; this makes entering several times per
day discoverable and hardens the read/write path.

Backend:
- services/time_list.py: normalize_time_list (validate / dedup / sort /
  cap at 24, raising TimeListError) + lenient parse_hhmm_list; the
  scheduler now uses the shared parser (drops its private copy).
- tracking_configs API normalizes *_times on every write (422 on bad
  input) and rejects enabling a slot whose times list is empty.
- scheduler warns when an enabled slot has zero or dropped fire times,
  restoring the observability lost with the old per-call warning.

Frontend:
- TimeListEditor.svelte: add/remove native time rows, dedupe + sort on
  emit, per-day cap, collapses on-screen duplicates, aria-labelled rows;
  syncs from the value prop only on external changes (untrack guard) so
  keyboard entry isn't clobbered mid-edit.
- Descriptor-driven save guard: an enabled feature section must have at
  least one time.
- i18n (en/ru) keys; refreshed help text; removed dead invalidTimeList.

Tests: time_list normalization/parsing (incl. non-ASCII/odd shapes) and
the enabled-implies-times validation.
This commit is contained in:
2026-05-29 14:57:41 +03:00
parent e2c17dd343
commit 8065e6effa
11 changed files with 718 additions and 89 deletions
@@ -0,0 +1,252 @@
<script lang="ts">
/**
* Multi-time-point editor — an add/remove list of native HH:MM pickers.
*
* Used in the tracking-configs form for the Immich scheduled / periodic /
* memory slots, which fire at one or more wall-clock times per day. The
* value crosses the wire as a single comma-separated string ("09:00,18:30")
* matching the backend `normalize_time_list`: each emitted value is
* de-duplicated and sorted ascending.
*
* Internally `rows` is the rendering source of truth and preserves the
* user's current edit state (including a blank, not-yet-filled row) so the
* list never jumps around mid-edit. Normalisation is applied only to the
* emitted value, not to what's on screen; the on-screen order re-settles to
* sorted form on the next external load.
*/
import { untrack } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
interface Props {
/** Comma-separated HH:MM list, e.g. "09:00,18:30". */
value: string;
/** Called with a normalised (deduped, sorted) comma-separated string. */
onchange: (value: string) => void;
/** Maximum number of distinct times allowed. */
max?: number;
/** Placeholder forwarded to each native time input. */
placeholder?: string;
}
let { value, onchange, max = 24, placeholder = '' }: Props = $props();
let rows = $state<string[]>([]);
// The exact string we last synced-from or emitted. The resync effect below
// compares against this so it fires ONLY for genuine external `value` changes
// (form reset, config switch) — never for the user's own keystrokes. Without
// this guard the effect re-ran on every `bind:value` mutation while `value`
// still held the old committed string, clobbering the row being typed in (the
// native picker reset just as the user reached the AM/PM segment).
let lastValue = '';
function parse(raw: string): string[] {
return (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
}
/** Distinct, non-blank times, in first-seen order. */
function distinct(list: string[]): string[] {
const seen = new Set<string>();
for (const r of list) {
const v = r.trim();
if (v) seen.add(v);
}
return [...seen];
}
// Populate on mount and re-sync on external `value` changes only. `value` is
// the sole reactive dependency; `rows` is read/written inside `untrack`, so
// editing a row never re-triggers this and can't clobber an in-progress edit.
// `$effect.pre` runs before the first paint, so initial rows show without a
// flash of the empty state. HH:MM is zero-padded, so a lexical sort is
// chronological.
$effect.pre(() => {
const incoming = value ?? '';
untrack(() => {
if (incoming !== lastValue) {
rows = parse(incoming);
lastValue = incoming;
}
});
});
function emit(): void {
const next = distinct(rows).sort().join(',');
// Record before emitting so the value round-trip back through the parent
// isn't mistaken for an external change and used to reset `rows`.
lastValue = next;
onchange(next);
}
// Fired when a row's picker changes. Collapse any on-screen duplicate of an
// already-present time (keeping the first occurrence and any blank
// in-progress row) so the displayed rows match what's persisted — emit()
// dedups the saved value, and without this the screen would keep showing two
// identical rows. Then emit the canonical value.
function onRowChange(): void {
const seen = new Set<string>();
const next: string[] = [];
for (const r of rows) {
const v = r.trim();
if (!v) { next.push(r); continue; }
if (seen.has(v)) continue;
seen.add(v);
next.push(r);
}
if (next.length !== rows.length) rows = next;
emit();
}
const filledCount = $derived(distinct(rows).length);
const hasBlankRow = $derived(rows.some((r) => !r.trim()));
const atMax = $derived(filledCount >= max);
// Block adding while a blank row is open (fill it first) or the cap is hit —
// keeps the list from stacking empty rows and respects the per-day limit.
const canAdd = $derived(!atMax && !hasBlankRow);
function addRow(): void {
if (!canAdd) return;
rows = [...rows, ''];
}
function removeRow(index: number): void {
rows = rows.filter((_, i) => i !== index);
emit();
}
</script>
<div class="time-list">
{#each rows as _, i (i)}
<div class="time-row">
<input
type="time"
bind:value={rows[i]}
onchange={onRowChange}
{placeholder}
aria-label={t('trackingConfig.timeRowLabel').replace('{n}', String(i + 1))}
class="time-input"
/>
<button
type="button"
class="time-remove"
aria-label={t('trackingConfig.removeTime')}
onclick={() => removeRow(i)}
>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
{/each}
{#if rows.length === 0}
<p class="time-empty">
<MdiIcon name="mdiClockOutline" size={12} />
<span>{t('trackingConfig.noTimes')}</span>
</p>
{/if}
{#if atMax}
<p class="time-cap">{t('trackingConfig.maxTimesReached').replace('{n}', String(max))}</p>
{:else}
<button type="button" class="time-add" disabled={!canAdd} onclick={addRow}>
<MdiIcon name="mdiPlus" size={14} />
<span>{t('trackingConfig.addTime')}</span>
</button>
{/if}
</div>
<style>
.time-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.time-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.time-input {
flex: 1;
min-width: 0;
padding: 0.3125rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background: var(--color-background);
color: var(--color-foreground);
font-size: 0.875rem;
line-height: 1.25;
font-variant-numeric: tabular-nums;
}
.time-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
.time-remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: color 0.12s ease, background 0.12s ease;
}
.time-remove:hover {
background: var(--color-muted);
color: var(--color-error-fg);
}
.time-add {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3125rem;
width: 100%;
padding: 0.3125rem 0.5rem;
border: 1px dashed var(--color-border);
border-radius: 0.375rem;
background: transparent;
color: var(--color-muted-foreground);
font-size: 0.8125rem;
line-height: 1.25;
cursor: pointer;
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.time-add:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-muted);
}
.time-add:disabled {
opacity: 0.5;
cursor: default;
}
.time-empty {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--color-muted-foreground);
}
.time-cap {
font-size: 0.6875rem;
color: var(--color-muted-foreground);
text-align: center;
}
</style>
+8 -3
View File
@@ -728,8 +728,13 @@
"deleted": "deleted",
"providerType": "Provider Type",
"sortRandom": "Random",
"timesInlineHelp": "HH:MM, comma-separated",
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
"timesInlineHelp": "One or more times per day",
"addTime": "Add time",
"removeTime": "Remove time",
"timeRowLabel": "Time {n}",
"noTimes": "No times set — add at least one",
"maxTimesReached": "Maximum {n} times reached",
"timesRequiredFor": "Add at least one time for \"{slot}\"",
"previewTemplate": "Preview template",
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
"editTemplate": "Edit template",
@@ -1011,7 +1016,7 @@
"maxAssets": "Maximum number of asset details to include in a single notification message.",
"periodicStartDate": "Reference date in the app timezone. The first summary fires at the next configured HH:MM on/after this date, then every N days.",
"intervalDays": "Days between successive summaries. 1 = daily, 7 = weekly.",
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
"times": "Time(s) of day to send notifications. Add as many time points per day as you need.",
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
"scheduledAlbumMode": "How albums are grouped in scheduled deliveries. Default: Per album (one notification per tracked album).",
"memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).",
+8 -3
View File
@@ -728,8 +728,13 @@
"deleted": "удалён",
"providerType": "Тип провайдера",
"sortRandom": "Случайный",
"timesInlineHelp": "ЧЧ:ММ, через запятую",
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
"timesInlineHelp": "Одно или несколько значений времени в день",
"addTime": "Добавить время",
"removeTime": "Удалить время",
"timeRowLabel": "Время {n}",
"noTimes": "Время не задано — добавьте хотя бы одно",
"maxTimesReached": "Достигнут максимум: {n}",
"timesRequiredFor": "Добавьте хотя бы одно время для «{slot}»",
"previewTemplate": "Предпросмотр шаблона",
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
"editTemplate": "Редактировать шаблон",
@@ -1011,7 +1016,7 @@
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
"times": "Время отправки уведомлений. Добавьте сколько угодно значений времени в день.",
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
+3 -3
View File
@@ -68,14 +68,14 @@ export const immichDescriptor: ProviderDescriptor = {
fields: [
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
],
},
{
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
enabledField: 'scheduled_enabled', enabledDefault: false,
fields: [
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
@@ -87,7 +87,7 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
enabledField: 'memory_enabled', enabledDefault: false,
fields: [
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
+2 -3
View File
@@ -67,7 +67,8 @@ export interface ExtraTrackingField {
* - `toggle` — on/off switch
* - `date` — HTML date picker (YYYY-MM-DD)
* - `time` — HTML time picker (HH:MM)
* - `time-list` — comma-separated HH:MM list, validated on blur
* - `time-list` — add/remove list of HH:MM pickers (TimeListEditor),
* serialized as a comma-separated string
*/
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
/** Grid-select item source function name from grid-items.ts. */
@@ -78,8 +79,6 @@ export interface ExtraTrackingField {
inlineHelp?: string;
min?: number;
max?: number;
/** For time-list: show live validation + auto-normalize on blur. */
validateFormat?: boolean;
/**
* Default value. Can be a function for dynamic values (e.g. today's date)
* evaluated each time the form is reset.
@@ -27,6 +27,7 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
import TimeListEditor from '$lib/components/TimeListEditor.svelte';
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
@@ -34,44 +35,12 @@
};
/**
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
* dispatch accepts. Matched on blur for time-list fields; invalid values
* are surfaced inline next to the input.
* Max distinct dispatch times per slot — mirrors the backend cap
* (`MAX_DISPATCH_TIMES` in services/time_list.py). The TimeListEditor
* disables "+ Add time" once this many rows are filled; the server enforces
* the same limit on write.
*/
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
/** Per-field error messages surfaced inline under time-list inputs. */
let timeListErrors = $state<Record<string, string>>({});
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
function normalizeTimeList(key: string) {
const raw = String(form[key] ?? '').trim();
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
if (!TIME_LIST_RE.test(raw)) {
// Try a lenient normalization: split on commas, zero-pad each part.
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
const fixed: string[] = [];
let ok = true;
for (const p of parts) {
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
if (!m) { ok = false; break; }
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
}
if (ok) {
form[key] = fixed.join(',');
timeListErrors = { ...timeListErrors, [key]: '' };
return;
}
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
return;
}
// Canonicalise spacing.
form[key] = raw.split(',').map(s => s.trim()).join(',');
timeListErrors = { ...timeListErrors, [key]: '' };
}
const MAX_DISPATCH_TIMES = 24;
/**
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
@@ -280,6 +249,18 @@
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
// Descriptor-driven guard: an enabled feature section that uses a
// time-list must have at least one time, otherwise it saves but the
// scheduler creates no cron job and the slot silently never fires.
for (const section of descriptor?.featureSections ?? []) {
const timeField = section.fields.find((f) => f.type === 'time-list');
if (!timeField) continue;
if (form[section.enabledField] && !String(form[timeField.key] ?? '').trim()) {
const msg = t('trackingConfig.timesRequiredFor').replace('{slot}', t(section.legend));
error = msg; snackError(msg);
return;
}
}
try {
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
@@ -413,22 +394,24 @@
</label>
{:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else}
{@const inputType = field.type === 'date' ? 'date'
: field.type === 'time' ? 'time'
: field.type === 'time-list' ? 'text'
: 'number'}
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
<input type={inputType}
bind:value={form[field.key]} min={field.min} max={field.max}
onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
{:else if field.type === 'time-list'}
<TimeListEditor
value={String(form[field.key] ?? '')}
onchange={(v) => form[field.key] = v}
max={MAX_DISPATCH_TIMES} />
{#if field.inlineHelp}
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
{/if}
{#if hasError}
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
{:else}
{@const inputType = field.type === 'date' ? 'date'
: field.type === 'time' ? 'time'
: 'number'}
<input type={inputType}
bind:value={form[field.key]} min={field.min} max={field.max}
placeholder={field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if field.inlineHelp}
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
{/if}
{/if}
</div>