From 28475e483d79474148f7ab1d84df953a6555237d Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 6 May 2026 13:57:03 -0500 Subject: [PATCH] Implement activity log (task 22) - activity_log table in _core/schema.ts with household+created_at index; migration 0008_activity_log.sql applied - logActivity() in _core/activity.ts reads current session and inserts a row - ActivityLogEntry type + renderActivity?(entry): string added to EntityTypeRegistration so modules declare human-readable labels without any if/else branches in the widget - core.activity widget replaced with a real async server component that queries the last 20 rows and renders via the registry - logActivity wired into every create/update/delete in calendar, lists, and notes server actions; getAuthorizedItem also returns text so toggle/delete can include item text in the payload - pnpm typecheck, lint, build, and all 4 E2E specs pass Co-Authored-By: Claude Sonnet 4.6 --- STATUS.md | 1 + drizzle/0008_activity_log.sql | 16 +++++++ src/modules/_core/activity.ts | 20 +++++++++ src/modules/_core/index.ts | 2 + src/modules/_core/manifest.tsx | 58 +++++++++++++++++++++----- src/modules/_core/module.ts | 12 ++++++ src/modules/_core/schema.ts | 19 +++++++++ src/modules/calendar/manifest.tsx | 12 ++++++ src/modules/calendar/server/actions.ts | 8 ++++ src/modules/lists/manifest.tsx | 18 ++++++++ src/modules/lists/server/actions.ts | 9 ++++ src/modules/notes/manifest.tsx | 8 ++++ src/modules/notes/server/actions.ts | 10 ++++- 13 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 drizzle/0008_activity_log.sql create mode 100644 src/modules/_core/activity.ts diff --git a/STATUS.md b/STATUS.md index f9c4163..d7ced4d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -21,6 +21,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass. - **20 — Dashboard composition (single-dashboard MVP)**. Added `default_dashboard_layout` jsonb column to `users` + migration `0007_uneven_living_lightning.sql`. Created `src/modules/_core/manifest.tsx` (`core.activity` placeholder widget) and registered it. Updated all three module manifests (calendar, lists, notes) with real async server component widget renders (data-fetching, empty states). Created `src/lib/dashboard.ts` (layout parsing + `computeDefaultLayout` greedy packer). Built `src/app/page.tsx` — 12-col CSS Grid, static `smColSpan` lookup for Tailwind class safety, per-widget `` for parallel loading, graceful skip for unknown widget IDs. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. - **21 — Quick-add registry**. Added `url: string` to `QuickAddAction` type (action is now optional). Added `getQuickAdds()` / `SerializedQuickAddItem` to registry (strips non-serializable `action` fn before crossing server→client boundary). Updated all three module manifests with navigation URLs. Built `QuickAddProvider` (context + cmd+k global shortcut), `QuickAddFab` (opens sheet, replaces plain button in dashboard), `QuickAddSheet` (bottom drawer / desktop popover grouped by module), and `CommandPalette` (cmdk-powered modal with arrow + enter + esc keyboard nav). Provider in root layout receives actions from `getQuickAdds()` at render time — adding a module's `quickAdds` automatically appears in both surfaces. Also added `.claude/**` to ESLint ignores to prevent stale worktree build artifacts from failing lint. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. +- **22 — Activity log**. Added `activity_log` table to `_core/schema.ts` with index on `(household_id, created_at desc)`. Migration `0008_activity_log.sql` applied. `logActivity()` server function in `_core/activity.ts` reads current session and inserts a row. Added `ActivityLogEntry` type and optional `renderActivity?(entry): string` to `EntityTypeRegistration` in `_core/module.ts`. All three module manifests implement `renderActivity` for each entity type (human-readable, no hardcoded branches in the widget). Replaced `core.activity` widget stub with a real async server component that queries the last 20 rows via `getEntityType(entry.entityType)?.renderActivity(entry)`. Wired `logActivity()` into every create/update/delete in calendar, lists, and notes server actions. Also added `text` to `getAuthorizedItem` select so toggle/delete log the item text. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. ## Next up diff --git a/drizzle/0008_activity_log.sql b/drizzle/0008_activity_log.sql new file mode 100644 index 0000000..b4d8e2a --- /dev/null +++ b/drizzle/0008_activity_log.sql @@ -0,0 +1,16 @@ +CREATE TABLE "activity_log" ( + "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, + "actor_id" uuid NOT NULL, + "action" text NOT NULL, + "payload" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "activity_log_household_created_idx" ON "activity_log" USING btree ("household_id","created_at"); diff --git a/src/modules/_core/activity.ts b/src/modules/_core/activity.ts new file mode 100644 index 0000000..61b7ad7 --- /dev/null +++ b/src/modules/_core/activity.ts @@ -0,0 +1,20 @@ +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { activityLog } from "./schema"; + +export async function logActivity(input: { + entityType: string; + entityId: string; + action: string; + payload?: Record; +}): Promise { + const { user, household } = await getCurrentSession(); + await db.insert(activityLog).values({ + householdId: household.id, + entityType: input.entityType, + entityId: input.entityId, + actorId: user.id, + action: input.action, + payload: input.payload ?? null, + }); +} diff --git a/src/modules/_core/index.ts b/src/modules/_core/index.ts index 1452c7d..e0fda88 100644 --- a/src/modules/_core/index.ts +++ b/src/modules/_core/index.ts @@ -8,6 +8,8 @@ export type { ReminderCapabilities, SearchAdapter, SearchResult, + ActivityLogEntry, } from "./module"; export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry"; export type { QuickAddItem, SerializedQuickAddItem } from "./registry"; +export { logActivity } from "./activity"; diff --git a/src/modules/_core/manifest.tsx b/src/modules/_core/manifest.tsx index 3489044..9292cf1 100644 --- a/src/modules/_core/manifest.tsx +++ b/src/modules/_core/manifest.tsx @@ -1,5 +1,49 @@ -import type { ModuleManifest } from "./module"; +import { desc, eq } from "drizzle-orm"; import { z } from "zod"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import type { ActivityLogEntry, ModuleManifest } from "./module"; +import { getEntityType } from "./registry"; +import { activityLog } from "./schema"; + +const activityConfigSchema = z.object({ + limit: z.number().int().min(1).max(50).optional(), +}); + +async function ActivityWidget({ config }: { config: unknown }) { + const parsed = activityConfigSchema.parse(config); + const { household } = await getCurrentSession(); + + const entries = await db + .select() + .from(activityLog) + .where(eq(activityLog.householdId, household.id)) + .orderBy(desc(activityLog.createdAt)) + .limit(parsed.limit ?? 20); + + if (entries.length === 0) { + return

