// Europe/Berlin calendar helpers for SLA / cut-off calculations. // // iDempiere REST timestamp semantics (verified on prod 2026-10-27, see // .claude/rules/idempiere/timestamps-timezone.md): // - Created / Updated / DateOrdered-like timestamps are the DB's LOCAL wall-clock time // (Europe/Berlin) with a bogus "Z" suffix → parse with `idempiereLocalToInstant`. // - ProcessedOn is a true epoch in milliseconds. // - Columns we write ourselves with a real UTC ISO string (m_inout.shipping_date from // mark-commissioned / commissioning) come back as the UTC wall-clock with a "Z" → `new Date(str)`. export const BERLIN_TZ = 'Europe/Berlin' export interface LocalParts { y: number m: number // 1-12 d: number h: number mi: number s: number weekday: number // 0 = Sunday … 6 = Saturday } const dtf = new Intl.DateTimeFormat('en-US', { timeZone: BERLIN_TZ, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', weekday: 'short' }) const WEEKDAYS: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } /** Calendar parts of an instant as seen in Berlin. */ export const berlinParts = (instant: Date | number): LocalParts => { const parts = dtf.formatToParts(typeof instant === 'number' ? new Date(instant) : instant) const get = (type: string) => parts.find(p => p.type === type)?.value || '' return { y: Number(get('year')), m: Number(get('month')), d: Number(get('day')), h: Number(get('hour')) % 24, mi: Number(get('minute')), s: Number(get('second')), weekday: WEEKDAYS[get('weekday')] ?? 0 } } /** Berlin UTC offset (ms) in effect at the given instant. */ export const berlinOffsetMs = (instantMs: number): number => { const p = berlinParts(instantMs) return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) - Math.floor(instantMs / 1000) * 1000 } /** Instant (ms) for a Berlin wall-clock time. Handles DST transitions by re-checking the offset. */ export const berlinLocalToInstantMs = (y: number, m: number, d: number, h = 0, mi = 0, s = 0): number => { const asUtc = Date.UTC(y, m - 1, d, h, mi, s) let instant = asUtc - berlinOffsetMs(asUtc) const check = berlinOffsetMs(instant) instant = asUtc - check return instant } /** iDempiere "local time with fake Z" (e.g. "2026-10-27T14:11:08Z") → instant ms, or null. */ export const idempiereLocalToInstantMs = (value: any): number | null => { if (!value) return null const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/) if (!m) return null return berlinLocalToInstantMs(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0)) } /** True-UTC ISO string (our own writes, e.g. shipping_date) → instant ms, or null. */ export const utcIsoToInstantMs = (value: any): number | null => { if (!value) return null let s = String(value).trim().replace(' ', 'T') if (!/(Z|[+-]\d{2}:?\d{2})$/.test(s)) s += 'Z' const t = new Date(s).getTime() return Number.isFinite(t) ? t : null } export const pad2 = (n: number) => String(n).padStart(2, '0') export const dateKey = (p: { y: number; m: number; d: number }) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}` export const monthKey = (p: { y: number; m: number }) => `${p.y}-${pad2(p.m)}` export const timeKey = (p: { h: number; mi: number }) => `${pad2(p.h)}:${pad2(p.mi)}` /** Calendar-day arithmetic on y/m/d without any timezone involvement. */ export const addDays = (p: { y: number; m: number; d: number }, days: number) => { const t = Date.UTC(p.y, p.m - 1, p.d + days) const dt = new Date(t) return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate(), weekday: dt.getUTCDay() } } export const weekdayOf = (p: { y: number; m: number; d: number }) => new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() /** Format an instant as Berlin "dd.mm.yyyy HH:MM". */ export const formatBerlin = (instantMs: number | null | undefined): string => { if (!instantMs && instantMs !== 0) return '' const p = berlinParts(instantMs) return `${pad2(p.d)}.${pad2(p.m)}.${p.y} ${pad2(p.h)}:${pad2(p.mi)}` }