/** * PeriodPicker - pure logic helpers. * * No React, no DOM. All date math is local-date based (no timezone shifting). * Use ISO `YYYY-MM-DD` strings as the wire format. */ import type { PeriodConstraints, PeriodPreset, PeriodPresetKind, PeriodUnit, PeriodValue, } from './PeriodPickerTypes'; // --------------------------------------------------------------------------- // Date primitives // --------------------------------------------------------------------------- const _pad = (n: number): string => String(n).padStart(2, '0'); export function toIsoDate(d: Date): string { return `${d.getFullYear()}-${_pad(d.getMonth() + 1)}-${_pad(d.getDate())}`; } export function fromIsoDate(s: string | null | undefined): Date | null { if (!s) return null; const parts = s.split('-').map(Number); if (parts.length !== 3 || parts.some(Number.isNaN)) return null; return new Date(parts[0], parts[1] - 1, parts[2]); } export function daysInRange(fromIso: string, toIso: string): number { const from = fromIsoDate(fromIso); const to = fromIsoDate(toIso); if (!from || !to) return 0; const ms = to.getTime() - from.getTime(); return Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24)) + 1); } export function todayDate(): Date { const d = new Date(); d.setHours(0, 0, 0, 0); return d; } function _addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r; } function _addMonths(d: Date, n: number): Date { const r = new Date(d); r.setMonth(r.getMonth() + n); return r; } function _addYears(d: Date, n: number): Date { const r = new Date(d); r.setFullYear(r.getFullYear() + n); return r; } function _startOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), 1); } function _endOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth() + 1, 0); } function _startOfYear(d: Date): Date { return new Date(d.getFullYear(), 0, 1); } function _endOfYear(d: Date): Date { return new Date(d.getFullYear(), 11, 31); } function _startOfQuarter(d: Date): Date { return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1); } function _endOfQuarter(d: Date): Date { const s = _startOfQuarter(d); return new Date(s.getFullYear(), s.getMonth() + 3, 0); } function _shiftBy(d: Date, amount: number, unit: PeriodUnit): Date { switch (unit) { case 'day': return _addDays(d, amount); case 'week': return _addDays(d, amount * 7); case 'month': return _addMonths(d, amount); case 'year': return _addYears(d, amount); } } // --------------------------------------------------------------------------- // Preset resolver // --------------------------------------------------------------------------- // Sentinel bounds used when the user picked ``Alle`` (no date filter). We keep // the values *inside* ``PeriodValue`` so downstream code that reads // ``fromDate``/``toDate`` doesn't break; callers that want to forward "no // filter" to the backend should check ``preset.kind === 'allTime'`` and drop // the dates explicitly before building the request. export const ALL_TIME_FROM = '1970-01-01'; export const ALL_TIME_TO = '2999-12-31'; export function resolvePeriod(preset: PeriodPreset, prevValue?: PeriodValue | null): { fromDate: string; toDate: string } { const today = todayDate(); switch (preset.kind) { case 'allTime': return { fromDate: ALL_TIME_FROM, toDate: ALL_TIME_TO }; case 'ytd': return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) }; case 'lastYear': { const ly = _addYears(today, -1); return { fromDate: toIsoDate(_startOfYear(ly)), toDate: toIsoDate(_endOfYear(ly)) }; } case 'nextYear': { const ny = _addYears(today, 1); return { fromDate: toIsoDate(_startOfYear(ny)), toDate: toIsoDate(_endOfYear(ny)) }; } case 'last12Months': return { fromDate: toIsoDate(_addMonths(today, -12)), toDate: toIsoDate(today) }; case 'next12Months': return { fromDate: toIsoDate(today), toDate: toIsoDate(_addMonths(today, 12)) }; case 'thisMonth': return { fromDate: toIsoDate(_startOfMonth(today)), toDate: toIsoDate(_endOfMonth(today)) }; case 'lastMonth': { const lm = _addMonths(today, -1); return { fromDate: toIsoDate(_startOfMonth(lm)), toDate: toIsoDate(_endOfMonth(lm)) }; } case 'thisQuarter': return { fromDate: toIsoDate(_startOfQuarter(today)), toDate: toIsoDate(_endOfQuarter(today)) }; case 'lastQuarter': { const lq = _addMonths(_startOfQuarter(today), -3); return { fromDate: toIsoDate(_startOfQuarter(lq)), toDate: toIsoDate(_endOfQuarter(lq)) }; } case 'lastN': return { fromDate: toIsoDate(_shiftBy(today, -preset.amount, preset.unit)), toDate: toIsoDate(today) }; case 'nextN': return { fromDate: toIsoDate(today), toDate: toIsoDate(_shiftBy(today, preset.amount, preset.unit)) }; case 'custom': // Custom holds whatever was last picked; rely on the previous value if available, // otherwise default to a single-day range at today to give the calendar an anchor. return { fromDate: prevValue?.fromDate || toIsoDate(today), toDate: prevValue?.toDate || toIsoDate(today), }; } } // --------------------------------------------------------------------------- // Constraints // --------------------------------------------------------------------------- export function isDateDisabled(d: Date, cfg: PeriodConstraints): boolean { const min = fromIsoDate(cfg.minDate); const max = fromIsoDate(cfg.maxDate); if (min && d < min) return true; if (max && d > max) return true; if (cfg.direction === 'past' && d > todayDate()) return true; if (cfg.direction === 'future' && d < todayDate()) return true; return false; } const _FUTURE_PRESETS: PeriodPresetKind[] = ['nextYear', 'next12Months', 'nextN']; const _PAST_PRESETS: PeriodPresetKind[] = ['lastYear', 'last12Months', 'lastN', 'lastMonth', 'lastQuarter']; export function isPresetDisabled(kind: PeriodPresetKind, cfg: PeriodConstraints): boolean { if (cfg.enabledPresets && !cfg.enabledPresets.includes(kind)) return true; if (cfg.direction === 'past' && _FUTURE_PRESETS.includes(kind)) return true; if (cfg.direction === 'future' && _PAST_PRESETS.includes(kind)) return true; return false; } export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints): boolean { if (!value) return false; if (isPresetDisabled(value.preset.kind, cfg)) return false; if (value.preset.kind === 'custom') { const f = fromIsoDate(value.fromDate); const tt = fromIsoDate(value.toDate); if (!f || !tt) return false; if (isDateDisabled(f, cfg)) return false; if (isDateDisabled(tt, cfg)) return false; } return true; } // Clamp an ISO date to the direction/min/max window defined by ``cfg``. Used // for ```` ``min``/``max`` attributes so the browser // refuses invalid years instead of us silently falling back to the default // preset afterwards. export function clampIsoDate(iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined { const today = toIsoDate(todayDate()); let lo: string | undefined = cfg.minDate; let hi: string | undefined = cfg.maxDate; if (cfg.direction === 'past') hi = hi && hi < today ? hi : today; if (cfg.direction === 'future') lo = lo && lo > today ? lo : today; if (side === 'min') return lo; return hi; } // --------------------------------------------------------------------------- // Label formatting // --------------------------------------------------------------------------- /** * Returns the human label for a preset kind. Caller wraps with `t()` because * `t()` only accepts string literals (no variables). */ export function presetLiteralKey(kind: PeriodPresetKind): string { switch (kind) { case 'allTime': return 'Alle'; case 'ytd': return 'Laufendes Jahr'; case 'lastYear': return 'Letztes Jahr'; case 'nextYear': return 'Nächstes Jahr'; case 'last12Months': return 'Letzte 12 Monate'; case 'next12Months': return 'Nächste 12 Monate'; case 'thisMonth': return 'Dieser Monat'; case 'lastMonth': return 'Letzter Monat'; case 'thisQuarter': return 'Dieses Quartal'; case 'lastQuarter': return 'Letztes Quartal'; case 'lastN': return 'Letzte N'; case 'nextN': return 'Nächste N'; case 'custom': return 'Benutzerdefiniert'; } } export function formatIsoDateDe(iso: string): string { const d = fromIsoDate(iso); if (!d) return iso; return `${_pad(d.getDate())}.${_pad(d.getMonth() + 1)}.${d.getFullYear()}`; } // --------------------------------------------------------------------------- // Calendar grid helper // --------------------------------------------------------------------------- export interface CalendarCell { date: Date; iso: string; inMonth: boolean; isToday: boolean; } /** * Returns 6x7 = 42 cells starting on Monday for the given month anchor. */ export function buildMonthCells(anchor: Date): CalendarCell[] { const start = _startOfMonth(anchor); const leading = (start.getDay() + 6) % 7; // Monday-first const today = todayDate(); const cells: CalendarCell[] = []; for (let i = 0; i < 42; i++) { const d = _addDays(start, i - leading); cells.push({ date: d, iso: toIsoDate(d), inMonth: d.getMonth() === anchor.getMonth(), isToday: _isSameDay(d, today), }); } return cells; } export function _isSameDay(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } export function addMonthsToDate(d: Date, n: number): Date { return _addMonths(d, n); } export function startOfMonth(d: Date): Date { return _startOfMonth(d); }