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 { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ErrorState, LoadingState } from "@/components/query-state";
import {
Select,
SelectContent,
@@ -27,25 +29,17 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const EVENT_ICON: Record<CalendarEventType, LucideIcon> = {
TASK: ListTodo,
INSPECTION: ClipboardCheck,
TREATMENT: Syringe,
FEEDING: Droplet,
HARVEST: Package,
QUEEN_ASSIGNMENT: Crown,
};
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",
const EVENT_META: Record<CalendarEventType, { icon: LucideIcon; label: string; tone: string }> = {
TASK: { icon: ListTodo, label: "Aufgabe", tone: "bg-secondary/15" },
INSPECTION: { icon: ClipboardCheck, label: "Durchsicht", tone: "bg-primary/15" },
TREATMENT: { icon: Syringe, label: "Behandlung", tone: "bg-destructive/15" },
FEEDING: { icon: Droplet, label: "Fütterung", tone: "bg-warning/20" },
HARVEST: { icon: Package, label: "Ernte", tone: "bg-primary/15" },
QUEEN_ASSIGNMENT: { icon: Crown, label: "Königin", tone: "bg-secondary/15" },
};
function eventHref(event: CalendarEvent): string | null {
@@ -56,8 +50,11 @@ function eventHref(event: CalendarEvent): string | null {
return null;
}
/** Local calendar day "2026-10-01" (not UTC - late evening entries stay on their day). */
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 {
@@ -71,9 +68,69 @@ function startOfGrid(monthStart: Date): Date {
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() {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const [apiaryId, setApiaryId] = useState("");
const [selectedKey, setSelectedKey] = useState(() => toDateKey(new Date()));
const todayKey = toDateKey(new Date());
const monthStart = startOfMonth(month);
const gridStart = startOfGrid(monthStart);
@@ -87,7 +144,7 @@ export default function CalendarPage() {
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const { data: events, isLoading, isError } = useQuery({
const eventsQuery = useQuery({
queryKey: ["calendar", monthStart.toISOString(), apiaryId],
queryFn: () =>
fetchCalendarEvents({
@@ -99,129 +156,197 @@ export default function CalendarPage() {
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const event of events ?? []) {
const key = event.date.slice(0, 10);
for (const event of eventsQuery.data ?? []) {
const key = toDateKey(new Date(event.date));
const bucket = map.get(key) ?? [];
bucket.push(event);
map.set(key, bucket);
}
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 (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={CalendarDays}
title={month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
description="Kalender"
title="Kalender"
description="Was wann ansteht und was erledigt wurde – Monat für Monat."
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}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Kalender konnte nicht geladen werden.
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
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>
<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}
<div className="grid grid-cols-7 overflow-hidden rounded-xl border">
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className="border-b bg-muted px-2 py-1.5 text-center text-xs font-medium text-muted-foreground"
>
{weekday}
{eventsQuery.data ? (
<>
{/* Phones: a plain list of the days that have something on. */}
<div className="flex flex-col gap-5 sm:hidden">
{daysOfMonthWithEvents.length === 0 ? (
<EmptyState icon={CalendarDays} title={`Im ${monthLabel} steht nichts an.`} />
) : (
daysOfMonthWithEvents.map((day) => (
<DayEvents
key={toDateKey(day)}
date={day}
events={eventsByDay.get(toDateKey(day)) ?? []}
isToday={toDateKey(day) === todayKey}
/>
))
)}
</div>
))}
{gridDays.map((day) => {
const key = toDateKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
const isCurrentMonth = day.getMonth() === month.getMonth();
const isToday = key === toDateKey(new Date());
return (
<div
key={key}
className={`flex min-h-24 flex-col gap-1 border-r border-b p-1.5 last:border-r-0 ${
isCurrentMonth ? "bg-card" : "bg-muted/40"
}`}
>
<span
className={`text-xs ${isCurrentMonth ? "text-foreground" : "text-muted-foreground"} ${
isToday ? "font-semibold text-primary" : ""
}`}
>
{day.getDate()}
</span>
<div className="flex flex-col gap-1">
{dayEvents.slice(0, 3).map((event) => {
const Icon = EVENT_ICON[event.type];
const href = eventHref(event);
const content = (
{/* Wider screens: month grid; choosing a day lists all its entries below. */}
<div className="hidden flex-col gap-4 sm:flex">
<div className="grid grid-cols-7 overflow-hidden rounded-xl border">
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className="border-b bg-muted px-2 py-1.5 text-center text-xs font-medium text-muted-foreground"
>
{weekday}
</div>
))}
{gridDays.map((day) => {
const key = toDateKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
const isCurrentMonth = day.getMonth() === month.getMonth();
const isToday = key === todayKey;
const isSelected = key === selectedKey;
return (
<button
key={key}
type="button"
onClick={() => setSelectedKey(key)}
aria-pressed={isSelected}
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
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 className="truncate">{event.title}</span>
<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>
);
return href ? (
<Link key={event.id} href={href}>
{content}
</Link>
) : (
<div key={event.id}>{content}</div>
);
})}
{dayEvents.length > 3 ? (
<span className="text-[0.7rem] text-muted-foreground">
+{dayEvents.length - 3} weitere
</span>
) : null}
</div>
<span className="flex flex-col gap-1">
{dayEvents.slice(0, 3).map((event) => {
const meta = EVENT_META[event.type];
const Icon = meta.icon;
return (
<span
key={event.id}
className={cn(
"flex items-center gap-1 truncate rounded px-1 py-0.5 text-xs",
meta.tone,
)}
>
<Icon className="size-3 shrink-0" aria-hidden />
<span className="truncate">{event.title}</span>
</span>
);
})}
{dayEvents.length > 3 ? (
<span className="text-xs text-muted-foreground">
+{dayEvents.length - 3} weitere
</span>
) : null}
</span>
</button>
);
})}
</div>
);
})}
</div>
{selectedDay ? (
<DayEvents
date={selectedDay}
events={eventsByDay.get(selectedKey) ?? []}
isToday={selectedKey === todayKey}
/>
) : null}
</div>
</>
) : null}
</main>
);
}