frontend_nyla/src/components/PeriodPicker/PeriodPickerLogic.ts
ValueOn AG fc2cce8732 fixes
2026-04-23 23:09:54 +02:00

266 lines
9.7 KiB
TypeScript

/**
* 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 ``<input type="date">`` ``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);
}