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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

+101
View File
@@ -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;
}