266 lines
9.7 KiB
TypeScript
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);
|
|
}
|