- 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]>
123 lines
3.7 KiB
TypeScript
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;
|
|
}
|