No recent activity

; + } + + return ( +
    + {entries.map((entry) => { + const reg = getEntityType(entry.entityType); + const description = + reg?.renderActivity?.(entry as ActivityLogEntry) ?? + `${entry.action} ${entry.entityType}`; + return ( +
  • + + {entry.createdAt.toLocaleDateString(undefined, { month: "short", day: "numeric" })} + + {description} +
  • + ); + })} +
+ ); +} const coreManifest: ModuleManifest = { id: "_core", @@ -14,16 +58,10 @@ const coreManifest: ModuleManifest = { defaultSize: { w: 4, h: 3 }, minSize: { w: 3, h: 2 }, defaultPriority: 50, - configSchema: z.object({ - limit: z.number().int().min(1).max(50).optional(), - }), - defaultConfig: { limit: 10 }, + configSchema: activityConfigSchema, + defaultConfig: { limit: 20 }, resolveConfigOptions: async () => undefined, - render: () => ( -
- Activity log coming in task 22 -
- ), + render: (props) => , }, ], }; diff --git a/src/modules/_core/module.ts b/src/modules/_core/module.ts index 0561f82..639e1c6 100644 --- a/src/modules/_core/module.ts +++ b/src/modules/_core/module.ts @@ -1,6 +1,17 @@ import type { ReactNode } from "react"; import type { ZodType } from "zod"; +export type ActivityLogEntry = { + id: string; + householdId: string; + entityType: string; + entityId: string; + actorId: string; + action: string; + payload: Record | null; + createdAt: Date; +}; + export type ShareCapabilities = { canShare: boolean; defaultCapabilities?: string[]; @@ -58,6 +69,7 @@ export type EntityTypeRegistration = { search?: SearchAdapter; resolveUrl: (id: string) => string; loadForShare?: (id: string) => Promise; + renderActivity?: (entry: ActivityLogEntry) => string; }; export type ModuleManifest = { diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index 161dcc0..3b18e67 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -87,6 +87,25 @@ export const householdMembers = pgTable( (t) => [primaryKey({ columns: [t.householdId, t.userId] })], ); +export const activityLog = pgTable( + "activity_log", + { + 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(), + actorId: uuid("actor_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + action: text("action").notNull(), + payload: jsonb("payload").$type | null>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("activity_log_household_created_idx").on(t.householdId, t.createdAt)], +); + export const reminders = pgTable( "reminders", { diff --git a/src/modules/calendar/manifest.tsx b/src/modules/calendar/manifest.tsx index 3c50b90..82c0fa3 100644 --- a/src/modules/calendar/manifest.tsx +++ b/src/modules/calendar/manifest.tsx @@ -101,6 +101,12 @@ const manifest: ModuleManifest = { share: { canShare: true, defaultCapabilities: ["read"] }, search: { search: searchCalendars }, resolveUrl: (id) => `/calendar?id=${id}`, + renderActivity: (entry) => { + const name = entry.payload?.name as string | undefined; + if (entry.action === "create") return `Created calendar${name ? ` "${name}"` : ""}`; + if (entry.action === "delete") return "Deleted calendar"; + return `Updated calendar${name ? ` "${name}"` : ""}`; + }, }, { type: "calendar.event", @@ -109,6 +115,12 @@ const manifest: ModuleManifest = { reminder: { canRemind: true }, search: { search: searchEvents }, resolveUrl: (id) => `/calendar/events/${id}`, + renderActivity: (entry) => { + const title = entry.payload?.title as string | undefined; + if (entry.action === "create") return `Created event${title ? ` "${title}"` : ""}`; + if (entry.action === "delete") return "Deleted event"; + return `Updated event${title ? ` "${title}"` : ""}`; + }, }, ], dashboardWidgets: [ diff --git a/src/modules/calendar/server/actions.ts b/src/modules/calendar/server/actions.ts index 6f9b021..f6218b4 100644 --- a/src/modules/calendar/server/actions.ts +++ b/src/modules/calendar/server/actions.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; +import { logActivity } from "@/modules/_core/activity"; import { calendarEvents, calendars } from "../schema"; import { canSeeCalendar } from "./queries"; @@ -55,6 +56,7 @@ export async function createCalendar(input: z.input) { .returning(); if (!calendar) throw new Error("Calendar was not created"); + await logActivity({ entityType: "calendar.calendar", entityId: calendar.id, action: "create", payload: { name: calendar.name } }); revalidatePath("/calendar"); return calendar; } @@ -68,6 +70,7 @@ export async function renameCalendar(input: { id: string; name: string }) { .set({ name: parsed.name, updatedAt: new Date() }) .where(eq(calendars.id, parsed.id)); + await logActivity({ entityType: "calendar.calendar", entityId: parsed.id, action: "update", payload: { name: parsed.name } }); revalidatePath("/calendar"); } @@ -85,6 +88,7 @@ export async function setCalendarVisibility(input: { .set({ visibility: parsed.visibility, updatedAt: new Date() }) .where(eq(calendars.id, parsed.id)); + await logActivity({ entityType: "calendar.calendar", entityId: parsed.id, action: "update", payload: { visibility: parsed.visibility } }); revalidatePath("/calendar"); } @@ -104,6 +108,7 @@ export async function deleteCalendar(input: { id: string }) { const { user } = await getCurrentSession(); const parsed = z.object({ id: z.string().uuid() }).parse(input); await assertOwnsCalendar(user.id, parsed.id); + await logActivity({ entityType: "calendar.calendar", entityId: parsed.id, action: "delete" }); await db.delete(calendars).where(eq(calendars.id, parsed.id)); revalidatePath("/calendar"); } @@ -124,6 +129,7 @@ export async function createEvent(input: z.input) { .returning(); if (!event) throw new Error("Event was not created"); + await logActivity({ entityType: "calendar.event", entityId: event.id, action: "create", payload: { title: event.title } }); revalidatePath("/calendar"); return { ...event, @@ -159,6 +165,7 @@ export async function updateEvent(input: { id: string } & Partial `/lists/${id}`, + renderActivity: (entry) => { + const name = entry.payload?.name as string | undefined; + if (entry.action === "create") return `Created list${name ? ` "${name}"` : ""}`; + if (entry.action === "archive") return `Archived list${name ? ` "${name}"` : ""}`; + if (entry.action === "delete") return "Deleted list"; + return `Updated list${name ? ` "${name}"` : ""}`; + }, }, { type: "lists.item", @@ -61,6 +68,17 @@ const manifest: ModuleManifest = { share: { canShare: false }, search: { search: searchItems }, resolveUrl: (id) => `/lists/items/${id}`, + renderActivity: (entry) => { + const text = entry.payload?.text as string | undefined; + if (entry.action === "create") return `Added${text ? ` "${text}"` : " item"}`; + if (entry.action === "delete") return `Removed${text ? ` "${text}"` : " item"}`; + if (entry.action === "toggle") { + const done = entry.payload?.done as boolean | undefined; + const label = text ? ` "${text}"` : " item"; + return done ? `Checked off${label}` : `Unchecked${label}`; + } + return `Updated${text ? ` "${text}"` : " item"}`; + }, }, ], dashboardWidgets: [ diff --git a/src/modules/lists/server/actions.ts b/src/modules/lists/server/actions.ts index 53beffa..5ff72a1 100644 --- a/src/modules/lists/server/actions.ts +++ b/src/modules/lists/server/actions.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; +import { logActivity } from "@/modules/_core/activity"; import { listItems, lists } from "../schema"; import { getOrCreateDefaultList } from "./defaults"; import { canAccessList, getList } from "./queries"; @@ -46,6 +47,7 @@ export async function createList(input: z.input) { .returning(); if (!list) throw new Error("List was not created"); + await logActivity({ entityType: "lists.list", entityId: list.id, action: "create", payload: { name: list.name } }); revalidatePath("/lists"); return list; } @@ -55,6 +57,7 @@ export async function renameList(input: { id: string; name: string }) { const { household } = await getCurrentSession(); await assertCanAccessList(parsed.id, household.id); await db.update(lists).set({ name: parsed.name }).where(eq(lists.id, parsed.id)); + await logActivity({ entityType: "lists.list", entityId: parsed.id, action: "update", payload: { name: parsed.name } }); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); await notifyListChanged(parsed.id); @@ -65,6 +68,7 @@ export async function archiveList(input: { id: string }) { const { household } = await getCurrentSession(); await assertCanAccessList(parsed.id, household.id); await db.update(lists).set({ archived: true }).where(eq(lists.id, parsed.id)); + await logActivity({ entityType: "lists.list", entityId: parsed.id, action: "archive" }); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); await notifyListChanged(parsed.id); @@ -94,6 +98,7 @@ export async function addItem(input: z.input) { .returning(); if (!item) throw new Error("List item was not created"); + await logActivity({ entityType: "lists.item", entityId: item.id, action: "create", payload: { text: item.text } }); revalidatePath(`/lists/${parsed.listId}`); await notifyListChanged(parsed.listId); return getList(parsed.listId); @@ -122,6 +127,7 @@ export async function toggleItem(input: { id: string; done?: boolean }) { .set({ done, updatedAt: new Date() }) .where(eq(listItems.id, parsed.id)); + await logActivity({ entityType: "lists.item", entityId: parsed.id, action: "toggle", payload: { done, text: existing.text } }); revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); return getList(existing.listId); @@ -144,6 +150,7 @@ export async function updateItem(input: z.input) { }) .where(eq(listItems.id, parsed.id)); + await logActivity({ entityType: "lists.item", entityId: parsed.id, action: "update", payload: { text: parsed.text ?? existing.text } }); revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); return getList(existing.listId); @@ -153,6 +160,7 @@ export async function deleteItem(input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); const { household } = await getCurrentSession(); const existing = await getAuthorizedItem(parsed.id, household.id); + await logActivity({ entityType: "lists.item", entityId: parsed.id, action: "delete", payload: { text: existing.text } }); await db.delete(listItems).where(eq(listItems.id, parsed.id)); revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); @@ -190,6 +198,7 @@ async function getAuthorizedItem(itemId: string, householdId: string) { id: listItems.id, listId: listItems.listId, done: listItems.done, + text: listItems.text, }) .from(listItems) .innerJoin(lists, eq(listItems.listId, lists.id)) diff --git a/src/modules/notes/manifest.tsx b/src/modules/notes/manifest.tsx index 544bdcc..9b55df7 100644 --- a/src/modules/notes/manifest.tsx +++ b/src/modules/notes/manifest.tsx @@ -45,6 +45,14 @@ const manifest: ModuleManifest = { reminder: { canRemind: true }, search: { search: searchNotes }, resolveUrl: (id) => `/notes/${id}`, + renderActivity: (entry) => { + const title = entry.payload?.title as string | undefined; + if (entry.action === "create") return `Created note${title ? ` "${title}"` : ""}`; + if (entry.action === "delete") return `Deleted note${title ? ` "${title}"` : ""}`; + if (entry.action === "pin") return `Pinned note${title ? ` "${title}"` : ""}`; + if (entry.action === "unpin") return `Unpinned note${title ? ` "${title}"` : ""}`; + return `Updated note${title ? ` "${title}"` : ""}`; + }, }, ], dashboardWidgets: [ diff --git a/src/modules/notes/server/actions.ts b/src/modules/notes/server/actions.ts index ecea5ed..584db9a 100644 --- a/src/modules/notes/server/actions.ts +++ b/src/modules/notes/server/actions.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; +import { logActivity } from "@/modules/_core/activity"; import { reminders } from "@/modules/_core/schema"; import { notes } from "../schema"; import { canAccessNote, getNote } from "./queries"; @@ -50,6 +51,7 @@ export async function createNote(input: z.input) { return [created]; }); + if (note) await logActivity({ entityType: "notes.note", entityId: note.id, action: "create", payload: { title: note.title } }); revalidatePath("/notes"); return note; } @@ -83,6 +85,7 @@ export async function updateNote(input: z.input) { return [updated]; }); + if (note) await logActivity({ entityType: "notes.note", entityId: note.id, action: "update", payload: { title: note.title } }); revalidatePath("/notes"); revalidatePath(`/notes/${parsed.id}`); return note; @@ -98,9 +101,11 @@ export async function setNotePinned(input: { id: string; pinned: boolean }) { .set({ pinned: parsed.pinned, updatedAt: new Date() }) .where(eq(notes.id, parsed.id)); + const note = await getNote(parsed.id); + await logActivity({ entityType: "notes.note", entityId: parsed.id, action: parsed.pinned ? "pin" : "unpin", payload: note ? { title: note.title } : undefined }); revalidatePath("/notes"); revalidatePath(`/notes/${parsed.id}`); - return getNote(parsed.id); + return note; } export async function deleteNote(input: { id: string }) { @@ -108,6 +113,9 @@ export async function deleteNote(input: { id: string }) { const { household } = await getCurrentSession(); await assertCanAccessNote(parsed.id, household.id); + const note = await getNote(parsed.id); + await logActivity({ entityType: "notes.note", entityId: parsed.id, action: "delete", payload: note ? { title: note.title } : undefined }); + await db.transaction(async (tx) => { await tx .delete(reminders)