feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { describeWeatherCode, weatherPrefillFor } from "./describe";
import type { DailyWeather } from "./types";
function day(overrides: Partial<DailyWeather> = {}): DailyWeather {
return {
date: "2026-10-13",
tempMaxC: 21.54,
tempMinC: 9,
precipitationMm: 0.4,
windSpeedMaxKmh: 12,
inspectionSuitable: true,
weatherCode: 2,
...overrides,
};
}
describe("describeWeatherCode", () => {
it.each([
[0, "Sonnig"],
[1, "Überwiegend sonnig"],
[2, "Teilweise bewölkt"],
[3, "Bedeckt"],
[45, "Nebel"],
[48, "Nebel"],
[53, "Nieselregen"],
[61, "Regen"],
[67, "Regen"],
[75, "Schnee"],
[81, "Regenschauer"],
[86, "Schneeschauer"],
[95, "Gewitter"],
[99, "Gewitter"],
])("describes code %i as %s", (code, text) => {
expect(describeWeatherCode(code)).toBe(text);
});
it("returns null for unknown, missing or null codes", () => {
expect(describeWeatherCode(4)).toBeNull();
expect(describeWeatherCode(null)).toBeNull();
expect(describeWeatherCode(undefined)).toBeNull();
});
});
describe("weatherPrefillFor", () => {
it("builds text and temperature for the chosen date", () => {
const prefill = weatherPrefillFor([day({ date: "2026-10-12" }), day()], "2026-10-13");
expect(prefill).toEqual({
weather: "Teilweise bewölkt, 0.4 mm, Wind 12 km/h",
temperature: "21.5",
});
});
it("leaves out parts that are null", () => {
const prefill = weatherPrefillFor(
[day({ weatherCode: null, precipitationMm: null })],
"2026-10-13",
);
expect(prefill).toEqual({ weather: "Wind 12 km/h", temperature: "21.5" });
});
it("returns null outside the weather window", () => {
expect(weatherPrefillFor([day()], "2026-09-01")).toBeNull();
expect(weatherPrefillFor([], "2026-10-13")).toBeNull();
});
it("returns null when the day holds nothing usable", () => {
const empty = day({
weatherCode: null,
precipitationMm: null,
windSpeedMaxKmh: null,
tempMaxC: null,
});
expect(weatherPrefillFor([empty], "2026-10-13")).toBeNull();
});
});
@@ -0,0 +1,55 @@
import { toCalendarDay } from "@/lib/dates";
import type { DailyWeather } from "./types";
/** WMO weather codes in plain German, as the beekeeper would say it. */
const WEATHER_CODE_RANGES: readonly { from: number; to: number; text: string }[] = [
{ from: 0, to: 0, text: "Sonnig" },
{ from: 1, to: 1, text: "Überwiegend sonnig" },
{ from: 2, to: 2, text: "Teilweise bewölkt" },
{ from: 3, to: 3, text: "Bedeckt" },
{ from: 45, to: 48, text: "Nebel" },
{ from: 51, to: 57, text: "Nieselregen" },
{ from: 61, to: 67, text: "Regen" },
{ from: 71, to: 77, text: "Schnee" },
{ from: 80, to: 82, text: "Regenschauer" },
{ from: 85, to: 86, text: "Schneeschauer" },
{ from: 95, to: 99, text: "Gewitter" },
];
/** `null` for a missing or unknown code: nothing is guessed. */
export function describeWeatherCode(code: number | null | undefined): string | null {
if (code === null || code === undefined) return null;
const match = WEATHER_CODE_RANGES.find((range) => code >= range.from && code <= range.to);
return match?.text ?? null;
}
export interface WeatherPrefill {
weather: string;
temperature: string;
}
/**
* The inspection's weather fields for one day of the apiary's weather data:
* the description with precipitation and wind (parts that are unknown are
* left out) and the day's maximum temperature with one decimal. `null`
* outside the forecast window or when the day holds nothing usable.
*/
export function weatherPrefillFor(
daily: readonly DailyWeather[],
isoDate: string,
): WeatherPrefill | null {
const day = daily.find((entry) => toCalendarDay(entry.date) === isoDate);
if (!day) return null;
const parts = [
describeWeatherCode(day.weatherCode),
day.precipitationMm != null ? `${day.precipitationMm} mm` : null,
day.windSpeedMaxKmh != null ? `Wind ${day.windSpeedMaxKmh} km/h` : null,
].filter((part): part is string => part !== null);
const weather = parts.join(", ");
const temperature = day.tempMaxC != null ? day.tempMaxC.toFixed(1) : "";
if (weather === "" && temperature === "") return null;
return { weather, temperature };
}
@@ -5,6 +5,8 @@ export interface DailyWeather {
precipitationMm: number | null;
windSpeedMaxKmh: number | null;
inspectionSuitable: boolean | null;
/** WMO weather code; null until the backend delivers it. */
weatherCode: number | null;
}
export interface ApiaryWeather {