Files
ApiCore/ApiCore-Backend/src/common/dates.ts
T
Nick SzaunigandClaude Opus 5.5 fab7f7e12f feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 22:47:17 +02:00

123 lines
3.7 KiB
TypeScript

const BERLIN_TIME_ZONE = 'Europe/Berlin';
const MS_PER_DAY = 86_400_000;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
// en-CA formats a date as YYYY-MM-DD.
const berlinDayFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: BERLIN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const berlinPartsFormat = new Intl.DateTimeFormat('en-GB', {
timeZone: BERLIN_TIME_ZONE,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
/** The calendar day (YYYY-MM-DD) of an instant in Europe/Berlin. */
export function berlinDayOf(instant: Date): string {
return berlinDayFormat.format(instant);
}
/** Today's calendar day (YYYY-MM-DD) in Europe/Berlin. */
export function berlinToday(now: Date = new Date()): string {
return berlinDayOf(now);
}
/** A stored day (YYYY-MM-DD) as the DateTime used for dates: midnight UTC. */
export function dayToDate(day: string): Date {
if (!DAY_PATTERN.test(day)) {
throw new Error(`Invalid day: ${day}`);
}
const date = new Date(`${day}T00:00:00.000Z`);
// 2026-02-30 parses to March 2nd: only a day that round-trips exists.
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== day) {
throw new Error(`Invalid day: ${day}`);
}
return date;
}
function dayNumber(day: string): number {
return Math.round(dayToDate(day).getTime() / MS_PER_DAY);
}
/** Calendar arithmetic on YYYY-MM-DD strings; no time zone involved. */
export function addDays(day: string, days: number): string {
const result = new Date(dayToDate(day).getTime() + days * MS_PER_DAY);
return result.toISOString().slice(0, 10);
}
/**
* Calendar months on a YYYY-MM-DD string. A day the target month does not
* have is cut to its last day: 2027-01-31 + 1 month is 2027-02-28.
*/
export function addMonthsClamped(day: string, months: number): string {
const start = dayToDate(day);
const monthIndex = start.getUTCMonth() + months;
const lastDay = new Date(
Date.UTC(start.getUTCFullYear(), monthIndex + 1, 0),
).getUTCDate();
return new Date(
Date.UTC(
start.getUTCFullYear(),
monthIndex,
Math.min(start.getUTCDate(), lastDay),
),
)
.toISOString()
.slice(0, 10);
}
/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
export function daysBetween(from: string, to: string): number {
return dayNumber(to) - dayNumber(from);
}
/** Offset of Europe/Berlin to UTC at an instant, in milliseconds. */
function berlinOffsetMs(instant: Date): number {
const parts: Record<string, number> = {};
for (const part of berlinPartsFormat.formatToParts(instant)) {
if (part.type !== 'literal') {
parts[part.type] = Number(part.value);
}
}
const asUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
// The instant without its milliseconds: the formatter works in seconds.
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* The instant at which the Berlin wall clock shows `day` `hhmm`. The offset
* is taken at the target day itself (applied twice, so the day a clock change
* happens comes out right): 06:30 is 04:30Z in summer and 05:30Z in winter.
*/
export function berlinDateTime(day: string, hhmm: string): Date {
const time = TIME_PATTERN.exec(hhmm);
if (!time) {
throw new Error(`Invalid time: ${hhmm}`);
}
const wallClockAsUtc =
dayToDate(day).getTime() +
(Number(time[1]) * 60 + Number(time[2])) * 60_000;
let instant = new Date(
wallClockAsUtc - berlinOffsetMs(new Date(wallClockAsUtc)),
);
instant = new Date(wallClockAsUtc - berlinOffsetMs(instant));
return instant;
}