diff --git a/STATUS.md b/STATUS.md index 03abfd3..bf0906e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -23,6 +23,14 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **Journal + dashboard polish** (commit `a09747c`, follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open. +- **89 — Calendar reminders overhaul** (Gitea #28). Multiple reminders per event via `syncCalendarEventReminders`; `ReminderPicker` with presets + custom offsets; migration `0022_multiple_reminders.sql` drops one-per-entity unique index, adds `offset_minutes` + `users.default_event_reminder_offsets`. Settings → Notifications default reminders editor. Create/edit/quick-add calendar flows updated; drag-reschedule resyncs existing offsets. + +- **90 — Lists index inline add + edit** (Gitea #29). `/lists` cards: inline task add row, pencil edit for name/type via `updateListProperties`. + +- **91 — Comments on lists and tasks** (Gitea #30). Generic `comments` table + `_core/comments.ts`; reusable `EntityComments` on list detail (list + per-item compact threads). Migration `0023_comments.sql`. + +- **92 — Bang stats dashboard widget** (Gitea #31). `bangs.stats` widget with monthly/yearly counts, average days between bangs, recent month breakdown. + - **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green. - **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility. - **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes. @@ -73,7 +81,7 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 7–9, 11–12, 15–19. -**How to resume:** Phase 9 P1 batch complete. Next work is P2/P3 Gitea backlog or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`). +**How to resume:** P2 batch #28–#31 complete. Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`). Run `pnpm db:migrate` for migrations `0022` and `0023`. ## Development login/testing notes diff --git a/docs/tasks/89-calendar-reminders-overhaul.md b/docs/tasks/89-calendar-reminders-overhaul.md new file mode 100644 index 0000000..df10804 --- /dev/null +++ b/docs/tasks/89-calendar-reminders-overhaul.md @@ -0,0 +1,39 @@ +# 89 — Calendar reminders overhaul + +## Goal + +Replace the single "Remind me 30 min before" checkbox with multiple reminders, presets, and custom offsets. + +## What we decided + +- Store multiple reminder rows per calendar event in the generic `reminders` table (drop one-per-entity unique constraint). +- Offsets are minutes before event start; presets + custom value/unit in UI. +- Per-user default offsets live on `users.default_event_reminder_offsets` (jsonb, default `[30]`). +- Notes/garden keep single-reminder behavior via delete-then-insert in `scheduleReminder`. + +## Depends on + +- 41 (reminders engine) + +## Scope + +- Migration: drop `reminders_entity_unique`, add `offset_minutes`, add user default column. +- `_core/reminders.ts`: `syncRemindersForEntity`, refactor `scheduleReminder`. +- Shared `ReminderPicker` component + offset helpers. +- Calendar create/edit + quick-add dialogs; sync reminders on create/update/delete. +- Optional: settings field for default reminder offsets. + +## Out of scope + +- Recurring reminders, snooze. + +## Acceptance criteria + +- [x] Users can add/remove multiple reminders on an event +- [x] Preset list and custom offset both work +- [x] Reminders fire via existing notification bus +- [x] Per-user default reminder preferences (stored + applied on new events) + +## Notes + +- Gitea: [ginnoir/famapp#28](https://gitea.ginnoir.com/ginnoir/famapp/issues/28) diff --git a/docs/tasks/90-lists-index-inline.md b/docs/tasks/90-lists-index-inline.md new file mode 100644 index 0000000..97f9544 --- /dev/null +++ b/docs/tasks/90-lists-index-inline.md @@ -0,0 +1,30 @@ +# 90 — Lists index: inline task add + list property edit + +## Goal + +From `/lists`, add tasks and edit list properties without opening the full list page. + +## What we decided + +- Inline add input at the bottom of each list card (Enter to submit, same as detail page). +- Inline edit for list name and type via a small edit control on the card header. +- Reuse existing `addItem`, `renameList`, and extended `updateList` server actions. + +## Depends on + +- 11 (lists module) + +## Scope + +- Extend `listUpdateInput` to allow `type` changes. +- Update `lists-index.tsx` with add-item row and list property editor. + +## Acceptance criteria + +- [x] Add an item to a list from the index +- [x] Edit list properties from the index +- [x] Changes persist and match detail-page behavior + +## Notes + +- Gitea: [ginnoir/famapp#29](https://gitea.ginnoir.com/ginnoir/famapp/issues/29) diff --git a/docs/tasks/91-list-comments.md b/docs/tasks/91-list-comments.md new file mode 100644 index 0000000..0fec682 --- /dev/null +++ b/docs/tasks/91-list-comments.md @@ -0,0 +1,39 @@ +# 91 — Comments on lists and tasks + +## Goal + +Household members can comment on lists and individual list items using a reusable comment component. + +## What we decided + +- Generic `comments` table in `_core` keyed by `(entity_type, entity_id)`. +- Reusable `EntityComments` client component in `src/components/comments/`. +- Wired on list detail: list-level thread + per-item expandable comments. +- Entity-generic design so notes/other modules can adopt later without schema changes. + +## Depends on + +- 11 (lists module) + +## Scope + +- Migration: `comments` table. +- `_core/comments.ts`: list, add, delete (author-only). +- `EntityComments` UI. +- Lists detail page integration for `lists.list` and `lists.item`. + +## Out of scope + +- Comments on notes/calendar (future adoption of same component). +- Rich-text comments (plain text only). + +## Acceptance criteria + +- [x] Comment on a list +- [x] Comment on a task/item +- [x] Household members see each other's comments +- [x] Component is entity-generic (documented in brief) + +## Notes + +- Gitea: [ginnoir/famapp#30](https://gitea.ginnoir.com/ginnoir/famapp/issues/30) diff --git a/docs/tasks/92-bang-stats-widget.md b/docs/tasks/92-bang-stats-widget.md new file mode 100644 index 0000000..e8750ad --- /dev/null +++ b/docs/tasks/92-bang-stats-widget.md @@ -0,0 +1,31 @@ +# 92 — Bang stats dashboard widget + +## Goal + +A second bangs dashboard widget showing monthly/yearly counts and average days between bangs. + +## What we decided + +- Widget id `bangs.stats`, separate from `bangs.counter`. +- Aggregates computed server-side from `bang_events.occurred_on`. +- Works alongside edit/delete from task 83. + +## Depends on + +- Bangs module, 83 (edit/delete) + +## Scope + +- `getBangAggregates` query. +- `BangStatsWidget` component. +- Manifest registration. + +## Acceptance criteria + +- [x] Widget registered and placeable on dashboards +- [x] Shows monthly and yearly counts +- [x] Shows average days between bangs + +## Notes + +- Gitea: [ginnoir/famapp#31](https://gitea.ginnoir.com/ginnoir/famapp/issues/31) diff --git a/drizzle/0022_multiple_reminders.sql b/drizzle/0022_multiple_reminders.sql new file mode 100644 index 0000000..e85fe07 --- /dev/null +++ b/drizzle/0022_multiple_reminders.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS "reminders_entity_unique"; +--> statement-breakpoint +ALTER TABLE "reminders" ADD COLUMN "offset_minutes" integer; +--> statement-breakpoint +CREATE INDEX "reminders_entity_idx" ON "reminders" USING btree ("entity_type","entity_id"); +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "default_event_reminder_offsets" jsonb DEFAULT '[30]'::jsonb NOT NULL; diff --git a/drizzle/0023_comments.sql b/drizzle/0023_comments.sql new file mode 100644 index 0000000..7dce8f5 --- /dev/null +++ b/drizzle/0023_comments.sql @@ -0,0 +1,16 @@ +CREATE TABLE "comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "entity_type" text NOT NULL, + "entity_id" uuid NOT NULL, + "author_id" uuid NOT NULL, + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "comments_entity_idx" ON "comments" USING btree ("entity_type","entity_id","created_at"); diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index bb5582d..8803581 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -1,5 +1,9 @@ import { CalendarShell } from "@/modules/calendar/components/calendar-shell"; -import { listCalendars, listEvents } from "@/modules/calendar/server/queries"; +import { + getDefaultEventReminderOffsets, + listCalendars, + listEvents, +} from "@/modules/calendar/server/queries"; import { getCurrentSession } from "@/lib/session"; import type { CalView } from "@/modules/_core/themes"; @@ -10,10 +14,11 @@ export default async function CalendarPage() { const to = new Date(now); to.setMonth(to.getMonth() + 10); - const [{ user }, calendars, events] = await Promise.all([ + const [{ user }, calendars, events, defaultReminderOffsets] = await Promise.all([ getCurrentSession(), listCalendars(), listEvents({ from, to, calendarIds: "all" }), + getDefaultEventReminderOffsets(), ]); return ( @@ -21,6 +26,7 @@ export default async function CalendarPage() { calendars={calendars} events={events} defaultView={user.themeCalView as CalView} + defaultReminderOffsets={defaultReminderOffsets} /> ); } diff --git a/src/app/lists/[id]/page.tsx b/src/app/lists/[id]/page.tsx index 75d713e..61c694d 100644 --- a/src/app/lists/[id]/page.tsx +++ b/src/app/lists/[id]/page.tsx @@ -1,11 +1,12 @@ import { notFound } from "next/navigation"; +import { getCurrentSession } from "@/lib/session"; import { ListDetail } from "@/modules/lists/components/list-detail"; import { getList } from "@/modules/lists/server/queries"; export default async function ListPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const list = await getList(id).catch(() => null); + const [{ user }, list] = await Promise.all([getCurrentSession(), getList(id).catch(() => null)]); if (!list) notFound(); - return ; + return ; } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index b2dfe02..c0b3213 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -14,6 +14,7 @@ import { revokeShareLinkAction } from "./actions"; import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token"; import { ApiTokenSettings } from "@/components/api-token-settings"; import { AssistantOptIn } from "@/components/assistant-opt-in"; +import { DefaultEventRemindersSetting } from "@/components/default-event-reminders-setting"; import { listCalendars } from "@/modules/calendar/server/queries"; import { listLists } from "@/modules/lists/server/queries"; import Link from "next/link"; @@ -61,7 +62,12 @@ export default async function SettingsPage({ {section === "household" && } {section === "sharing" && } {section === "notifications" && ( - + )} {section === "calendars" && } {section === "appearance" && } @@ -185,10 +191,12 @@ async function SharingSection() { function NotificationsSection({ user, + defaultEventReminderOffsets, vapidKey, ntfyConfigured, }: { user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean }; + defaultEventReminderOffsets: number[]; vapidKey: string; ntfyConfigured: boolean; }) { @@ -216,6 +224,18 @@ function NotificationsSection({ /> + + + + Default event reminders + + + + + ); } diff --git a/src/app/settings/reminder-actions.ts b/src/app/settings/reminder-actions.ts new file mode 100644 index 0000000..0f3a9b5 --- /dev/null +++ b/src/app/settings/reminder-actions.ts @@ -0,0 +1,21 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { normalizeReminderOffsets } from "@/lib/reminder-offsets"; +import { users } from "@/modules/_core/schema"; + +const offsetsSchema = z.array(z.number().int().min(0)).max(20); + +export async function setDefaultEventReminderOffsets(offsets: number[]) { + const { user } = await getCurrentSession(); + const parsed = normalizeReminderOffsets(offsetsSchema.parse(offsets)); + + await db.update(users).set({ defaultEventReminderOffsets: parsed }).where(eq(users.id, user.id)); + + revalidatePath("/calendar"); + revalidatePath("/settings"); +} diff --git a/src/components/comments/entity-comments.tsx b/src/components/comments/entity-comments.tsx new file mode 100644 index 0000000..8099fd9 --- /dev/null +++ b/src/components/comments/entity-comments.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { MessageSquare, Trash2 } from "lucide-react"; +import { useEffect, useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { addComment, deleteComment, listComments, type CommentDto } from "@/modules/_core/comments"; + +type Props = { + entityType: string; + entityId: string; + currentUserId: string; + compact?: boolean; +}; + +export function EntityComments({ entityType, entityId, currentUserId, compact = false }: Props) { + const [open, setOpen] = useState(!compact); + const [comments, setComments] = useState([]); + const [draft, setDraft] = useState(""); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + if (!open) return; + listComments(entityType, entityId) + .then(setComments) + .catch(() => setComments([])); + }, [entityType, entityId, open]); + + function submitComment() { + const body = draft.trim(); + if (!body) return; + startTransition(async () => { + const created = await addComment({ entityType, entityId, body }); + setComments((current) => [...current, created]); + setDraft(""); + }); + } + + function removeComment(id: string) { + startTransition(async () => { + await deleteComment({ id }); + setComments((current) => current.filter((comment) => comment.id !== id)); + }); + } + + return ( +
+ {compact ? ( + + ) : ( +
Comments
+ )} + + {open && ( +
+ {comments.length === 0 ? ( +

No comments yet.

+ ) : ( +
    + {comments.map((comment) => ( +
  • +
    +
    +
    + {comment.authorName ?? "Someone"} · {formatCommentTime(comment.createdAt)} +
    +

    {comment.body}

    +
    + {comment.authorId === currentUserId ? ( + + ) : null} +
    +
  • + ))} +
+ )} + +
+ setDraft(e.target.value)} + placeholder="Add a comment…" + disabled={isPending} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitComment(); + } + }} + /> + +
+
+ )} +
+ ); +} + +function formatCommentTime(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} diff --git a/src/components/default-event-reminders-setting.tsx b/src/components/default-event-reminders-setting.tsx new file mode 100644 index 0000000..4119764 --- /dev/null +++ b/src/components/default-event-reminders-setting.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { ReminderPicker } from "@/components/reminder-picker"; +import { Button } from "@/components/ui/button"; +import { setDefaultEventReminderOffsets } from "@/app/settings/reminder-actions"; + +export function DefaultEventRemindersSetting({ initialOffsets }: { initialOffsets: number[] }) { + const [saved, setSaved] = useState(initialOffsets); + const [offsets, setOffsets] = useState(initialOffsets); + const [isPending, startTransition] = useTransition(); + + const dirty = + offsets.length !== saved.length || offsets.some((value, index) => value !== saved[index]); + + function handleSave() { + startTransition(async () => { + await setDefaultEventReminderOffsets(offsets); + setSaved(offsets); + }); + } + + return ( +
+

+ Applied when you create a new calendar event. You can still customize reminders per event. +

+ + +
+ ); +} diff --git a/src/components/quick-add/dialogs/calendar-event-create-dialog.tsx b/src/components/quick-add/dialogs/calendar-event-create-dialog.tsx index 2ca90dc..a34445c 100644 --- a/src/components/quick-add/dialogs/calendar-event-create-dialog.tsx +++ b/src/components/quick-add/dialogs/calendar-event-create-dialog.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; import { useEffect, useMemo, useState, useTransition } from "react"; +import { ReminderPicker } from "@/components/reminder-picker"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -22,7 +23,11 @@ import { } from "@/components/ui/select"; import { createEvent } from "@/modules/calendar/server/actions"; import { richTextToPlainText } from "@/components/rich-text"; -import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries"; +import { + getDefaultEventReminderOffsets, + listCalendars, + type CalendarDto, +} from "@/modules/calendar/server/queries"; const RichTextEditor = dynamic( () => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor), @@ -59,7 +64,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) { const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000))); const [location, setLocation] = useState(""); const [notes, setNotes] = useState(""); - const [remind, setRemind] = useState(true); + const [reminderOffsets, setReminderOffsets] = useState([30]); const [isPending, startTransition] = useTransition(); useEffect(() => { @@ -67,6 +72,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) { setCalendars(rows); setCalendarId(rows[0]?.id ?? ""); }); + getDefaultEventReminderOffsets().then(setReminderOffsets); }, []); const calendarItems = useMemo( @@ -86,7 +92,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) { allDay: false, location: location || null, notes: richTextToPlainText(notes) ? notes : null, - remindMinutesBefore: remind ? 30 : null, + reminderOffsets, }); onDone(); }); @@ -171,15 +177,11 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) { placeholder="Add details…" /> - + + + ))} + + )} + + {availablePresets.length > 0 && ( +
+ {availablePresets.map((preset) => ( + + ))} +
+ )} + +
+
+ + setCustomValue(e.target.value)} + /> +
+
+ + +
+ +
+ + ); +} diff --git a/src/lib/reminder-offsets.ts b/src/lib/reminder-offsets.ts new file mode 100644 index 0000000..ea943ff --- /dev/null +++ b/src/lib/reminder-offsets.ts @@ -0,0 +1,62 @@ +export type ReminderOffsetUnit = "minutes" | "hours" | "days" | "weeks"; + +export type ReminderPreset = { + label: string; + offsetMinutes: number; +}; + +export const REMINDER_PRESETS: ReminderPreset[] = [ + { label: "At event time", offsetMinutes: 0 }, + { label: "5 minutes before", offsetMinutes: 5 }, + { label: "15 minutes before", offsetMinutes: 15 }, + { label: "30 minutes before", offsetMinutes: 30 }, + { label: "1 hour before", offsetMinutes: 60 }, + { label: "2 hours before", offsetMinutes: 120 }, + { label: "1 day before", offsetMinutes: 1440 }, + { label: "2 days before", offsetMinutes: 2880 }, + { label: "1 week before", offsetMinutes: 10080 }, +]; + +export function formatReminderOffset(offsetMinutes: number): string { + const preset = REMINDER_PRESETS.find((p) => p.offsetMinutes === offsetMinutes); + if (preset) return preset.label; + + if (offsetMinutes === 0) return "At event time"; + if (offsetMinutes % 10080 === 0) { + const weeks = offsetMinutes / 10080; + return weeks === 1 ? "1 week before" : `${weeks} weeks before`; + } + if (offsetMinutes % 1440 === 0) { + const days = offsetMinutes / 1440; + return days === 1 ? "1 day before" : `${days} days before`; + } + if (offsetMinutes % 60 === 0) { + const hours = offsetMinutes / 60; + return hours === 1 ? "1 hour before" : `${hours} hours before`; + } + return offsetMinutes === 1 ? "1 minute before" : `${offsetMinutes} minutes before`; +} + +export function customOffsetToMinutes(value: number, unit: ReminderOffsetUnit): number { + const safe = Math.max(0, Math.floor(value)); + switch (unit) { + case "minutes": + return safe; + case "hours": + return safe * 60; + case "days": + return safe * 1440; + case "weeks": + return safe * 10080; + } +} + +export function normalizeReminderOffsets(offsets: number[]): number[] { + return [...new Set(offsets.filter((n) => Number.isFinite(n) && n >= 0))].toSorted( + (a, b) => b - a, + ); +} + +export function fireAtForEventStart(startAt: Date, offsetMinutes: number): Date { + return new Date(startAt.getTime() - offsetMinutes * 60_000); +} diff --git a/src/modules/_core/comments.ts b/src/modules/_core/comments.ts new file mode 100644 index 0000000..338d860 --- /dev/null +++ b/src/modules/_core/comments.ts @@ -0,0 +1,107 @@ +"use server"; + +import { and, asc, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { comments, users } from "./schema"; + +export type CommentDto = { + id: string; + entityType: string; + entityId: string; + body: string; + authorId: string; + authorName: string | null; + createdAt: string; +}; + +const commentInput = z.object({ + entityType: z.string().trim().min(1).max(80), + entityId: z.string().uuid(), + body: z.string().trim().min(1).max(2000), +}); + +export async function listComments(entityType: string, entityId: string): Promise { + const { household } = await getCurrentSession(); + const parsedEntityId = z.string().uuid().parse(entityId); + + const rows = await db + .select({ + id: comments.id, + entityType: comments.entityType, + entityId: comments.entityId, + body: comments.body, + authorId: comments.authorId, + authorName: users.name, + createdAt: comments.createdAt, + }) + .from(comments) + .innerJoin(users, eq(comments.authorId, users.id)) + .where( + and( + eq(comments.householdId, household.id), + eq(comments.entityType, entityType), + eq(comments.entityId, parsedEntityId), + ), + ) + .orderBy(asc(comments.createdAt)); + + return rows.map((row) => ({ + id: row.id, + entityType: row.entityType, + entityId: row.entityId, + body: row.body, + authorId: row.authorId, + authorName: row.authorName, + createdAt: row.createdAt.toISOString(), + })); +} + +export async function addComment(input: z.input): Promise { + const parsed = commentInput.parse(input); + const { user, household } = await getCurrentSession(); + + const [row] = await db + .insert(comments) + .values({ + householdId: household.id, + entityType: parsed.entityType, + entityId: parsed.entityId, + authorId: user.id, + body: parsed.body, + }) + .returning(); + + if (!row) throw new Error("Comment was not created"); + + revalidatePath("/lists"); + + return { + id: row.id, + entityType: row.entityType, + entityId: row.entityId, + body: row.body, + authorId: row.authorId, + authorName: user.name, + createdAt: row.createdAt.toISOString(), + }; +} + +export async function deleteComment(input: { id: string }) { + const parsed = z.object({ id: z.string().uuid() }).parse(input); + const { user, household } = await getCurrentSession(); + + const [existing] = await db + .select({ id: comments.id, authorId: comments.authorId }) + .from(comments) + .where(and(eq(comments.id, parsed.id), eq(comments.householdId, household.id))) + .limit(1); + + if (!existing) throw new Error("Comment not found"); + if (existing.authorId !== user.id) throw new Error("Forbidden"); + + await db.delete(comments).where(eq(comments.id, parsed.id)); + revalidatePath("/lists"); +} diff --git a/src/modules/_core/index.ts b/src/modules/_core/index.ts index 5412a34..20b2ae3 100644 --- a/src/modules/_core/index.ts +++ b/src/modules/_core/index.ts @@ -24,4 +24,13 @@ export { createShareLink, resolveShareToken, revokeShareLink } from "./share"; export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share"; export { sendPush, sendPushToEndpoint } from "./push"; export { notify } from "./notify"; -export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders"; +export { + scheduleReminder, + cancelReminder, + listReminders, + listReminderOffsets, + syncRemindersForEntity, + syncCalendarEventReminders, + startReminderWorker, +} from "./reminders"; +export { listComments, addComment, deleteComment, type CommentDto } from "./comments"; diff --git a/src/modules/_core/reminders.ts b/src/modules/_core/reminders.ts index 015feb4..b76a7fd 100644 --- a/src/modules/_core/reminders.ts +++ b/src/modules/_core/reminders.ts @@ -1,11 +1,19 @@ import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import logger from "@/lib/logger"; +import { fireAtForEventStart } from "@/lib/reminder-offsets"; import { reminders } from "./schema"; import { notify } from "./notify"; const REMINDER_LOCK_KEY = 7_777_777; +type ReminderInput = { + fireAt: Date; + offsetMinutes?: number | null; + title?: string; + body?: string; +}; + export async function scheduleReminder(input: { householdId: string; entityType: string; @@ -15,30 +23,92 @@ export async function scheduleReminder(input: { channel?: string; title?: string; body?: string; + offsetMinutes?: number | null; }) { await db - .insert(reminders) - .values({ + .delete(reminders) + .where( + and( + eq(reminders.entityType, input.entityType), + eq(reminders.entityId, input.entityId), + isNull(reminders.firedAt), + ), + ); + + await db.insert(reminders).values({ + householdId: input.householdId, + entityType: input.entityType, + entityId: input.entityId, + fireAt: input.fireAt, + offsetMinutes: input.offsetMinutes ?? null, + channel: input.channel ?? "auto", + title: input.title ?? null, + body: input.body ?? null, + createdBy: input.createdBy, + firedAt: null, + }); +} + +export async function syncRemindersForEntity(input: { + householdId: string; + entityType: string; + entityId: string; + createdBy: string; + channel?: string; + reminders: ReminderInput[]; +}) { + await db + .delete(reminders) + .where( + and( + eq(reminders.entityType, input.entityType), + eq(reminders.entityId, input.entityId), + isNull(reminders.firedAt), + ), + ); + + const now = new Date(); + const rows = input.reminders.filter((r) => r.fireAt > now); + if (rows.length === 0) return; + + await db.insert(reminders).values( + rows.map((row) => ({ householdId: input.householdId, entityType: input.entityType, entityId: input.entityId, - fireAt: input.fireAt, + fireAt: row.fireAt, + offsetMinutes: row.offsetMinutes ?? null, channel: input.channel ?? "auto", - title: input.title ?? null, - body: input.body ?? null, + title: row.title ?? null, + body: row.body ?? null, createdBy: input.createdBy, firedAt: null, - }) - .onConflictDoUpdate({ - target: [reminders.entityType, reminders.entityId], - set: { - fireAt: input.fireAt, - title: input.title ?? null, - body: input.body ?? null, - firedAt: null, - createdBy: input.createdBy, - }, - }); + })), + ); +} + +export async function syncCalendarEventReminders(input: { + householdId: string; + eventId: string; + eventTitle: string; + startAt: Date; + createdBy: string; + offsetMinutes: number[]; +}) { + const remindersToSchedule = input.offsetMinutes.map((offset) => ({ + fireAt: fireAtForEventStart(input.startAt, offset), + offsetMinutes: offset, + title: input.eventTitle, + body: formatReminderOffsetBody(offset, input.eventTitle), + })); + + await syncRemindersForEntity({ + householdId: input.householdId, + entityType: "calendar.event", + entityId: input.eventId, + createdBy: input.createdBy, + reminders: remindersToSchedule, + }); } export async function cancelReminder(entityType: string, entityId: string) { @@ -54,6 +124,29 @@ export async function listReminders(entityType: string, entityId: string) { .where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId))); } +export async function listReminderOffsets(entityType: string, entityId: string): Promise { + const rows = await listReminders(entityType, entityId); + return rows + .filter((row) => row.offsetMinutes != null && row.firedAt == null) + .map((row) => row.offsetMinutes!) + .toSorted((a, b) => b - a); +} + +function formatReminderOffsetBody(offsetMinutes: number, title: string): string { + if (offsetMinutes === 0) return `${title} is starting now`; + if (offsetMinutes % 1440 === 0) { + const days = offsetMinutes / 1440; + return days === 1 ? `${title} starts in 1 day` : `${title} starts in ${days} days`; + } + if (offsetMinutes % 60 === 0) { + const hours = offsetMinutes / 60; + return hours === 1 ? `${title} starts in 1 hour` : `${title} starts in ${hours} hours`; + } + return offsetMinutes === 1 + ? `${title} starts in 1 minute` + : `${title} starts in ${offsetMinutes} minutes`; +} + async function tickReminders() { let dueReminders: (typeof reminders.$inferSelect)[] = []; @@ -94,7 +187,12 @@ async function tickReminders() { await notify(reminder.createdBy, { title: reminder.title ?? "Reminder", body: reminder.body ?? "You have a reminder", - url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/", + url: + reminder.entityType === "notes.note" + ? `/notes/${reminder.entityId}` + : reminder.entityType === "calendar.event" + ? "/calendar" + : "/", channels: ["push", "inapp"], }); } catch (err) { diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index e398a36..b65b171 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -36,6 +36,10 @@ export const users = pgTable("users", { notifInApp: boolean("notif_inapp").notNull().default(true), notifNtfy: boolean("notif_ntfy").notNull().default(false), assistantEnabled: boolean("assistant_enabled").notNull().default(false), + defaultEventReminderOffsets: jsonb("default_event_reminder_offsets") + .notNull() + .$type() + .default([30]), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -170,12 +174,13 @@ export const reminders = pgTable( channel: text("channel").notNull().default("auto"), title: text("title"), body: text("body"), + offsetMinutes: integer("offset_minutes"), firedAt: timestamp("fired_at", { withTimezone: true }), createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [ - uniqueIndex("reminders_entity_unique").on(t.entityType, t.entityId), + index("reminders_entity_idx").on(t.entityType, t.entityId), index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt), ], ); @@ -212,6 +217,25 @@ export const notifications = pgTable( (t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)], ); +export const comments = pgTable( + "comments", + { + id: uuid("id").primaryKey().defaultRandom(), + householdId: uuid("household_id") + .notNull() + .references(() => households.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + authorId: uuid("author_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("comments_entity_idx").on(t.entityType, t.entityId, t.createdAt)], +); + export const householdApiTokens = pgTable( "household_api_tokens", { diff --git a/src/modules/bangs/components/bang-stats-widget.tsx b/src/modules/bangs/components/bang-stats-widget.tsx new file mode 100644 index 0000000..547f251 --- /dev/null +++ b/src/modules/bangs/components/bang-stats-widget.tsx @@ -0,0 +1,55 @@ +import type { BangAggregatesDto } from "../server/queries"; + +type Props = { + aggregates: BangAggregatesDto; +}; + +export function BangStatsWidget({ aggregates }: Props) { + const recentMonths = aggregates.monthlyCounts.slice(-6); + + return ( +
+
+ + + +
+ + {recentMonths.length > 0 ? ( +
+

Recent months

+
+ {recentMonths.map((row) => ( +
+ {formatMonth(row.month)} + {row.count} +
+ ))} +
+
+ ) : ( +

No bangs recorded yet.

+ )} +
+ ); +} + +function StatBlock({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function formatMonth(monthKey: string): string { + const [year, month] = monthKey.split("-").map(Number); + return new Date(year!, month! - 1, 1).toLocaleDateString(undefined, { + month: "short", + year: "numeric", + }); +} diff --git a/src/modules/bangs/manifest.tsx b/src/modules/bangs/manifest.tsx index ba5657c..41c0caa 100644 --- a/src/modules/bangs/manifest.tsx +++ b/src/modules/bangs/manifest.tsx @@ -1,18 +1,26 @@ import { z } from "zod"; import type { ModuleManifest, WidgetContext } from "../_core/module"; -import { getBangStats } from "./server/queries"; +import { getBangAggregates, getBangStats } from "./server/queries"; import { BangWidget } from "./components/bang-widget"; +import { BangStatsWidget } from "./components/bang-stats-widget"; const bangWidgetConfigSchema = z.object({ maxRecentBangs: z.number().int().min(1).max(20).default(5), }); +const bangStatsConfigSchema = z.object({}); + async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) { const parsed = bangWidgetConfigSchema.parse(config); const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs); return ; } +async function BangStatsWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) { + const aggregates = await getBangAggregates(ctx.householdId); + return ; +} + const bangsManifest: ModuleManifest = { id: "bangs", name: "Bangs", @@ -44,6 +52,18 @@ const bangsManifest: ModuleManifest = { defaultConfig: { maxRecentBangs: 5 }, render: (props) => , }, + { + id: "bangs.stats", + title: "Bang Stats", + description: "Monthly and yearly bang counts plus average days between bangs.", + category: "Fun", + defaultSize: { w: 3, h: 3 }, + minSize: { w: 2, h: 2 }, + defaultPriority: 55, + configSchema: bangStatsConfigSchema, + defaultConfig: {}, + render: (props) => , + }, ], quickAdds: [ { diff --git a/src/modules/bangs/server/queries.ts b/src/modules/bangs/server/queries.ts index fe1a065..9e40185 100644 --- a/src/modules/bangs/server/queries.ts +++ b/src/modules/bangs/server/queries.ts @@ -1,4 +1,4 @@ -import { count, desc, eq } from "drizzle-orm"; +import { asc, count, desc, eq, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { users } from "@/modules/_core/schema"; import { bangEvents } from "../schema"; @@ -14,6 +14,13 @@ export type RecentBangDto = { recordedByName: string | null; }; +export type BangAggregatesDto = { + thisMonth: number; + thisYear: number; + averageDaysBetween: number | null; + monthlyCounts: { month: string; count: number }[]; +}; + export async function getBangStatsForScope( householdId: string, limit: number, @@ -48,3 +55,61 @@ export async function getBangStatsForScope( export async function getBangStats(householdId: string, limit: number): Promise { return getBangStatsForScope(householdId, limit); } + +export async function getBangAggregates(householdId: string): Promise { + const now = new Date(); + const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const yearPrefix = `${now.getFullYear()}-`; + + const [thisMonthRow] = await db + .select({ count: sql`count(*)::int` }) + .from(bangEvents) + .where( + sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${monthPrefix}%`}`, + ); + + const [thisYearRow] = await db + .select({ count: sql`count(*)::int` }) + .from(bangEvents) + .where( + sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${yearPrefix}%`}`, + ); + + const monthlyRows = await db + .select({ + month: sql`substring(${bangEvents.occurredOn}, 1, 7)`, + count: sql`count(*)::int`, + }) + .from(bangEvents) + .where(eq(bangEvents.householdId, householdId)) + .groupBy(sql`substring(${bangEvents.occurredOn}, 1, 7)`) + .orderBy(asc(sql`substring(${bangEvents.occurredOn}, 1, 7)`)); + + const dateRows = await db + .select({ occurredOn: bangEvents.occurredOn }) + .from(bangEvents) + .where(eq(bangEvents.householdId, householdId)) + .orderBy(asc(bangEvents.occurredOn)); + + let averageDaysBetween: number | null = null; + if (dateRows.length >= 2) { + const dates = dateRows.map((row) => parseBangDate(row.occurredOn).getTime()); + let totalGapDays = 0; + for (let i = 1; i < dates.length; i++) { + totalGapDays += (dates[i]! - dates[i - 1]!) / (1000 * 60 * 60 * 24); + } + averageDaysBetween = Math.round((totalGapDays / (dates.length - 1)) * 10) / 10; + } + + return { + thisMonth: thisMonthRow?.count ?? 0, + thisYear: thisYearRow?.count ?? 0, + averageDaysBetween, + monthlyCounts: monthlyRows.map((row) => ({ month: row.month, count: row.count })), + }; +} + +function parseBangDate(isoDate: string): Date { + const [year, month, day] = isoDate.split("-").map(Number); + return new Date(year!, month! - 1, day!); +} diff --git a/src/modules/calendar/components/calendar-shell.tsx b/src/modules/calendar/components/calendar-shell.tsx index 38ad846..38268a8 100644 --- a/src/modules/calendar/components/calendar-shell.tsx +++ b/src/modules/calendar/components/calendar-shell.tsx @@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ShareButton } from "@/components/share-button"; +import { ReminderPicker } from "@/components/reminder-picker"; import type { CalView } from "@/modules/_core/themes"; import { Select, @@ -25,6 +26,7 @@ import { SelectValue, } from "@/components/ui/select"; import type { CalendarDto, CalendarEventDto } from "../server/queries"; +import { getEventReminderOffsets } from "../server/queries"; import { createCalendar, createEvent, @@ -57,7 +59,7 @@ type EventDraft = { allDay: boolean; location: string; notes: string; - remindMinutesBefore: number | null; + reminderOffsets: number[]; }; type MobileView = "day" | "week" | "agenda"; @@ -99,10 +101,12 @@ export function CalendarShell({ calendars, events, defaultView = "month", + defaultReminderOffsets = [30], }: { calendars: CalendarDto[]; events: CalendarEventDto[]; defaultView?: CalView; + defaultReminderOffsets?: number[]; }) { const [calendarRows, setCalendarRows] = useState(calendars); const [eventRows, setEventRows] = useState(events); @@ -160,13 +164,14 @@ export function CalendarShell({ allDay, location: "", notes: "", - remindMinutesBefore: 30, + reminderOffsets: [...defaultReminderOffsets], }); } function openExistingEvent({ event }: EventClickArg) { const row = eventRows.find((item) => item.id === event.id); if (!row) return; + const eventId = row.id; setSelectedEvent({ id: row.id, calendarId: row.calendarId, @@ -176,7 +181,12 @@ export function CalendarShell({ allDay: row.allDay, location: row.location ?? "", notes: row.notes ?? "", - remindMinutesBefore: null, + reminderOffsets: [], + }); + void getEventReminderOffsets(eventId).then((offsets) => { + setSelectedEvent((current) => + current?.id === eventId ? { ...current, reminderOffsets: offsets } : current, + ); }); } @@ -199,7 +209,11 @@ export function CalendarShell({ startTransition(async () => { if (eventId) { - await updateEvent({ id: eventId, ...payload }); + await updateEvent({ + id: eventId, + ...payload, + reminderOffsets: selectedEvent.reminderOffsets, + }); setLastCalendarId(payload.calendarId); setEventRows((current) => current.map((event) => @@ -220,7 +234,7 @@ export function CalendarShell({ } else { const created = await createEvent({ ...payload, - remindMinutesBefore: selectedEvent.remindMinutesBefore, + reminderOffsets: selectedEvent.reminderOffsets, }); setLastCalendarId(payload.calendarId); setEventRows((current) => [...current, created]); @@ -538,22 +552,11 @@ export function CalendarShell({ placeholder="Add details…" /> - {!selectedEvent.id && ( - - )} + setSelectedEvent({ ...selectedEvent, reminderOffsets })} + disabled={isPending} + />
{selectedEvent.id && ( diff --git a/src/modules/calendar/server/actions.ts b/src/modules/calendar/server/actions.ts index 74b5a62..35ae704 100644 --- a/src/modules/calendar/server/actions.ts +++ b/src/modules/calendar/server/actions.ts @@ -7,7 +7,12 @@ import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { logActivityForScope } from "@/modules/_core/activity"; import { householdMembers } from "@/modules/_core/schema"; -import { scheduleReminder, cancelReminder } from "@/modules/_core/reminders"; +import { + cancelReminder, + syncCalendarEventReminders, + listReminderOffsets, +} from "@/modules/_core/reminders"; +import { normalizeReminderOffsets } from "@/lib/reminder-offsets"; import { calendarEvents, calendars } from "../schema"; import { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries"; import { calendarInput, calendarUpdateInput, eventInput, eventUpdateInput } from "./schemas"; @@ -193,17 +198,16 @@ export async function createEventForScope( if (!event) throw new Error("Event was not created"); - if (parsed.remindMinutesBefore != null && scope.userId) { - const fireAt = new Date(parsed.startAt.getTime() - parsed.remindMinutesBefore * 60_000); - if (fireAt > new Date()) { - await scheduleReminder({ - householdId: scope.householdId, - entityType: "calendar.event", - entityId: event.id, - fireAt, - createdBy: scope.userId, - }); - } + const reminderOffsets = resolveReminderOffsets(parsed); + if (reminderOffsets.length > 0 && scope.userId) { + await syncCalendarEventReminders({ + householdId: scope.householdId, + eventId: event.id, + eventTitle: event.title, + startAt: parsed.startAt, + createdBy: scope.userId, + offsetMinutes: reminderOffsets, + }); } await logActivityForScope(scope, { @@ -256,6 +260,43 @@ export async function updateEventForScope( }) .where(eq(calendarEvents.id, parsed.id)); + if (scope.userId) { + const [updated] = await db + .select({ title: calendarEvents.title, startAt: calendarEvents.startAt }) + .from(calendarEvents) + .where(eq(calendarEvents.id, parsed.id)) + .limit(1); + + if (updated) { + const startAt = parsed.startAt ?? updated.startAt; + let offsets: number[]; + + if (parsed.reminderOffsets !== undefined || parsed.remindMinutesBefore !== undefined) { + offsets = resolveReminderOffsets(parsed); + } else if (parsed.startAt !== undefined) { + offsets = await listReminderOffsets("calendar.event", parsed.id); + } else { + offsets = []; + } + + if ( + offsets.length === 0 && + (parsed.reminderOffsets !== undefined || parsed.remindMinutesBefore !== undefined) + ) { + await cancelReminder("calendar.event", parsed.id); + } else if (offsets.length > 0) { + await syncCalendarEventReminders({ + householdId: scope.householdId, + eventId: parsed.id, + eventTitle: updated.title, + startAt, + createdBy: scope.userId, + offsetMinutes: offsets, + }); + } + } + } + await logActivityForScope(scope, { entityType: "calendar.event", entityId: parsed.id, @@ -304,3 +345,16 @@ async function assertOwnsCalendar(userId: string, calendarId: string) { if (!calendar) throw new Error("Forbidden"); } + +function resolveReminderOffsets(input: { + reminderOffsets?: number[]; + remindMinutesBefore?: number | null; +}): number[] { + if (input.reminderOffsets !== undefined) { + return normalizeReminderOffsets(input.reminderOffsets); + } + if (input.remindMinutesBefore != null) { + return normalizeReminderOffsets([input.remindMinutesBefore]); + } + return []; +} diff --git a/src/modules/calendar/server/queries.ts b/src/modules/calendar/server/queries.ts index 0fd4a1c..c0f3ca0 100644 --- a/src/modules/calendar/server/queries.ts +++ b/src/modules/calendar/server/queries.ts @@ -4,6 +4,8 @@ import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; +import { normalizeReminderOffsets } from "@/lib/reminder-offsets"; +import { listReminderOffsets } from "@/modules/_core/reminders"; import { householdMembers } from "@/modules/_core/schema"; import { calendarEvents, calendars } from "../schema"; @@ -258,6 +260,19 @@ export async function searchEvents(query: string, householdId: string) { })); } +export async function getDefaultEventReminderOffsets(): Promise { + const { user } = await getCurrentSession(); + return normalizeReminderOffsets(user.defaultEventReminderOffsets ?? [30]); +} + +export async function getEventReminderOffsets(eventId: string): Promise { + const parsed = z.string().uuid().parse(eventId); + const { user, household } = await getCurrentSession(); + const event = await getEventForScope({ householdId: household.id, userId: user.id }, parsed); + if (!event) throw new Error("Event not found"); + return listReminderOffsets("calendar.event", parsed); +} + function toEventDto(row: { id: string; calendarId: string; diff --git a/src/modules/calendar/server/schemas.ts b/src/modules/calendar/server/schemas.ts index f5ec018..83206f5 100644 --- a/src/modules/calendar/server/schemas.ts +++ b/src/modules/calendar/server/schemas.ts @@ -20,6 +20,8 @@ export const eventBaseInput = z.object({ allDay: z.boolean().default(false), location: z.string().trim().max(300).nullable().optional(), notes: z.string().trim().max(3000).nullable().optional(), + reminderOffsets: z.array(z.number().int().min(0)).optional(), + /** @deprecated use reminderOffsets */ remindMinutesBefore: z.number().int().min(0).nullable().optional(), }); diff --git a/src/modules/lists/components/list-detail.tsx b/src/modules/lists/components/list-detail.tsx index 7c9eef6..9b16616 100644 --- a/src/modules/lists/components/list-detail.tsx +++ b/src/modules/lists/components/list-detail.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { Archive, Plus, Trash2 } from "lucide-react"; import { useEffect, useRef, useState, useTransition } from "react"; import { DetailBackLink } from "@/components/detail-back-link"; +import { EntityComments } from "@/components/comments/entity-comments"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ShareButton } from "@/components/share-button"; @@ -18,7 +19,13 @@ import { updateItem, } from "../server/actions"; -export function ListDetail({ initialList }: { initialList: ListDetailDto }) { +export function ListDetail({ + initialList, + currentUserId, +}: { + initialList: ListDetailDto; + currentUserId: string; +}) { const router = useRouter(); const [list, setList] = useState(initialList); const [draft, setDraft] = useState(""); @@ -156,6 +163,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) { setItemDone(item, done)} onEdit={(text) => editItemText(item, text)} onCommit={() => commitItemText(item)} @@ -170,6 +178,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) { setItemDone(item, done)} onEdit={(text) => editItemText(item, text)} onCommit={() => commitItemText(item)} @@ -179,18 +188,22 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) { )}
+ +
); } function ListItemRow({ item, + currentUserId, onToggle, onEdit, onCommit, onRemove, }: { item: ListItemDto; + currentUserId: string; onToggle: (done: boolean) => void; onEdit: (text: string) => void; onCommit: () => void; @@ -333,6 +346,14 @@ function ListItemRow({ +
+ +
); } diff --git a/src/modules/lists/components/lists-index.tsx b/src/modules/lists/components/lists-index.tsx index aa1e651..6681697 100644 --- a/src/modules/lists/components/lists-index.tsx +++ b/src/modules/lists/components/lists-index.tsx @@ -1,13 +1,13 @@ "use client"; import Link from "next/link"; -import { ChevronDown, ChevronRight, ExternalLink, Plus } from "lucide-react"; +import { ChevronDown, ChevronRight, ExternalLink, Pencil, Plus } from "lucide-react"; import { useMemo, useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { ListIndexItem, ListWithItemsDto } from "../server/queries"; -import { createList, toggleItem } from "../server/actions"; +import { addItem, createList, toggleItem, updateListProperties } from "../server/actions"; export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) { const [listRows, setListRows] = useState(lists); @@ -61,6 +61,44 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) { }); } + function handleAddItem(listId: string, text: string) { + setListRows((current) => + current.map((list) => + list.id !== listId + ? list + : { + ...list, + openCount: list.openCount + 1, + items: [ + ...list.items, + { + id: `temp-${Date.now()}`, + text, + done: false, + position: list.items.length, + }, + ], + }, + ), + ); + + startTransition(async () => { + await addItem({ listId, text }); + const { listListsWithItems } = await import("../server/queries"); + const refreshed = await listListsWithItems(); + setListRows(refreshed); + }); + } + + function handleUpdateList(listId: string, patch: { name?: string; type?: string }) { + setListRows((current) => + current.map((list) => (list.id === listId ? { ...list, ...patch } : list)), + ); + startTransition(async () => { + await updateListProperties({ id: listId, ...patch }); + }); + } + return (
@@ -101,7 +139,13 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
{groupType}
{groupLists.map((list) => ( - + ))}
@@ -114,11 +158,34 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) { function ListCard({ list, onToggle, + onAddItem, + onUpdateList, }: { list: ListWithItemsDto; onToggle: (listId: string, item: ListIndexItem, done: boolean) => void; + onAddItem: (listId: string, text: string) => void; + onUpdateList: (listId: string, patch: { name?: string; type?: string }) => void; }) { const [expanded, setExpanded] = useState(true); + const [editing, setEditing] = useState(false); + const [draftName, setDraftName] = useState(list.name); + const [draftType, setDraftType] = useState(list.type); + const [itemDraft, setItemDraft] = useState(""); + + function saveListProperties() { + const name = draftName.trim(); + const type = draftType.trim(); + if (!name || !type) return; + onUpdateList(list.id, { name, type }); + setEditing(false); + } + + function submitItem() { + const text = itemDraft.trim(); + if (!text) return; + onAddItem(list.id, text); + setItemDraft(""); + } return (
{expanded ? : } -
-

- {list.name} -

-
- {list.openCount} open · {list.doneCount} done -
+
+ {editing ? ( +
+ setDraftName(e.target.value)} /> + setDraftType(e.target.value)} /> +
+ + +
+
+ ) : ( + <> +

+ {list.name} +

+
+ {list.type} · {list.openCount} open · {list.doneCount} done +
+ + )}
- - - +
+ {!editing && ( + + )} + + + +
{expanded && ( @@ -204,6 +309,23 @@ function ListCard({ )}
)} + +
+ setItemDraft(e.target.value)} + placeholder="Add a task…" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitItem(); + } + }} + /> + +
)} diff --git a/src/modules/lists/server/actions.ts b/src/modules/lists/server/actions.ts index f2e0d30..c6b7e93 100644 --- a/src/modules/lists/server/actions.ts +++ b/src/modules/lists/server/actions.ts @@ -59,16 +59,20 @@ export async function updateListForScope( .update(lists) .set({ name: parsed.name, + type: parsed.type, archived: parsed.archived, }) .where(eq(lists.id, parsed.id)); - if (parsed.name) { + if (parsed.name || parsed.type) { await logActivityForScope(toScope(scope), { entityType: "lists.list", entityId: parsed.id, action: "update", - payload: { name: parsed.name }, + payload: { + ...(parsed.name ? { name: parsed.name } : {}), + ...(parsed.type ? { type: parsed.type } : {}), + }, }); } if (parsed.archived === true) { @@ -93,6 +97,20 @@ export async function renameList(input: { id: string; name: string }) { revalidatePath(`/lists/${parsed.id}`); } +export async function updateListProperties(input: { id: string; name?: string; type?: string }) { + const parsed = z + .object({ + id: z.string().uuid(), + name: listInput.shape.name.optional(), + type: listInput.shape.type.optional(), + }) + .parse(input); + const { household, user } = await getCurrentSession(); + await updateListForScope({ householdId: household.id, userId: user.id, role: null }, parsed); + revalidatePath("/lists"); + revalidatePath(`/lists/${parsed.id}`); +} + export async function archiveList(input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); const { household, user } = await getCurrentSession(); diff --git a/src/modules/lists/server/schemas.ts b/src/modules/lists/server/schemas.ts index 4265379..62a9461 100644 --- a/src/modules/lists/server/schemas.ts +++ b/src/modules/lists/server/schemas.ts @@ -7,6 +7,7 @@ export const listInput = z.object({ export const listUpdateInput = z.object({ name: listInput.shape.name.optional(), + type: listInput.shape.type.optional(), archived: z.boolean().optional(), });