diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx index d08aa2e..5791b64 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.spec.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import DashboardPage from "./page"; import { fetchAutopilotToday } from "@/lib/autopilot/api"; import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures"; -import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; +import { fetchDashboardAlerts } from "@/lib/dashboard/api"; import { completeTask, fetchTasks } from "@/lib/tasks/api"; import { fetchColonies } from "@/lib/colonies/api"; import { ApiError } from "@/lib/auth/api-client"; @@ -17,7 +17,6 @@ vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1", })); vi.mock("@/lib/dashboard/api", () => ({ - fetchDashboardSummary: vi.fn(), fetchDashboardAlerts: vi.fn(), })); vi.mock("@/lib/tasks/api", () => ({ @@ -73,14 +72,6 @@ describe("DashboardPage – Heute", () => { }); beforeEach(() => { - vi.mocked(fetchDashboardSummary).mockResolvedValue({ - apiariesActive: 1, - coloniesActive: 2, - queensActive: 2, - openTasks: 1, - honeyBatchesTotal: 0, - stockItemsLowCount: 0, - } as never); vi.mocked(fetchDashboardAlerts).mockResolvedValue([]); vi.mocked(fetchTasks).mockResolvedValue([overdueTask()]); vi.mocked(fetchAutopilotToday).mockReset(); @@ -114,7 +105,7 @@ describe("DashboardPage – Heute", () => { expect(await within(fallback).findByText("Stock prüfen")).toBeInTheDocument(); }); - it("shows a „Durchsicht fällig“ card per apiary, the suggestion count and the season per apiary", async () => { + it("shows a „Durchsicht fällig“ card per apiary and the suggestion count", async () => { vi.mocked(fetchTasks).mockResolvedValue([]); vi.mocked(fetchAutopilotToday).mockResolvedValue( todayFixture({ @@ -137,7 +128,49 @@ describe("DashboardPage – Heute", () => { await screen.findByRole("heading", { name: /Durchsicht fällig · Obstwiese/ }), ).toBeInTheDocument(); expect(screen.getByRole("link", { name: /Vorschläge 4/ })).toHaveAttribute("href", "/suggestions"); - expect(screen.getByText("Obstwiese: Schwarmzeit")).toBeInTheDocument(); + }); + + it("the dashboard no longer shows the season chip row", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + seasons: [ + { apiaryId: "a-1", apiaryName: "Obstwiese", segment: "SCHWARMZEIT", segmentSince: "2026-04-15" }, + ], + }), + ); + renderPage(); + + // The season moved into the centre of the bee-year wheel (spec 9.3). + const wheel = (await screen.findByRole("heading", { name: "Bienenjahr" })).closest("section")!; + expect(await within(wheel).findByText(/^Jetzt: Schwarmzeit seit 15\.04\./)).toBeInTheDocument(); + expect(screen.queryByText("Obstwiese: Schwarmzeit")).not.toBeInTheDocument(); + expect(screen.queryByRole("list", { name: /Saison/ })).not.toBeInTheDocument(); + }); + + it("the dashboard has no count tiles and no white text on honey", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({})); + vi.mocked(fetchDashboardAlerts).mockResolvedValue([ + { type: "LOW_STOCK", message: "Gläser unter Minimum", relatedType: "StockItem", relatedId: "s1" }, + ]); + const { container } = renderPage(); + + const wheel = await screen.findByRole("heading", { level: 2, name: "Bienenjahr" }); + expect(screen.queryByRole("heading", { name: "Überblick" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /Völker|Honigchargen|unter Minimum/ })).not.toBeInTheDocument(); + // „Bienenjahr“ is the last section, after „Im Blick behalten“ (spec 9.3). + const alerts = await screen.findByRole("heading", { name: "Im Blick behalten" }); + expect(alerts.compareDocumentPosition(wheel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(container.innerHTML).not.toMatch(/(^|[\s"])text-white\b/); + }); + + it("the dashboard still works without T4 data", async () => { + vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch")); + renderPage(); + + expect(await screen.findByText("Stock prüfen")).toBeInTheDocument(); + const wheel = (await screen.findByRole("heading", { name: "Bienenjahr" })).closest("section")!; + expect(within(wheel).getByRole("img", { name: /Bienenjahr \d{4}/ })).toBeInTheDocument(); + expect(within(wheel).getByText(/^Jetzt: .* \(Kalender\)/)).toBeInTheDocument(); }); it("falls back silently for a network error and a 403, but says so for other errors and allows a retry", async () => { @@ -211,7 +244,8 @@ describe("DashboardPage – Heute", () => { vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({})); renderPage(); - await screen.findByRole("heading", { name: "Überblick" }); + // Wait for the data, not just the wheel (it renders from the calendar at once). + await screen.findByText("Stock prüfen"); expect(screen.queryByText(/^Stand:/)).not.toBeInTheDocument(); }); diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx index 5616432..e4b2244 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx @@ -6,11 +6,8 @@ import { useQuery } from "@tanstack/react-query"; import { AlertTriangle, Crown, - Droplet, ListTodo, type LucideIcon, - MapPin, - Layers, PackageSearch, ShieldAlert, Sprout, @@ -19,13 +16,14 @@ import { ApiError } from "@/lib/auth/api-client"; import { useAuth } from "@/lib/auth/auth-context"; import { isNetworkError } from "@/lib/http/fetch-with-timeout"; import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api"; -import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; +import { fetchDashboardAlerts } from "@/lib/dashboard/api"; import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types"; import { fetchTasks } from "@/lib/tasks/api"; import { tasksForToday } from "@/lib/tasks/today"; import { Button } from "@/components/ui/button"; import { AppearanceNotice } from "@/components/appearance-notice"; import { StandNote } from "@/components/offline/stand-note"; +import { YearWheel } from "@/components/season/year-wheel"; import { ComplianceLine } from "@/components/today/compliance-line"; import { TodayPanel } from "@/components/today/today-panel"; @@ -49,14 +47,6 @@ function todaySummary(count: number): string { return `Heute stehen ${count} Sachen an – eine nach der anderen.`; } -interface StatTile { - label: string; - value: number; - icon: LucideIcon; - href: string; - attention?: boolean; -} - const ALERT_META: Record< DashboardAlertType, { label: string; icon: LucideIcon; className: string } @@ -91,11 +81,6 @@ const ALERT_META: Record< export default function DashboardPage() { const { user } = useAuth(); const [now] = useState(() => new Date()); - const summaryQuery = useQuery({ - queryKey: ["dashboard", "summary"], - queryFn: fetchDashboardSummary, - }); - const { data: summary } = summaryQuery; const alertsQuery = useQuery({ queryKey: ["dashboard", "alerts"], queryFn: fetchDashboardAlerts, @@ -124,37 +109,15 @@ export default function DashboardPage() { const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK"); const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null; // Offline everything shown is from the cache: the oldest part sets the „Stand“. - const loadedAt = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery] + const loadedAt = [alertsQuery, tasksQuery, autopilotQuery] .map((query) => query.dataUpdatedAt) .filter((updatedAt) => updatedAt > 0); - const hasStaleData = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery].some( + const hasStaleData = [alertsQuery, tasksQuery, autopilotQuery].some( (query) => query.isError && query.data !== undefined, ); const standAt = loadedAt.length > 0 ? Math.min(...loadedAt) : 0; const firstName = user?.name.trim().split(/\s+/)[0]; - const tiles: StatTile[] = summary - ? [ - { label: "Bienenstände", value: summary.apiariesActive, icon: MapPin, href: "/apiaries" }, - { label: "Völker", value: summary.coloniesActive, icon: Layers, href: "/colonies" }, - { label: "Königinnen", value: summary.queensActive, icon: Crown, href: "/queens" }, - { label: "Offene Aufgaben", value: summary.openTasks, icon: ListTodo, href: "/tasks" }, - { - label: "Honigchargen", - value: summary.honeyBatchesTotal, - icon: Droplet, - href: "/honey-batches", - }, - { - label: "unter Minimum", - value: summary.stockItemsLowCount, - icon: AlertTriangle, - href: "/storage", - attention: summary.stockItemsLowCount > 0, - }, - ] - : []; - return (
@@ -235,37 +198,7 @@ export default function DashboardPage() { ) : null} - {tiles.length > 0 ? ( -
-

- Überblick -

-
    - {tiles.map((tile) => { - const Icon = tile.icon; - return ( -
  • - - - - {tile.value} - - - {tile.label} - - -
  • - ); - })} -
-
- ) : null} +
); } diff --git a/ApiCore-Frontend/src/components/season/year-wheel-table.tsx b/ApiCore-Frontend/src/components/season/year-wheel-table.tsx new file mode 100644 index 0000000..1493227 --- /dev/null +++ b/ApiCore-Frontend/src/components/season/year-wheel-table.tsx @@ -0,0 +1,105 @@ +import { formatDayMonth } from "@/lib/autopilot/format"; +import type { SeasonOfApiary } from "@/lib/autopilot/types"; +import type { YearWheelModel } from "@/lib/season/year-wheel"; +import { SEGMENT_LABELS } from "@/components/autopilot/season-chip"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + WINDOW_LABELS, + nextFourWeeks, + segmentEndText, + segmentStartText, + sourceText, +} from "./year-wheel-text"; + +const CAPTION = "caption-top px-3 py-2 text-left text-sm font-medium"; + +interface YearWheelTableProps { + model: YearWheelModel; + /** The apiaries' seasons; each gets a column when they do not all agree. */ + seasons: SeasonOfApiary[]; +} + +/** The wheel as tables (spec 9.4): segments, work windows and the tasks of the next four weeks. */ +export function YearWheelTable({ model, seasons }: YearWheelTableProps) { + const apiaries = model.deviations.length > 0 ? seasons : []; + const weeks = nextFourWeeks(model); + return ( +
+ + + + + Abschnitt + von + bis + Quelle + {apiaries.map((season) => ( + {season.apiaryName} + ))} + + + + {model.segments.map((segment, index) => ( + + {SEGMENT_LABELS[segment.key]} + {segmentStartText(segment)} + {segmentEndText(model, index)} + {segment.current ? sourceText(model) : "Kalender"} + {apiaries.map((season) => ( + + {season.segment !== segment.key + ? "–" + : season.segmentSince + ? `jetzt, seit ${formatDayMonth(season.segmentSince)}` + : "jetzt"} + + ))} + + ))} + +
Abschnitte des Bienenjahrs
+ + + + + Fenster + von + bis + + + + {model.windows.map((window) => ( + + {WINDOW_LABELS[window.key]} + {formatDayMonth(window.from)} + {formatDayMonth(window.to)} + + ))} + +
Arbeitsfenster
+ + + + + Woche + {weeks.map((week) => ( + {`ab ${formatDayMonth(week.weekStart)}`} + ))} + überfällig + + + + + Aufgaben je Woche + {weeks.map((week) => ( + + {week.count} + + ))} + {model.overdueCount} + + +
Aufgaben je Woche
+
+ ); +} diff --git a/ApiCore-Frontend/src/components/season/year-wheel-text.ts b/ApiCore-Frontend/src/components/season/year-wheel-text.ts new file mode 100644 index 0000000..70995f8 --- /dev/null +++ b/ApiCore-Frontend/src/components/season/year-wheel-text.ts @@ -0,0 +1,144 @@ +import { formatDayMonth } from "@/lib/autopilot/format"; +import type { SeasonNote } from "@/lib/phenology/types"; +import { phaseSourceText } from "@/lib/phenology/labels"; +import type { YearWheelModel, YearWheelSegment, YearWheelWindow, YearWheelWindowKey } from "@/lib/season/year-wheel"; +import { SEGMENT_LABELS } from "@/components/autopilot/season-chip"; + +export const WINDOW_LABELS: Record = { + TRACHT: "Tracht", + VARROA_SUMMER: "Varroa-Sommerbehandlung", + EINWINTERUNG: "Einwinterung", + VARROA_WINTER: "Varroa-Winterbehandlung", +}; + +/** Short enough for the wheel's centre at 320 px in sun (16 px): „Einwinterung“ is the longest. */ +const CENTRE_WINDOW_LABELS: Record = { + TRACHT: "Tracht", + VARROA_SUMMER: "Varroa", + EINWINTERUNG: "Einwinterung", + VARROA_WINTER: "Varroa", +}; + +/** Lower case: the note follows the window in the line below the wheel. */ +const NOTE_TEXT: Record, string> = { + NO_COORDINATES: "ohne Koordinaten", + WEATHER_GAPS: "Wetterdaten lückenhaft", + WEATHER_STALE: "Wetterdaten veraltet", +}; + +const DAY_MS = 86_400_000; +const addDays = (date: string, days: number): string => + new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10); + +/** The Monday of the week of `date` (YYYY-MM-DD). */ +export function mondayOf(date: string): string { + return addDays(date, -((new Date(`${date}T00:00:00Z`).getUTCDay() + 6) % 7)); +} + +/** The four weeks from this one on, with their open tasks (spec 9.4). */ +export function nextFourWeeks(model: YearWheelModel): { weekStart: string; count: number }[] { + const monday = mondayOf(model.today); + return [0, 1, 2, 3].map((week) => { + const weekStart = addDays(monday, week * 7); + return { weekStart, count: model.taskWeeks.find((entry) => entry.weekStart === weekStart)?.count ?? 0 }; + }); +} + +/** „ab morgen, noch offen“ for a late bloom, „ca. 15.11.“ for a calendar boundary, else „15.09.“. */ +export function segmentStartText(segment: YearWheelSegment): string { + if (segment.lateStart) return "ab morgen, noch offen"; + return `${segment.estimated ? "ca. " : ""}${formatDayMonth(segment.from)}`; +} + +/** Where the segment at `index` ends: the next one's start, „noch offen“ before a late bloom. */ +export function segmentEndText(model: YearWheelModel, index: number): string { + const next = model.segments[index + 1]; + if (next?.lateStart) return "noch offen"; + return `ca. ${formatDayMonth(model.segments[index].to)}`; +} + +export function sourceText(model: YearWheelModel): string { + return phaseSourceText(model.segments[0].source, model.reference?.reportedOn ?? null) ?? "Kalender"; +} + + +function windowsInOrder(model: YearWheelModel): YearWheelWindow[] { + return [...model.windows, ...model.nextWindows]; +} + +export function runningWindow(model: YearWheelModel): YearWheelWindow | null { + return windowsInOrder(model).find((window) => window.from <= model.today && model.today < window.to) ?? null; +} + +function comingWindow(model: YearWheelModel): YearWheelWindow | null { + return windowsInOrder(model).find((window) => window.from >= model.today && window !== runningWindow(model)) ?? null; +} + +/** The centre's lines under the segment; the running window as name (wide wheel) or end (narrow). */ +export function centreLines(model: YearWheelModel): { since: string; window: { name: string; end: string } | null } { + const running = runningWindow(model); + return { + since: `seit ${formatDayMonth(model.segments[0].from)}`, + window: running ? { name: CENTRE_WINDOW_LABELS[running.key], end: `bis ${formatDayMonth(running.to)}` } : null, + }; +} + +/** „Südhang: noch Frühjahr“, or how many apiaries deviate. */ +function deviationText(model: YearWheelModel): string | null { + if (model.deviations.length > 1) return `${model.deviations.length} Stände weichen ab – siehe Tabelle`; + const [deviation] = model.deviations; + if (!deviation) return null; + // A segment begun before the reference one is behind („noch“), a later one ahead („schon“). + const word = deviation.since < model.segments[0].from ? "noch" : "schon"; + return `${deviation.apiaryName}: ${word} ${SEGMENT_LABELS[deviation.segment]}`; +} + +/** Below the wheel: the running window in full, a weak estimate and deviating apiaries. */ +export function detailLine(model: YearWheelModel): string | null { + const running = runningWindow(model); + const note = model.reference?.note; + const parts = [ + running ? `${WINDOW_LABELS[running.key]} bis ca. ${formatDayMonth(running.to)}` : null, + note ? NOTE_TEXT[note] : null, + deviationText(model), + ].filter(Boolean); + return parts.length > 0 ? parts.join(" · ") : null; +} + +/** „Jetzt: Herbst seit 15.09. (Kalender) · Danach: Winterruhe ab ca. 15.11.“ */ +export function nowAndNext(model: YearWheelModel): string { + const [current, next] = model.segments; + const start = segmentStartText(next); + const nextText = next.lateStart ? start : `ab ${start}`; + return `Jetzt: ${SEGMENT_LABELS[current.key]} seit ${formatDayMonth(current.from)} (${sourceText(model)}) · Danach: ${SEGMENT_LABELS[next.key]} ${nextText}`; +} + +function sourcePhrase(model: YearWheelModel): string { + const source = model.segments[0].source; + return source === "CALENDAR" ? "nach Kalender" : sourceText(model); +} + +/** A date like „20.09.“ already ends the sentence. */ +const sentence = (text: string): string => (text.endsWith(".") ? text : `${text}.`); + +/** The SVG description (spec 9.4). */ +export function wheelDescription(model: YearWheelModel): string { + const current = model.segments[0]; + const running = runningWindow(model); + const coming = comingWindow(model); + const windowText = (window: YearWheelWindow): string => + `${WINDOW_LABELS[window.key]} ${formatDayMonth(window.from)} bis ${formatDayMonth(window.to)}`; + const weeks = nextFourWeeks(model).reduce((sum, week) => sum + week.count, 0); + const tasks = + weeks + model.overdueCount === 0 + ? "Keine offenen Aufgaben mit Termin in den nächsten 4 Wochen." + : `Offene Aufgaben mit Termin: ${weeks} in den nächsten 4 Wochen${model.overdueCount > 0 ? `, ${model.overdueCount} überfällig` : ""}.`; + return [ + sentence(`Heute, ${formatDayMonth(model.today)}: ${SEGMENT_LABELS[current.key]} seit ${formatDayMonth(current.from)} ${sourcePhrase(model)}`), + running ? sentence(`${WINDOW_LABELS[running.key]} läuft bis etwa ${formatDayMonth(running.to)}`) : null, + coming ? sentence(`${running ? "Danach" : "Als Nächstes"} ${windowText(coming)}`) : null, + tasks, + ] + .filter(Boolean) + .join(" "); +} diff --git a/ApiCore-Frontend/src/components/season/year-wheel.spec.tsx b/ApiCore-Frontend/src/components/season/year-wheel.spec.tsx new file mode 100644 index 0000000..9188a9f --- /dev/null +++ b/ApiCore-Frontend/src/components/season/year-wheel.spec.tsx @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fetchAutopilotToday } from "@/lib/autopilot/api"; +import { todayFixture } from "@/lib/autopilot/step-fixtures"; +import type { SeasonOfApiary } from "@/lib/autopilot/types"; +import { fetchTasks } from "@/lib/tasks/api"; +import type { Task } from "@/lib/tasks/types"; +import { YearWheel } from "./year-wheel"; + +vi.mock("@/lib/tasks/api", () => ({ fetchTasks: vi.fn() })); +vi.mock("@/lib/autopilot/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchAutopilotToday: vi.fn(), +})); + +function task(id: string, dueDate: string): Task { + return { + id, + title: `Aufgabe ${id}`, + description: null, + priority: "NORMAL", + dueDate: `${dueDate}T00:00:00.000Z`, + status: "OPEN", + assignedUserId: null, + createdByUserId: null, + relatedType: "GENERAL", + relatedId: null, + sourceInspectionId: null, + sourceTemplateId: null, + sourceAutomationTrigger: null, + createdAt: "2026-10-01T00:00:00.000Z", + completedAt: null, + }; +} + +function season(overrides: Partial): SeasonOfApiary { + return { + apiaryId: "a-1", + apiaryName: "Obstwiese", + segment: "HERBST", + phaseSource: "CALENDAR", + segmentSince: "2026-09-15", + reportedOn: null, + note: null, + ...overrides, + }; +} + +function renderWheel() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("YearWheel", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-14T10:00:00")); + // Wednesday 14.10.: one overdue, three in the next four weeks, one far ahead. + vi.mocked(fetchTasks).mockResolvedValue([ + task("late", "2026-10-10"), + task("w1", "2026-10-16"), + task("w2", "2026-10-21"), + task("w3", "2026-11-04"), + task("far", "2027-03-01"), + ]); + vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({ seasons: [season({})] })); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("the centre shows the segment, the source and the running window", async () => { + const { container } = renderWheel(); + + expect(await screen.findByRole("heading", { level: 2, name: "Bienenjahr" })).toBeInTheDocument(); + // The title names the image, the description describes it (not both in the name). + const wheel = screen.getByRole("img", { name: "Bienenjahr 2026" }); + await waitFor(() => + expect(wheel).toHaveAccessibleDescription( + "Heute, 14.10.: Herbst seit 15.09. nach Kalender. Einwinterung läuft bis etwa 15.11. Danach Varroa-Winterbehandlung 15.11. bis 20.12. Offene Aufgaben mit Termin: 3 in den nächsten 4 Wochen, 1 überfällig.", + ), + ); + // The centre fits the free circle: title plus two short lines; the long forms sit below. + const centre = container.querySelector('[data-part="centre"]') as HTMLElement; + expect(centre.children).toHaveLength(3); + expect(within(centre).getByText("Herbst")).toBeInTheDocument(); + expect(within(centre).getByText("seit 15.09.")).toBeInTheDocument(); + expect(within(centre).getByText("Einwinterung")).toBeInTheDocument(); + expect(within(centre).getByText("bis 15.11.")).toBeInTheDocument(); + // The box follows the wheel size (container units), line 3 shortens by container query. + expect(centre.className).toMatch(/cqw/); + expect(within(centre).getByText("Einwinterung").className).toMatch(/@min-/); + expect(screen.getByText("Einwinterung bis ca. 15.11.")).toBeInTheDocument(); + expect(screen.getByText("Jetzt: Herbst seit 15.09. (Kalender) · Danach: Winterruhe ab ca. 15.11.")).toBeInTheDocument(); + // All text is HTML at fixed sizes; SVG text would shrink with the viewBox. + expect(wheel.querySelector("text")).toBeNull(); + expect(wheel.querySelectorAll("a, button, [tabindex]")).toHaveLength(0); + }); + + it("names the report date below the wheel and keeps the weak estimate out of the centre", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + seasons: [season({ phaseSource: "REPORT", reportedOn: "2026-09-20", segmentSince: "2026-09-20", note: "WEATHER_GAPS" })], + }), + ); + const { container } = renderWheel(); + + expect(await screen.findByText(/^Jetzt: Herbst seit 20\.09\. \(gemeldet am 20\.09\.\)/)).toBeInTheDocument(); + expect(screen.getByText("Einwinterung bis ca. 15.11. · Wetterdaten lückenhaft")).toBeInTheDocument(); + const centre = container.querySelector('[data-part="centre"]') as HTMLElement; + expect(centre).not.toHaveTextContent(/Wetterdaten/); + // „20.09.“ ends the sentence: no double dot. + const wheel = screen.getByRole("img", { name: "Bienenjahr 2026" }); + await waitFor(() => expect(wheel).toHaveAccessibleDescription(/seit 20\.09\. gemeldet am 20\.09\. Einwinterung/)); + expect(container.querySelector("desc")?.textContent).not.toMatch(/\.\./); + }); + + it("„Als Tabelle“ opens the segment and window tables with the weekly task row", async () => { + renderWheel(); + const toggle = await screen.findByRole("button", { name: "Als Tabelle" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + // aria-controls always points at an element; closed it is hidden. + const panel = document.getElementById(toggle.getAttribute("aria-controls") ?? ""); + expect(panel).not.toBeNull(); + expect(panel).not.toBeVisible(); + + await userEvent.click(toggle); + + expect(toggle).toHaveAttribute("aria-expanded", "true"); + const segments = await screen.findByRole("table", { name: "Abschnitte des Bienenjahrs" }); + expect(within(segments).getAllByRole("columnheader").map((cell) => cell.textContent)).toEqual([ + "Abschnitt", + "von", + "bis", + "Quelle", + ]); + const herbst = within(segments).getByRole("row", { name: /Herbst/ }); + expect(within(herbst).getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["15.09.", "ca. 15.11.", "Kalender"]); + const windows = screen.getByRole("table", { name: "Arbeitsfenster" }); + expect(within(windows).getByRole("row", { name: /Einwinterung/ })).toHaveTextContent("15.09.15.11."); + const weeks = screen.getByRole("table", { name: "Aufgaben je Woche" }); + const row = within(weeks).getByRole("row", { name: /Aufgaben je Woche/ }); + expect(within(row).getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["1", "1", "0", "1", "1"]); + expect(within(weeks).getAllByRole("columnheader").map((cell) => cell.textContent)).toEqual([ + "Woche", + "ab 12.10.", + "ab 19.10.", + "ab 26.10.", + "ab 02.11.", + "überfällig", + ]); + }); + + it("„Zum Kalender“ links to the calendar", async () => { + renderWheel(); + + const link = await screen.findByRole("link", { name: "Zum Kalender" }); + expect(link).toHaveAttribute("href", "/calendar"); + expect(link).toHaveClass("min-h-11"); + }); + + it("names deviating apiaries in the third centre line", async () => { + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + seasons: [ + season({}), + season({ apiaryId: "a-2", apiaryName: "Dorf", segment: "HERBST" }), + season({ apiaryId: "a-3", apiaryName: "Südhang", segment: "SPAETSOMMER", segmentSince: "2026-08-01" }), + ], + }), + ); + const first = renderWheel(); + // Deviations sit in the line below the wheel, not in the narrow centre. + expect(await screen.findByText("Einwinterung bis ca. 15.11. · Südhang: noch Spätsommer")).toBeInTheDocument(); + expect(first.container.querySelector('[data-part="centre"]')).not.toHaveTextContent(/Südhang/); + first.unmount(); + + vi.mocked(fetchAutopilotToday).mockResolvedValue( + todayFixture({ + seasons: [ + season({}), + season({ apiaryId: "a-2", apiaryName: "Dorf", segment: "HERBST" }), + season({ apiaryId: "a-3", apiaryName: "Südhang", segment: "SPAETSOMMER", segmentSince: "2026-08-01" }), + season({ apiaryId: "a-4", apiaryName: "Moor", segment: "WINTERRUHE", segmentSince: "2026-10-10" }), + ], + }), + ); + renderWheel(); + expect(await screen.findByText(/· 2 Stände weichen ab – siehe Tabelle$/)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Als Tabelle" })); + const segments = screen.getByRole("table", { name: "Abschnitte des Bienenjahrs" }); + // One column per apiary when they deviate: where each one is now. + expect(within(segments).getAllByRole("columnheader").map((cell) => cell.textContent)).toEqual([ + "Abschnitt", + "von", + "bis", + "Quelle", + "Obstwiese", + "Dorf", + "Südhang", + "Moor", + ]); + const winter = within(segments).getByRole("row", { name: /Winterruhe/ }); + expect(winter).toHaveTextContent("jetzt, seit 10.10."); + }); + + it("works on the calendar alone without the autopilot", async () => { + vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch")); + renderWheel(); + + expect(await screen.findByText(/^Jetzt: Herbst seit 15\.09\. \(Kalender\)/)).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Bienenjahr 2026" })).toBeInTheDocument(); + }); + + it("the pointer is a fixed element without animation", async () => { + const { container } = renderWheel(); + // The tasks are in once the description counts the overdue one. + await screen.findByText(/1 überfällig\.$/); + + const pointer = container.querySelector('[data-part="pointer"]'); + expect(pointer).not.toBeNull(); + expect(pointer?.getAttribute("style")).toBeNull(); + const svg = container.querySelector("svg[role='img']") as SVGElement; + expect(svg.querySelectorAll("animate, animateTransform, animateMotion, set")).toHaveLength(0); + // The wheel itself; the „Als Tabelle“ button keeps the app's button feedback. + expect(svg.parentElement?.innerHTML).not.toMatch(/\b(animate-|transition)/); + // The current segment is honey framed in ink; the overdue dot sits before the pointer. + expect(container.querySelectorAll('[data-part="segment"][data-current="true"]').length).toBeGreaterThan(0); + expect(container.querySelector('[data-part="overdue"]')).not.toBeNull(); + expect(container.querySelectorAll('[data-part="task-week"]')).toHaveLength(4); + // The honeycomb cap ends inside the ring of month initials (r = 150, 13–14 px text). + const cap = pointer?.querySelector("polygon")?.getAttribute("points") ?? ""; + const reach = Math.max( + ...cap.split(" ").map((xy) => { + const [x, y] = xy.split(",").map(Number); + return Math.hypot(x - 160, y - 160); + }), + ); + expect(reach).toBeGreaterThan(0); + expect(reach).toBeLessThanOrEqual(141); + }); + + it("this week's dot keeps clear of next week's dot", async () => { + // Sunday: next week's mid-week is only four days away. + vi.setSystemTime(new Date("2026-10-18T10:00:00")); + vi.mocked(fetchTasks).mockResolvedValue([ + task("now", "2026-10-18"), + ...["19", "20", "21", "22"].map((day) => task(`n${day}`, `2026-10-${day}`)), + ]); + const { container } = renderWheel(); + await screen.findByText(/5 in den nächsten 4 Wochen/); + + const dots = [...container.querySelectorAll('[data-part="task-week"]')].map((dot) => ({ + x: Number(dot.getAttribute("cx")), + y: Number(dot.getAttribute("cy")), + r: Number(dot.getAttribute("r")), + })); + expect(dots).toHaveLength(2); + expect(Math.hypot(dots[0].x - dots[1].x, dots[0].y - dots[1].y)).toBeGreaterThanOrEqual(dots[0].r + dots[1].r + 1); + }); + + it("the legend shows each window as the ring does, also in forced colours", async () => { + const { container } = renderWheel(); + await screen.findByRole("img", { name: "Bienenjahr 2026" }); + + for (const [key, legend] of [["TRACHT", "TRACHT"], ["VARROA_WINTER", "VARROA"], ["EINWINTERUNG", "EINWINTERUNG"]]) { + const ring = container.querySelector(`[data-window="${key}"]`); + const swatch = container.querySelector(`[data-legend="${legend}"]`); + expect(ring).not.toBeNull(); + expect(swatch?.getAttribute("class")).toBe(ring?.getAttribute("class")); + } + const forced = (key: string) => + (container.querySelector(`[data-window="${key}"]`)?.getAttribute("class") ?? "") + .split(" ") + .filter((name) => name.startsWith("forced-colors:")) + .join(" "); + // In forced colours Tracht stays full and the wintering window turns hollow. + expect(forced("TRACHT")).not.toBe(forced("EINWINTERUNG")); + }); +}); diff --git a/ApiCore-Frontend/src/components/season/year-wheel.tsx b/ApiCore-Frontend/src/components/season/year-wheel.tsx new file mode 100644 index 0000000..b60de0f --- /dev/null +++ b/ApiCore-Frontend/src/components/season/year-wheel.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { useId, useMemo, useState, type ReactNode } from "react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight } from "lucide-react"; +import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api"; +import { daysBetween } from "@/lib/autopilot/format"; +import { todayLocalIsoDate } from "@/lib/dates"; +import { angleOf, arcsOf, buildYearWheel, type YearWheelModel, type YearWheelWindowKey } from "@/lib/season/year-wheel"; +import { fetchTasks } from "@/lib/tasks/api"; +import { SEGMENT_LABELS } from "@/components/autopilot/season-chip"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { YearWheelTable } from "./year-wheel-table"; +import { centreLines, detailLine, nowAndNext, wheelDescription } from "./year-wheel-text"; + +// Spec 9.2, in px of the 320 × 320 viewBox. +const SIZE = 320; +const CENTER = SIZE / 2; +const OUTER_R = 124; +const OUTER_W = 26; +const INNER_R = 96; +const INNER_W = 10; +const DOT_R = 78; +const MONTH_R = 150; +const POINTER_FROM = 66; +/** The cap ends at r = 140, clear of the month initials at r = 150 (13–14 px). */ +const POINTER_TO = 134; +const CAP_R = 6; +const SEGMENT_GAP = 2; +/** The overdue dot sits this far before the pointer (spec 9.2). */ +const NEAR_POINTER_DEG = 4; +/** Half the pointer's 3 px stroke: the first dot after it keeps clear of that. */ +const POINTER_HALF = 1.5; +const PX_PER_DEG_AT_DOTS = (DOT_R * Math.PI) / 180; +const MONTH_INITIALS = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]; +const INK = "forced-colors:stroke-[CanvasText]"; + +/** + * Shared by ring and legend. Tracht is full, wintering full but hollow in forced colours, the + * two Varroa windows hatched: told apart without colour. + */ +const WINDOW_CLASS: Record = { + TRACHT: "fill-chart-3 forced-colors:fill-[CanvasText]", + VARROA_SUMMER: cn("stroke-chart-4", INK), + EINWINTERUNG: "fill-chart-5 forced-colors:fill-[Canvas] forced-colors:stroke-[CanvasText]", + VARROA_WINTER: cn("stroke-chart-4", INK), +}; +const isHatched = (key: YearWheelWindowKey): boolean => key === "VARROA_SUMMER" || key === "VARROA_WINTER"; +const DOT_CLASS = "fill-foreground forced-colors:fill-[CanvasText]"; +const OVERDUE_CLASS = cn("fill-condition-critical stroke-foreground forced-colors:fill-[Canvas]", INK); + +const round = (value: number): number => Math.round(value * 10) / 10; + +function polar(angle: number, radius: number): [number, number] { + const rad = (angle * Math.PI) / 180; + return [round(CENTER + radius * Math.cos(rad)), round(CENTER + radius * Math.sin(rad))]; +} + +function arcTo(angle: number, radius: number, sweep: 0 | 1, large: number): string { + const [x, y] = polar(angle, radius); + return `A${radius} ${radius} 0 ${large} ${sweep} ${x} ${y}`; +} + +/** A ring piece from `start` to `end` (degrees, clockwise) around `radius`, `width` wide. */ +function bandPath(start: number, end: number, radius: number, width: number): string { + const outer = radius + width / 2; + const inner = radius - width / 2; + const large = end - start > 180 ? 1 : 0; + const [x1, y1] = polar(start, outer); + const [x2, y2] = polar(end, inner); + return `M${x1} ${y1}${arcTo(end, outer, 1, large)}L${x2} ${y2}${arcTo(start, inner, 0, large)}Z`; +} + +/** Only the outer and inner edge of a ring piece, so the radial edges can be dashed apart. */ +function bandEdges(start: number, end: number, radius: number, width: number): string { + const large = end - start > 180 ? 1 : 0; + return [radius + width / 2, radius - width / 2] + .map((r) => { + const [x, y] = polar(start, r); + return `M${x} ${y}${arcTo(end, r, 1, large)}`; + }) + .join(""); +} + +/** The arcs of a segment, 2 px apart from its neighbours. */ +function segmentArcs(from: string, to: string) { + const gap = ((SEGMENT_GAP / 2 / OUTER_R) * 180) / Math.PI; + const arcs = arcsOf(from, to); + return arcs + .map((arc, index) => ({ + startAngle: index === 0 ? arc.startAngle + gap : arc.startAngle, + endAngle: index === arcs.length - 1 ? arc.endAngle - gap : arc.endAngle, + })) + .filter((arc) => arc.endAngle > arc.startAngle); +} + +function RadialEdge({ angle, dashed }: { angle: number; dashed: boolean }) { + const [x1, y1] = polar(angle, OUTER_R - OUTER_W / 2); + const [x2, y2] = polar(angle, OUTER_R + OUTER_W / 2); + return ( + + ); +} + +function Segments({ model }: { model: YearWheelModel }) { + return model.segments.map((segment, index) => { + const arcs = segmentArcs(segment.from, segment.to); + if (arcs.length === 0) return null; + const first = arcs[0]; + const last = arcs[arcs.length - 1]; + if (segment.current) { + return arcs.map((arc) => ( + + )); + } + const nextIsEstimated = model.segments[(index + 1) % model.segments.length].estimated; + return ( + + {arcs.map((arc) => ( + + ))} + bandEdges(arc.startAngle, arc.endAngle, OUTER_R, OUTER_W)).join("")} + fill="none" + strokeWidth={1.5} + className={cn("stroke-input", INK)} + /> + + + + ); + }); +} + +function Windows({ model, hatchId }: { model: YearWheelModel; hatchId: string }) { + return model.windows.flatMap((window) => + arcsOf(window.from, window.to).map((arc) => ( + + )), + ); +} + +/** + * One dot per week with open tasks: 3 / 4.5 / 6 px for 1 / 2–3 / 4 and more (spec 9.2), at + * mid-week. A dot never touches the pointer or the dot before it: it moves on clockwise. + */ +function dotPlacements(model: YearWheelModel, pointer: number): { weekStart: string; angle: number; r: number }[] { + const dots: { weekStart: string; angle: number; r: number }[] = []; + let previous = { angle: pointer, r: POINTER_HALF }; + for (const week of model.taskWeeks) { + const midWeek = new Date(Date.parse(`${week.weekStart}T00:00:00Z`) + 3 * 86_400_000).toISOString().slice(0, 10); + const r = week.count >= 4 ? 6 : week.count >= 2 ? 4.5 : 3; + // Angles counted on from the pointer, so a dot next spring is not "before" today. + const ahead = daysBetween(model.today, midWeek) <= 0 ? 0 : (angleOf(midWeek) - pointer + 360) % 360; + // 1.5 px air: 1 px gap plus the chord being a little shorter than the arc and rounding. + const clear = previous.angle + (previous.r + r + 1.5) / PX_PER_DEG_AT_DOTS; + // shortcut: a year of busy weeks would push dots past the overdue dot; capped short of it. + const angle = Math.min(Math.max(pointer + ahead, clear), pointer + 360 - 2 * NEAR_POINTER_DEG); + dots.push({ weekStart: week.weekStart, angle, r }); + previous = { angle, r }; + } + return dots; +} + +function TaskDots({ model, pointer }: { model: YearWheelModel; pointer: number }) { + return dotPlacements(model, pointer).map((dot) => { + const [cx, cy] = polar(dot.angle, DOT_R); + return ; + }); +} + +function Pointer({ angle }: { angle: number }) { + const [x1, y1] = polar(angle, POINTER_FROM); + const [x2, y2] = polar(angle, POINTER_TO); + const cell = [0, 60, 120, 180, 240, 300] + .map((corner) => { + const rad = ((angle + corner + 30) * Math.PI) / 180; + return `${round(x2 + CAP_R * Math.cos(rad))},${round(y2 + CAP_R * Math.sin(rad))}`; + }) + .join(" "); + // Fixed on today: no transform, no transition (spec 9.5, 17.5). + return ( + + + + + ); +} + +function Legend({ hatchId }: { hatchId: string }) { + const swatch = (child: ReactNode) => ( + + ); + const band = (legend: string, key: YearWheelWindowKey) => ( + + ); + const items: [string, ReactNode][] = [ + ["Tracht", band("TRACHT", "TRACHT")], + ["Varroa-Behandlung", band("VARROA", "VARROA_WINTER")], + ["Einwinterung", band("EINWINTERUNG", "EINWINTERUNG")], + ["Aufgabe", ], + ["überfällig", ], + ]; + return ( +
    + {items.map(([label, shape]) => ( +
  • + {swatch(shape)} + {label} +
  • + ))} +
+ ); +} + +function Wheel({ model }: { model: YearWheelModel }) { + const ids = useId().replace(/[^\w-]/g, ""); + const hatchId = `wheel-hatch-${ids}`; + const pointer = angleOf(model.today); + const centre = centreLines(model); + const detail = detailLine(model); + const [overdueX, overdueY] = polar(pointer - NEAR_POINTER_DEG, DOT_R); + return ( + <> + {/* @container: the centre text sizes and shortens with the wheel, not the viewport. */} +
+ + {`Bienenjahr ${model.year}`} + {wheelDescription(model)} + + + + + + + + + {model.overdueCount > 0 ? ( + + ) : null} + + + {/* Text as HTML at fixed sizes: SVG text would shrink with the wheel (spec 9.2). */} + {MONTH_INITIALS.map((initial, month) => { + const [x, y] = polar(angleOf(`${model.year}-${String(month + 1).padStart(2, "0")}-15`), MONTH_R); + return ( + + ); + })} + {/* + The free circle is r < 62 of 320 (38.75cqw across). Three single lines at most: the + title scales with the wheel (13–22 px), the two lines are text-sm (sun 16 px) and short. + At a 256 px wheel (320 px viewport) in sun: „Schwarmzeit“ ≈ 79 px, „seit 15.09.“ ≈ 80 px, + „bis 15.11.“ ≈ 75 px; from 304 px the window name („Einwinterung“ ≈ 101 px) instead. + */} + +
+ +

{nowAndNext(model)}

+ {detail ?

{detail}

: null} + + ); +} + +/** + * The bee year at a glance (spec 9.2–9.5): segments, work windows, open tasks and a pointer on + * today. Nothing in the wheel is tappable or animated; the table says the same in words. + */ +export function YearWheel() { + const [isTableOpen, setTableOpen] = useState(false); + const headingId = useId(); + const tableId = useId(); + // Same queries as the dashboard, so the wheel adds no request. + const tasksQuery = useQuery({ queryKey: ["tasks", "dashboard"], queryFn: () => fetchTasks({}) }); + const autopilotQuery = useQuery({ queryKey: autopilotTodayKey, queryFn: fetchAutopilotToday, retry: false }); + const today = todayLocalIsoDate(); + const model = useMemo( + () => buildYearWheel({ today: autopilotQuery.data, tasks: tasksQuery.data ?? [] }, today), + [autopilotQuery.data, tasksQuery.data, today], + ); + + return ( +
+
+

+ Bienenjahr +

+ + Zum Kalender + + +
+
+ + + {/* Always there, so aria-controls resolves; hidden while closed. */} + +
+
+ ); +} diff --git a/ApiCore-Frontend/src/components/today/today-panel.tsx b/ApiCore-Frontend/src/components/today/today-panel.tsx index e025ebe..312af76 100644 --- a/ApiCore-Frontend/src/components/today/today-panel.tsx +++ b/ApiCore-Frontend/src/components/today/today-panel.tsx @@ -11,7 +11,6 @@ import { PresenceItem, PresenceList } from "@/components/motion/presence-list"; import { Button } from "@/components/ui/button"; import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status"; import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note"; -import { SeasonChip } from "@/components/autopilot/season-chip"; import { StepActions } from "@/components/autopilot/next-steps-card"; import { useStepActions } from "@/components/autopilot/use-step-actions"; import { CleanupSheet } from "@/components/suggestions/cleanup-sheet"; @@ -363,29 +362,17 @@ function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps) ); } -/** Open suggestions and the season of each apiary - small, but readable as words. */ +/** Open suggestions - small, but readable as words. The season per apiary is in the bee-year wheel. */ function AutopilotChips({ autopilot }: { autopilot: AutopilotToday }) { - const showSuggestions = autopilot.tasksVisible && autopilot.suggestionsOpen > 0; - if (!showSuggestions && autopilot.seasons.length === 0) return null; + if (!autopilot.tasksVisible || autopilot.suggestionsOpen === 0) return null; return ( -
    - {showSuggestions ? ( -
  • - - - Vorschläge {autopilot.suggestionsOpen} - -
  • - ) : null} - {autopilot.seasons.map((season) => ( -
  • - -
  • - ))} -
+ + + Vorschläge {autopilot.suggestionsOpen} + ); } diff --git a/ApiCore-Frontend/src/lib/dashboard/api.ts b/ApiCore-Frontend/src/lib/dashboard/api.ts index 8f087e0..6801c82 100644 --- a/ApiCore-Frontend/src/lib/dashboard/api.ts +++ b/ApiCore-Frontend/src/lib/dashboard/api.ts @@ -1,9 +1,5 @@ import { apiFetch } from "@/lib/auth/api-client"; -import { DashboardAlert, DashboardSummary } from "./types"; - -export function fetchDashboardSummary(): Promise { - return apiFetch("/dashboard/summary"); -} +import { DashboardAlert } from "./types"; export function fetchDashboardAlerts(): Promise { return apiFetch("/dashboard/alerts"); diff --git a/ApiCore-Frontend/src/lib/dashboard/types.ts b/ApiCore-Frontend/src/lib/dashboard/types.ts index 716a21a..f90f437 100644 --- a/ApiCore-Frontend/src/lib/dashboard/types.ts +++ b/ApiCore-Frontend/src/lib/dashboard/types.ts @@ -1,12 +1,3 @@ -export interface DashboardSummary { - apiariesActive: number; - coloniesActive: number; - queensActive: number; - openTasks: number; - honeyBatchesTotal: number; - stockItemsLowCount: number; -} - export type DashboardAlertType = | "OVERDUE_TASK" | "COLONY_ATTENTION" diff --git a/ApiCore-Frontend/src/lib/season/year-wheel.spec.ts b/ApiCore-Frontend/src/lib/season/year-wheel.spec.ts index ce5c591..35a49f5 100644 --- a/ApiCore-Frontend/src/lib/season/year-wheel.spec.ts +++ b/ApiCore-Frontend/src/lib/season/year-wheel.spec.ts @@ -89,6 +89,7 @@ describe("buildYearWheel", () => { source: "REPORT", current: true, estimated: false, + lateStart: false, }); // The ring closes where it began: spring ends at the reported day. expect(model.segments[5]).toMatchObject({ key: "FRUEHJAHR", from: "2027-03-20", to: "2027-04-05" }); @@ -103,7 +104,13 @@ describe("buildYearWheel", () => { it("a late bloom makes the next segment „ab morgen, noch offen“", () => { const model = wheel([season({ segment: "FRUEHJAHR", segmentSince: "2026-03-20" })], "2026-04-20"); expect(model.segments[0]).toMatchObject({ key: "FRUEHJAHR", from: "2026-03-20", to: "2026-04-21" }); - expect(model.segments[1]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-21", to: "2026-06-20", estimated: true }); + expect(model.segments[1]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-21", to: "2026-06-20", estimated: true, lateStart: true }); + expect(model.segments.filter((s) => s.lateStart)).toHaveLength(1); + }); + + it("a calendar start that happens to be tomorrow is no late bloom", () => { + const model = wheel([season({ segment: "FRUEHJAHR", segmentSince: "2026-03-20" })], "2026-04-14"); + expect(model.segments[1]).toMatchObject({ key: "SCHWARMZEIT", from: "2026-04-15", lateStart: false }); }); it("segments are gapless around the ring", () => { diff --git a/ApiCore-Frontend/src/lib/season/year-wheel.ts b/ApiCore-Frontend/src/lib/season/year-wheel.ts index 3eb7f66..83a9824 100644 --- a/ApiCore-Frontend/src/lib/season/year-wheel.ts +++ b/ApiCore-Frontend/src/lib/season/year-wheel.ts @@ -14,6 +14,8 @@ export interface YearWheelSegment { current: boolean; /** A calendar boundary, drawn dashed. */ estimated: boolean; + /** Its calendar day has passed but it has not come yet (late bloom): „ab morgen, noch offen“. */ + lateStart: boolean; } export type YearWheelWindowKey = "TRACHT" | "VARROA_SUMMER" | "EINWINTERUNG" | "VARROA_WINTER"; @@ -165,10 +167,13 @@ function ringOf( // The ring closes a year after the current segment began; a stale segment caps the rest there. const ringEnd = nextYear(current.since); const froms = [current.since]; + const lateStarts = [false]; for (let step = 1; step < CYCLE.length; step += 1) { const index = first + step; const calendarDay = `${beeYear + Math.floor(index / CYCLE.length)}-${starts[CYCLE[index % CYCLE.length]]}`; - froms.push(earliest(latest(calendarDay, tomorrow, froms[step - 1]), ringEnd)); + const from = earliest(latest(calendarDay, tomorrow, froms[step - 1]), ringEnd); + froms.push(from); + lateStarts.push(calendarDay < tomorrow && from === tomorrow); } const tos = [...froms.slice(1), ringEnd]; return froms.map((from, step) => ({ @@ -178,6 +183,7 @@ function ringOf( source: step === 0 ? current.source : "CALENDAR", current: step === 0, estimated: step !== 0, + lateStart: lateStarts[step], })); }