diff --git a/CLAUDE.md b/CLAUDE.md
index 20ea39b..e6391e7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,7 +32,10 @@ user-facing overview.
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
tree, and `Task`s can link to an animal (`Task.animalId`).
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
-- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`.
+- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`
+ (incl. `occurrencesInRange` projection). **/calendar** renders a month view of
+ all tasks (garden + house) plus PlantType bloom/harvest windows
+ (`src/lib/garden/season.ts`).
- **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`).
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
diff --git a/src/app/(dashboard)/calendar/page.tsx b/src/app/(dashboard)/calendar/page.tsx
new file mode 100644
index 0000000..59fe6c4
--- /dev/null
+++ b/src/app/(dashboard)/calendar/page.tsx
@@ -0,0 +1,67 @@
+import { db } from "@/lib/db";
+import { CalendarView, type CalendarTask, type SeasonType } from "@/components/calendar/calendar-view";
+
+export const metadata = { title: "Calendar" };
+export const dynamic = "force-dynamic";
+
+export default async function CalendarPage() {
+ const [tasks, types] = await Promise.all([
+ db.task.findMany({
+ where: { active: true },
+ orderBy: { nextDue: "asc" },
+ include: {
+ plant: { select: { commonName: true, variety: true } },
+ animal: { select: { name: true } },
+ item: { select: { name: true } },
+ location: { select: { name: true } },
+ },
+ }),
+ db.plantType.findMany({
+ where: {
+ active: true,
+ plants: { some: { active: true } },
+ OR: [
+ { bloomStart: { not: null } },
+ { bloomEnd: { not: null } },
+ { harvestStart: { not: null } },
+ { harvestEnd: { not: null } },
+ ],
+ },
+ orderBy: { commonName: "asc" },
+ select: {
+ id: true, commonName: true,
+ bloomStart: true, bloomEnd: true, harvestStart: true, harvestEnd: true,
+ },
+ }),
+ ]);
+
+ const calendarTasks: CalendarTask[] = tasks.map((t) => ({
+ id: t.id,
+ title: t.title,
+ category: t.category,
+ nextDue: t.nextDue.toISOString(),
+ recurrence: t.recurrence,
+ intervalDays: t.intervalDays,
+ month: t.month,
+ linkedTo:
+ (t.plant && `${t.plant.commonName}${t.plant.variety ? ` (${t.plant.variety})` : ""}`) ||
+ t.animal?.name ||
+ t.item?.name ||
+ t.location?.name ||
+ null,
+ }));
+
+ const seasonTypes: SeasonType[] = types;
+
+ return (
+
+
+
Calendar
+
+ Everything due around the homestead — garden and house — plus what's blooming and ready to pick.
+
+
+
+
+ );
+}
diff --git a/src/components/calendar/calendar-view.tsx b/src/components/calendar/calendar-view.tsx
new file mode 100644
index 0000000..83ab820
--- /dev/null
+++ b/src/components/calendar/calendar-view.tsx
@@ -0,0 +1,225 @@
+"use client";
+
+// Month calendar merging every kind of task (garden, home, equipment) with
+// the garden's bloom/harvest windows. Tasks project forward from their
+// nextDue via occurrencesInRange; windows come from PlantType months.
+import { useMemo, useState } from "react";
+import Link from "next/link";
+import { AlertTriangle, Apple, ChevronLeft, ChevronRight, Flower2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { occurrencesInRange, describeRecurrence, type RecurrenceType } from "@/lib/tasks/schedule";
+import { monthInWindow } from "@/lib/garden/season";
+import { CompleteTaskButton } from "@/components/tasks/complete-task-button";
+import { cn } from "@/lib/utils";
+
+export type CalendarTask = {
+ id: string;
+ title: string;
+ category: "GARDEN" | "HOME" | "EQUIPMENT" | "OTHER";
+ nextDue: string; // ISO
+ recurrence: RecurrenceType;
+ intervalDays: number | null;
+ month: number | null;
+ linkedTo: string | null; // plant/animal/item/location name
+};
+
+export type SeasonType = {
+ id: string;
+ commonName: string;
+ bloomStart: number | null;
+ bloomEnd: number | null;
+ harvestStart: number | null;
+ harvestEnd: number | null;
+};
+
+const CATEGORY_STYLES: Record = {
+ GARDEN: "bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))]",
+ HOME: "bg-amber-100 text-amber-800",
+ EQUIPMENT: "bg-sky-100 text-sky-800",
+ OTHER: "bg-muted text-muted-foreground",
+};
+
+const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+function iso(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+}
+
+export function CalendarView({ tasks, seasonTypes }: {
+ tasks: CalendarTask[];
+ seasonTypes: SeasonType[];
+}) {
+ const today = new Date();
+ const todayIso = iso(today);
+ const [year, setYear] = useState(today.getFullYear());
+ const [month, setMonth] = useState(today.getMonth()); // 0-indexed
+ const [selected, setSelected] = useState(todayIso);
+
+ const monthStart = new Date(year, month, 1);
+ const monthEnd = new Date(year, month + 1, 0);
+
+ const byDay = useMemo(() => {
+ const map = new Map();
+ for (const t of tasks) {
+ const occ = occurrencesInRange(
+ { nextDue: new Date(t.nextDue), recurrence: t.recurrence, intervalDays: t.intervalDays, month: t.month },
+ monthStart,
+ monthEnd,
+ );
+ for (const d of occ) {
+ const key = iso(d);
+ if (!map.has(key)) map.set(key, []);
+ map.get(key)!.push(t);
+ }
+ }
+ return map;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tasks, year, month]);
+
+ const blooming = seasonTypes.filter((t) => monthInWindow(month + 1, t.bloomStart, t.bloomEnd));
+ const harvesting = seasonTypes.filter((t) => monthInWindow(month + 1, t.harvestStart, t.harvestEnd));
+ const overdue = tasks.filter((t) => new Date(t.nextDue) < new Date(todayIso));
+
+ function nav(delta: number) {
+ const d = new Date(year, month + delta, 1);
+ setYear(d.getFullYear());
+ setMonth(d.getMonth());
+ setSelected(null);
+ }
+
+ const leadingBlanks = monthStart.getDay();
+ const daysInMonth = monthEnd.getDate();
+ const selectedTasks = selected ? byDay.get(selected) ?? [] : [];
+
+ return (
+
+
+
+ {monthStart.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
+
+
+
+
+
+
+ {(blooming.length > 0 || harvesting.length > 0) && (
+
+ {harvesting.map((t) => (
+
+ {t.commonName} ready to harvest
+
+ ))}
+ {blooming.map((t) => (
+
+ {t.commonName} in bloom
+
+ ))}
+
+ )}
+
+ {overdue.length > 0 && (
+
+
+ {overdue.length} {overdue.length === 1 ? "task is" : "tasks are"} overdue — see Reminders
+
+ )}
+
+
+
+ {WEEKDAYS.map((w) => (
+
{w}
+ ))}
+
+
+ {Array.from({ length: leadingBlanks }).map((_, i) => (
+
+ ))}
+ {Array.from({ length: daysInMonth }).map((_, i) => {
+ const day = i + 1;
+ const key = iso(new Date(year, month, day));
+ const dayTasks = byDay.get(key) ?? [];
+ const isToday = key === todayIso;
+ const isSelected = key === selected;
+ return (
+
+ );
+ })}
+
+
+
+ {selected && (
+
+
+ {new Date(`${selected}T00:00:00`).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}
+
+ {selectedTasks.length === 0 ? (
+
Nothing scheduled.
+ ) : (
+
+ {selectedTasks.map((t) => {
+ const isCurrentDue = iso(new Date(t.nextDue)) === selected;
+ return (
+
+
+ {t.category.toLowerCase()}
+
+
+
{t.title}
+
+ {describeRecurrence(t.recurrence, t.intervalDays, t.month)}
+ {t.linkedTo && ` · ${t.linkedTo}`}
+
+
+ {isCurrentDue &&
}
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/layout/nav-items.ts b/src/components/layout/nav-items.ts
index 2bc98c8..9c0d31c 100644
--- a/src/components/layout/nav-items.ts
+++ b/src/components/layout/nav-items.ts
@@ -1,6 +1,7 @@
import {
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell,
- Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, type LucideIcon,
+ Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, CalendarDays,
+ type LucideIcon,
} from "lucide-react";
export type NavItem = { label: string; href: string; icon: LucideIcon; section?: string };
@@ -13,6 +14,7 @@ export const navItems: NavItem[] = [
{ label: "Animals", href: "/animals", icon: PawPrint },
{ label: "Locations", href: "/locations", icon: MapPin },
{ label: "Reminders", href: "/tasks", icon: Bell },
+ { label: "Calendar", href: "/calendar", icon: CalendarDays },
];
export const kitchenItems: NavItem[] = [
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index b109ce1..6be537e 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -8,6 +8,15 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.26.0",
+ date: "2026-07-06",
+ changes: [
+ "New Calendar page (in the menu, next to Reminders) — a month view of everything due around the homestead. Garden chores, furnace filter, vacuum servicing: one calendar. Repeating reminders show every time they'll come up, not just the next one.",
+ "The calendar also knows your garden's seasons: chips at the top of each month show what's in bloom and what's ready to harvest, based on the bloom/harvest months saved on your plant types.",
+ "Click any day to see its tasks and mark them done right there; anything overdue is flagged at the top with a link to Reminders.",
+ ],
+ },
{
version: "0.25.0",
date: "2026-07-06",
diff --git a/src/lib/garden/season.test.ts b/src/lib/garden/season.test.ts
new file mode 100644
index 0000000..12cea59
--- /dev/null
+++ b/src/lib/garden/season.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { monthInWindow } from "./season";
+
+describe("monthInWindow", () => {
+ it("plain range", () => {
+ expect(monthInWindow(7, 6, 9)).toBe(true);
+ expect(monthInWindow(5, 6, 9)).toBe(false);
+ });
+
+ it("wraps the year end", () => {
+ expect(monthInWindow(11, 10, 3)).toBe(true);
+ expect(monthInWindow(2, 10, 3)).toBe(true);
+ expect(monthInWindow(7, 10, 3)).toBe(false);
+ });
+
+ it("single-ended windows collapse to one month", () => {
+ expect(monthInWindow(6, 6, null)).toBe(true);
+ expect(monthInWindow(7, 6, null)).toBe(false);
+ expect(monthInWindow(9, null, 9)).toBe(true);
+ });
+
+ it("no window at all", () => {
+ expect(monthInWindow(6, null, null)).toBe(false);
+ });
+});
diff --git a/src/lib/garden/season.ts b/src/lib/garden/season.ts
new file mode 100644
index 0000000..62d5c96
--- /dev/null
+++ b/src/lib/garden/season.ts
@@ -0,0 +1,10 @@
+// Season-window math for PlantType bloom/harvest month ranges (1–12).
+// Windows may wrap the year end (e.g. Oct–Mar).
+
+/** Is `month` (1–12) inside the inclusive window start..end, wrapping year end? */
+export function monthInWindow(month: number, start: number | null, end: number | null): boolean {
+ if (start == null && end == null) return false;
+ const s = start ?? end!;
+ const e = end ?? start!;
+ return s <= e ? month >= s && month <= e : month >= s || month <= e;
+}
diff --git a/src/lib/tasks/schedule.test.ts b/src/lib/tasks/schedule.test.ts
new file mode 100644
index 0000000..51fe080
--- /dev/null
+++ b/src/lib/tasks/schedule.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest";
+import { nextDueDate, occurrencesInRange, type ProjectableTask } from "./schedule";
+
+const d = (s: string) => new Date(`${s}T00:00:00`);
+
+describe("nextDueDate", () => {
+ it("ONCE never recurs", () => {
+ expect(nextDueDate("ONCE", d("2026-07-06"), null, null)).toBeNull();
+ });
+
+ it("CUSTOM adds the interval (default 30)", () => {
+ expect(nextDueDate("CUSTOM", d("2026-07-06"), 14, null)).toEqual(d("2026-07-20"));
+ expect(nextDueDate("CUSTOM", d("2026-07-06"), null, null)).toEqual(d("2026-08-05"));
+ });
+
+ it("MONTHLY moves to next month", () => {
+ expect(nextDueDate("MONTHLY", d("2026-07-06"), null, null)).toEqual(d("2026-08-06"));
+ });
+
+ it("YEARLY lands on the 1st of the target month next year", () => {
+ expect(nextDueDate("YEARLY", d("2026-07-06"), null, 4)).toEqual(d("2027-04-01"));
+ });
+});
+
+function task(overrides: Partial): ProjectableTask {
+ return { nextDue: d("2026-07-10"), recurrence: "ONCE", intervalDays: null, month: null, ...overrides };
+}
+
+describe("occurrencesInRange", () => {
+ it("ONCE: shows only on its due date", () => {
+ const t = task({});
+ expect(occurrencesInRange(t, d("2026-07-01"), d("2026-07-31"))).toEqual([d("2026-07-10")]);
+ expect(occurrencesInRange(t, d("2026-08-01"), d("2026-08-31"))).toEqual([]);
+ });
+
+ it("CUSTOM: repeats by interval within the range", () => {
+ const t = task({ recurrence: "CUSTOM", intervalDays: 7 });
+ expect(occurrencesInRange(t, d("2026-07-01"), d("2026-07-31"))).toEqual([
+ d("2026-07-10"), d("2026-07-17"), d("2026-07-24"), d("2026-07-31"),
+ ]);
+ });
+
+ it("CUSTOM: projects into a later month without drifting", () => {
+ const t = task({ recurrence: "CUSTOM", intervalDays: 14 });
+ expect(occurrencesInRange(t, d("2026-09-01"), d("2026-09-30"))).toEqual([
+ d("2026-09-04"), d("2026-09-18"),
+ ]);
+ });
+
+ it("an overdue occurrence stays visible in its own (past) month", () => {
+ const t = task({ recurrence: "CUSTOM", intervalDays: 30, nextDue: d("2026-06-20") });
+ expect(occurrencesInRange(t, d("2026-06-01"), d("2026-06-30"))).toEqual([d("2026-06-20")]);
+ });
+
+ it("MONTHLY: same day each month, clamped at short months", () => {
+ const t = task({ recurrence: "MONTHLY", nextDue: d("2026-01-31") });
+ expect(occurrencesInRange(t, d("2026-02-01"), d("2026-02-28"))).toEqual([d("2026-02-28")]);
+ expect(occurrencesInRange(t, d("2026-03-01"), d("2026-03-31"))).toEqual([d("2026-03-31")]);
+ });
+
+ it("MONTHLY: never projects before the stored due date", () => {
+ const t = task({ recurrence: "MONTHLY", nextDue: d("2026-07-15") });
+ expect(occurrencesInRange(t, d("2026-06-01"), d("2026-06-30"))).toEqual([]);
+ });
+
+ it("YEARLY: shows the stored due and the same date next year", () => {
+ const t = task({ recurrence: "YEARLY", nextDue: d("2026-10-01"), month: 10 });
+ expect(occurrencesInRange(t, d("2026-10-01"), d("2026-10-31"))).toEqual([d("2026-10-01")]);
+ expect(occurrencesInRange(t, d("2027-10-01"), d("2027-10-31"))).toEqual([d("2027-10-01")]);
+ });
+
+ it("returns nothing for an inverted range", () => {
+ expect(occurrencesInRange(task({}), d("2026-07-31"), d("2026-07-01"))).toEqual([]);
+ });
+});
diff --git a/src/lib/tasks/schedule.ts b/src/lib/tasks/schedule.ts
index c5ea08f..4d4fc47 100644
--- a/src/lib/tasks/schedule.ts
+++ b/src/lib/tasks/schedule.ts
@@ -74,3 +74,90 @@ export const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
+
+// ---------------------------------------------------------------------------
+// Occurrence projection — which days does a task land on within a date range?
+// The DB stores only the next due date; recurring tasks repeat from there.
+// Used by the calendar to paint future (and the one overdue) occurrence(s).
+// ---------------------------------------------------------------------------
+
+export type ProjectableTask = {
+ nextDue: Date;
+ recurrence: RecurrenceType;
+ intervalDays: number | null;
+ month: number | null; // 1–12, for YEARLY
+};
+
+/** Same day-of-month in the given month, clamped to its last day (Jan 31 → Feb 28). */
+function onDayOfMonth(year: number, monthIdx: number, day: number): Date {
+ const lastDay = new Date(year, monthIdx + 1, 0).getDate();
+ return new Date(year, monthIdx, Math.min(day, lastDay));
+}
+
+function startOfDay(d: Date): Date {
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
+}
+
+/**
+ * All days in [rangeStart, rangeEnd] (inclusive) the task lands on.
+ * The stored nextDue is always an occurrence (even if overdue/in the past);
+ * recurring tasks project forward from it, never backward.
+ */
+export function occurrencesInRange(
+ task: ProjectableTask,
+ rangeStart: Date,
+ rangeEnd: Date,
+): Date[] {
+ const due = startOfDay(new Date(task.nextDue));
+ const start = startOfDay(rangeStart);
+ const end = startOfDay(rangeEnd);
+ if (start > end) return [];
+
+ const inRange = (d: Date) => d >= start && d <= end;
+ const out: Date[] = [];
+ const push = (d: Date) => {
+ if (inRange(d) && !out.some((o) => o.getTime() === d.getTime())) out.push(d);
+ };
+
+ switch (task.recurrence) {
+ case "ONCE": {
+ push(due);
+ break;
+ }
+ case "CUSTOM": {
+ const step = Math.max(1, task.intervalDays ?? 30);
+ push(due); // the stored (possibly overdue) occurrence stays visible
+ let d = due;
+ if (d < start) {
+ // Jump close to the range start instead of stepping day by day.
+ const behind = Math.ceil((start.getTime() - d.getTime()) / (step * 86_400_000));
+ d = new Date(d.getTime() + behind * step * 86_400_000);
+ }
+ for (; d <= end; d = new Date(d.getTime() + step * 86_400_000)) push(d);
+ break;
+ }
+ case "MONTHLY": {
+ const day = due.getDate();
+ push(due);
+ let y = start.getFullYear();
+ let m = start.getMonth();
+ for (; new Date(y, m, 1) <= end; m === 11 ? (m = 0, y++) : m++) {
+ const d = onDayOfMonth(y, m, day);
+ if (d >= due) push(d);
+ }
+ break;
+ }
+ case "YEARLY": {
+ push(due);
+ const targetMonth = (task.month ?? due.getMonth() + 1) - 1;
+ const day = due.getDate();
+ for (let y = start.getFullYear(); y <= end.getFullYear(); y++) {
+ const d = onDayOfMonth(y, targetMonth, day);
+ if (d > due) push(d);
+ }
+ break;
+ }
+ }
+
+ return out.sort((a, b) => a.getTime() - b.getTime());
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
index bd3409a..2bdb10a 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
-export const APP_VERSION = "0.25.0";
+export const APP_VERSION = "0.26.0";