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:
1 parent
dff6c364c8
commit
bd2070436c
1 file changed
+198
-73
@@ -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,29 +156,69 @@ 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">
|
|
||||||
|
<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
|
<Select
|
||||||
value={apiaryId || "ALL"}
|
value={apiaryId || "ALL"}
|
||||||
onValueChange={(value) => setApiaryId(value === "ALL" ? "" : (value ?? ""))}
|
onValueChange={(value) => setApiaryId(value === "ALL" ? "" : (value ?? ""))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-44">
|
<SelectTrigger className="w-full sm:ml-auto sm:w-52" aria-label="Bienenstand">
|
||||||
<SelectValue placeholder="Alle Bienenstände">
|
<SelectValue placeholder="Alle Bienenstände">
|
||||||
{apiaryId
|
{apiaryId
|
||||||
? (apiaries?.find((apiary) => apiary.id === apiaryId)?.name ?? apiaryId)
|
? (apiaries?.find((apiary) => apiary.id === apiaryId)?.name ?? apiaryId)
|
||||||
@@ -137,33 +234,37 @@ export default function CalendarPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{eventsQuery.isLoading ? <LoadingState what="Einträge" /> : null}
|
||||||
{isError ? (
|
{eventsQuery.isError ? (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<ErrorState
|
||||||
Kalender konnte nicht geladen werden.
|
what="Kalendereinträge"
|
||||||
</p>
|
onRetry={() => void eventsQuery.refetch()}
|
||||||
|
isRetrying={eventsQuery.isFetching}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* 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">
|
<div className="grid grid-cols-7 overflow-hidden rounded-xl border">
|
||||||
{WEEKDAYS.map((weekday) => (
|
{WEEKDAYS.map((weekday) => (
|
||||||
<div
|
<div
|
||||||
@@ -177,51 +278,75 @@ export default function CalendarPage() {
|
|||||||
const key = toDateKey(day);
|
const key = toDateKey(day);
|
||||||
const dayEvents = eventsByDay.get(key) ?? [];
|
const dayEvents = eventsByDay.get(key) ?? [];
|
||||||
const isCurrentMonth = day.getMonth() === month.getMonth();
|
const isCurrentMonth = day.getMonth() === month.getMonth();
|
||||||
const isToday = key === toDateKey(new Date());
|
const isToday = key === todayKey;
|
||||||
|
const isSelected = key === selectedKey;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
className={`flex min-h-24 flex-col gap-1 border-r border-b p-1.5 last:border-r-0 ${
|
type="button"
|
||||||
isCurrentMonth ? "bg-card" : "bg-muted/40"
|
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
|
<span
|
||||||
className={`text-xs ${isCurrentMonth ? "text-foreground" : "text-muted-foreground"} ${
|
className={cn(
|
||||||
isToday ? "font-semibold text-primary" : ""
|
"flex items-center gap-1 text-xs",
|
||||||
}`}
|
isCurrentMonth ? "text-foreground" : "text-muted-foreground",
|
||||||
|
isToday && "font-semibold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-6 items-center justify-center rounded-full",
|
||||||
|
isToday && "bg-primary text-primary-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{day.getDate()}
|
{day.getDate()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1">
|
{isToday ? <span>Heute</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
{dayEvents.slice(0, 3).map((event) => {
|
{dayEvents.slice(0, 3).map((event) => {
|
||||||
const Icon = EVENT_ICON[event.type];
|
const meta = EVENT_META[event.type];
|
||||||
const href = eventHref(event);
|
const Icon = meta.icon;
|
||||||
const content = (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`flex items-center gap-1 truncate rounded px-1 py-0.5 text-[0.7rem] ${EVENT_TONE[event.type]}`}
|
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" />
|
<Icon className="size-3 shrink-0" aria-hidden />
|
||||||
<span className="truncate">{event.title}</span>
|
<span className="truncate">{event.title}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
return href ? (
|
|
||||||
<Link key={event.id} href={href}>
|
|
||||||
{content}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div key={event.id}>{content}</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
{dayEvents.length > 3 ? (
|
{dayEvents.length > 3 ? (
|
||||||
<span className="text-[0.7rem] text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
+{dayEvents.length - 3} weitere
|
+{dayEvents.length - 3} weitere
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedDay ? (
|
||||||
|
<DayEvents
|
||||||
|
date={selectedDay}
|
||||||
|
events={eventsByDay.get(selectedKey) ?? []}
|
||||||
|
isToday={selectedKey === todayKey}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user