feat(ui): calendar as a day list on phones, today named, every entry reachable

- Phones get a list of the days that have entries (44 px rows with type
  word, icon and title) instead of a 7-column grid with 0.7 rem chips.
- Month grid on wider screens: days are buttons, the chosen day lists
  all its entries below, so "+N weitere" is no longer a dead end.
- Today is marked with a filled circle and the word "Heute"; a "Heute"
  button jumps back. Month navigation buttons are touch size.
- Load errors offer a retry; an empty month says so.
- Entries are grouped by local day instead of UTC, so late-evening
  entries no longer land on the next day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:38:09 +02:00
1 parent dff6c364c8
commit bd2070436c
1 file changed
+245 -120
+245 -120
View File
@@ -20,6 +20,8 @@ import { CalendarEvent, CalendarEventType } from "@/lib/calendar/types";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ErrorState, LoadingState } from "@/components/query-state";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -27,25 +29,17 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils";
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const EVENT_ICON: Record<CalendarEventType, LucideIcon> = { const EVENT_META: Record<CalendarEventType, { icon: LucideIcon; label: string; tone: string }> = {
TASK: ListTodo, TASK: { icon: ListTodo, label: "Aufgabe", tone: "bg-secondary/15" },
INSPECTION: ClipboardCheck, INSPECTION: { icon: ClipboardCheck, label: "Durchsicht", tone: "bg-primary/15" },
TREATMENT: Syringe, TREATMENT: { icon: Syringe, label: "Behandlung", tone: "bg-destructive/15" },
FEEDING: Droplet, FEEDING: { icon: Droplet, label: "Fütterung", tone: "bg-warning/20" },
HARVEST: Package, HARVEST: { icon: Package, label: "Ernte", tone: "bg-primary/15" },
QUEEN_ASSIGNMENT: Crown, QUEEN_ASSIGNMENT: { icon: Crown, label: "Königin", tone: "bg-secondary/15" },
};
const EVENT_TONE: Record<CalendarEventType, string> = {
TASK: "bg-secondary/15 text-secondary",
INSPECTION: "bg-primary/15 text-primary",
TREATMENT: "bg-destructive/15 text-destructive",
FEEDING: "bg-warning/20 text-warning-foreground",
HARVEST: "bg-primary/15 text-primary",
QUEEN_ASSIGNMENT: "bg-secondary/15 text-secondary",
}; };
function eventHref(event: CalendarEvent): string | null { function eventHref(event: CalendarEvent): string | null {
@@ -56,8 +50,11 @@ function eventHref(event: CalendarEvent): string | null {
return null; return null;
} }
/** Local calendar day "2026-10-01" (not UTC - late evening entries stay on their day). */
function toDateKey(date: Date): string { function toDateKey(date: Date): string {
return date.toISOString().slice(0, 10); const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
} }
function startOfMonth(date: Date): Date { function startOfMonth(date: Date): Date {
@@ -71,9 +68,69 @@ function startOfGrid(monthStart: Date): Date {
return gridStart; return gridStart;
} }
function dayHeading(date: Date): string {
return date.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" });
}
function EventRow({ event }: { event: CalendarEvent }) {
const meta = EVENT_META[event.type];
const Icon = meta.icon;
const href = eventHref(event);
const content = (
<span className={cn("flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm", meta.tone)}>
<Icon className="size-4 shrink-0" aria-hidden />
<span className="shrink-0 text-xs font-medium text-muted-foreground">{meta.label}</span>
<span className="truncate">{event.title}</span>
</span>
);
return href ? (
<Link href={href} className="block rounded-lg focus-visible:ring-3 focus-visible:ring-ring/50">
{content}
</Link>
) : (
content
);
}
function DayEvents({
date,
events,
isToday,
}: {
date: Date;
events: CalendarEvent[];
isToday: boolean;
}) {
return (
<section className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold">
{dayHeading(date)}
{isToday ? (
<span className="rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
Heute
</span>
) : null}
</h2>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground">Nichts eingetragen.</p>
) : (
<ul className="flex flex-col gap-1.5">
{events.map((event) => (
<li key={event.id}>
<EventRow event={event} />
</li>
))}
</ul>
)}
</section>
);
}
export default function CalendarPage() { export default function CalendarPage() {
const [month, setMonth] = useState(() => startOfMonth(new Date())); const [month, setMonth] = useState(() => startOfMonth(new Date()));
const [apiaryId, setApiaryId] = useState(""); const [apiaryId, setApiaryId] = useState("");
const [selectedKey, setSelectedKey] = useState(() => toDateKey(new Date()));
const todayKey = toDateKey(new Date());
const monthStart = startOfMonth(month); const monthStart = startOfMonth(month);
const gridStart = startOfGrid(monthStart); const gridStart = startOfGrid(monthStart);
@@ -87,7 +144,7 @@ export default function CalendarPage() {
queryKey: ["apiaries"], queryKey: ["apiaries"],
queryFn: () => fetchApiaries(), queryFn: () => fetchApiaries(),
}); });
const { data: events, isLoading, isError } = useQuery({ const eventsQuery = useQuery({
queryKey: ["calendar", monthStart.toISOString(), apiaryId], queryKey: ["calendar", monthStart.toISOString(), apiaryId],
queryFn: () => queryFn: () =>
fetchCalendarEvents({ fetchCalendarEvents({
@@ -99,129 +156,197 @@ export default function CalendarPage() {
const eventsByDay = useMemo(() => { const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>(); const map = new Map<string, CalendarEvent[]>();
for (const event of events ?? []) { for (const event of eventsQuery.data ?? []) {
const key = event.date.slice(0, 10); const key = toDateKey(new Date(event.date));
const bucket = map.get(key) ?? []; const bucket = map.get(key) ?? [];
bucket.push(event); bucket.push(event);
map.set(key, bucket); map.set(key, bucket);
} }
return map; return map;
}, [events]); }, [eventsQuery.data]);
const daysOfMonthWithEvents = gridDays.filter(
(day) =>
day.getMonth() === month.getMonth() && (eventsByDay.get(toDateKey(day))?.length ?? 0) > 0,
);
const selectedDay = gridDays.find((day) => toDateKey(day) === selectedKey);
function goToMonth(offset: number) {
setMonth(new Date(month.getFullYear(), month.getMonth() + offset, 1));
}
function goToToday() {
setMonth(startOfMonth(new Date()));
setSelectedKey(todayKey);
}
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={CalendarDays} icon={CalendarDays}
title={month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })} title="Kalender"
description="Kalender" description="Was wann ansteht und was erledigt wurde – Monat für Monat."
gradient="var(--gradient-sky)" gradient="var(--gradient-sky)"
actions={
<div className="flex items-center gap-2">
<Select
value={apiaryId || "ALL"}
onValueChange={(value) => setApiaryId(value === "ALL" ? "" : (value ?? ""))}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="Alle Bienenstände">
{apiaryId
? (apiaries?.find((apiary) => apiary.id === apiaryId)?.name ?? apiaryId)
: "Alle Bienenstände"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Bienenstände</SelectItem>
{apiaries?.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
aria-label="Vorheriger Monat"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Nächster Monat"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
>
<ChevronRight className="size-4" />
</Button>
</div>
}
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <div className="flex flex-wrap items-center gap-2">
{isError ? ( <Button
<p role="alert" className="text-sm text-destructive"> variant="outline"
Kalender konnte nicht geladen werden. size="touch"
aria-label="Vorheriger Monat"
onClick={() => goToMonth(-1)}
>
<ChevronLeft aria-hidden />
</Button>
<p className="min-w-40 text-center font-heading text-lg font-semibold" aria-live="polite">
{monthLabel}
</p> </p>
<Button
variant="outline"
size="touch"
aria-label="Nächster Monat"
onClick={() => goToMonth(1)}
>
<ChevronRight aria-hidden />
</Button>
<Button variant="ghost" size="touch" onClick={goToToday}>
Heute
</Button>
<Select
value={apiaryId || "ALL"}
onValueChange={(value) => setApiaryId(value === "ALL" ? "" : (value ?? ""))}
>
<SelectTrigger className="w-full sm:ml-auto sm:w-52" aria-label="Bienenstand">
<SelectValue placeholder="Alle Bienenstände">
{apiaryId
? (apiaries?.find((apiary) => apiary.id === apiaryId)?.name ?? apiaryId)
: "Alle Bienenstände"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Bienenstände</SelectItem>
{apiaries?.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{eventsQuery.isLoading ? <LoadingState what="Einträge" /> : null}
{eventsQuery.isError ? (
<ErrorState
what="Kalendereinträge"
onRetry={() => void eventsQuery.refetch()}
isRetrying={eventsQuery.isFetching}
/>
) : null} ) : null}
<div className="grid grid-cols-7 overflow-hidden rounded-xl border"> {eventsQuery.data ? (
{WEEKDAYS.map((weekday) => ( <>
<div {/* Phones: a plain list of the days that have something on. */}
key={weekday} <div className="flex flex-col gap-5 sm:hidden">
className="border-b bg-muted px-2 py-1.5 text-center text-xs font-medium text-muted-foreground" {daysOfMonthWithEvents.length === 0 ? (
> <EmptyState icon={CalendarDays} title={`Im ${monthLabel} steht nichts an.`} />
{weekday} ) : (
daysOfMonthWithEvents.map((day) => (
<DayEvents
key={toDateKey(day)}
date={day}
events={eventsByDay.get(toDateKey(day)) ?? []}
isToday={toDateKey(day) === todayKey}
/>
))
)}
</div> </div>
))}
{gridDays.map((day) => { {/* Wider screens: month grid; choosing a day lists all its entries below. */}
const key = toDateKey(day); <div className="hidden flex-col gap-4 sm:flex">
const dayEvents = eventsByDay.get(key) ?? []; <div className="grid grid-cols-7 overflow-hidden rounded-xl border">
const isCurrentMonth = day.getMonth() === month.getMonth(); {WEEKDAYS.map((weekday) => (
const isToday = key === toDateKey(new Date()); <div
return ( key={weekday}
<div className="border-b bg-muted px-2 py-1.5 text-center text-xs font-medium text-muted-foreground"
key={key} >
className={`flex min-h-24 flex-col gap-1 border-r border-b p-1.5 last:border-r-0 ${ {weekday}
isCurrentMonth ? "bg-card" : "bg-muted/40" </div>
}`} ))}
> {gridDays.map((day) => {
<span const key = toDateKey(day);
className={`text-xs ${isCurrentMonth ? "text-foreground" : "text-muted-foreground"} ${ const dayEvents = eventsByDay.get(key) ?? [];
isToday ? "font-semibold text-primary" : "" const isCurrentMonth = day.getMonth() === month.getMonth();
}`} const isToday = key === todayKey;
> const isSelected = key === selectedKey;
{day.getDate()} return (
</span> <button
<div className="flex flex-col gap-1"> key={key}
{dayEvents.slice(0, 3).map((event) => { type="button"
const Icon = EVENT_ICON[event.type]; onClick={() => setSelectedKey(key)}
const href = eventHref(event); aria-pressed={isSelected}
const content = ( aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${dayEvents.length} Einträge`}
className={cn(
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0",
isCurrentMonth ? "bg-card" : "bg-muted/40",
isSelected && "outline-2 -outline-offset-2 outline-primary",
)}
>
<span <span
className={`flex items-center gap-1 truncate rounded px-1 py-0.5 text-[0.7rem] ${EVENT_TONE[event.type]}`} className={cn(
"flex items-center gap-1 text-xs",
isCurrentMonth ? "text-foreground" : "text-muted-foreground",
isToday && "font-semibold",
)}
> >
<Icon className="size-3 shrink-0" /> <span
<span className="truncate">{event.title}</span> className={cn(
"flex size-6 items-center justify-center rounded-full",
isToday && "bg-primary text-primary-foreground",
)}
>
{day.getDate()}
</span>
{isToday ? <span>Heute</span> : null}
</span> </span>
); <span className="flex flex-col gap-1">
return href ? ( {dayEvents.slice(0, 3).map((event) => {
<Link key={event.id} href={href}> const meta = EVENT_META[event.type];
{content} const Icon = meta.icon;
</Link> return (
) : ( <span
<div key={event.id}>{content}</div> key={event.id}
); className={cn(
})} "flex items-center gap-1 truncate rounded px-1 py-0.5 text-xs",
{dayEvents.length > 3 ? ( meta.tone,
<span className="text-[0.7rem] text-muted-foreground"> )}
+{dayEvents.length - 3} weitere >
</span> <Icon className="size-3 shrink-0" aria-hidden />
) : null} <span className="truncate">{event.title}</span>
</div> </span>
);
})}
{dayEvents.length > 3 ? (
<span className="text-xs text-muted-foreground">
+{dayEvents.length - 3} weitere
</span>
) : null}
</span>
</button>
);
})}
</div> </div>
); {selectedDay ? (
})} <DayEvents
</div> date={selectedDay}
events={eventsByDay.get(selectedKey) ?? []}
isToday={selectedKey === todayKey}
/>
) : null}
</div>
</>
) : null}
</main> </main>
); );
} }