feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers - Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as ColonyCondition with legacy text mapping), split queen source, varroa sample size; migration harness for data migrations - SuggestionsService: propose/refresh/resolve/obsolete/expire, accept/dismiss/reopen incl. bulk, lock order, concurrency e2e - SeasonService: phenology-based segments, inspection/varroa due dates, queen age; TimeEventsStep with JobPartialFailure - Frontend: per-rule settings forms with visible value sources Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
Reference in new issue
Block a user