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 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 13:57:03 -05:00
co-authored by Claude Sonnet 4.6
parent 1133fa8aac
commit 28475e483d
13 changed files with 182 additions and 11 deletions
+1
View File
@@ -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 `<Suspense>` 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
+16
View File
@@ -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");
+20
View File
@@ -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<string, unknown>;
}): Promise<void> {
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,
});
}
+2
View File
@@ -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";
+48 -10
View File
@@ -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 <p className="text-sm text-muted-foreground">No recent activity</p>;
}
return (
<ul className="space-y-2">
{entries.map((entry) => {
const reg = getEntityType(entry.entityType);
const description =
reg?.renderActivity?.(entry as ActivityLogEntry) ??
`${entry.action} ${entry.entityType}`;
return (
<li key={entry.id} className="flex items-start gap-2 text-sm">
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">
{entry.createdAt.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</span>
<span className="leading-snug">{description}</span>
</li>
);
})}
</ul>
);
}
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: () => (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Activity log coming in task 22
</div>
),
render: (props) => <ActivityWidget {...props} />,
},
],
};
+12
View File
@@ -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<string, unknown> | 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<unknown>;
renderActivity?: (entry: ActivityLogEntry) => string;
};
export type ModuleManifest = {
+19
View File
@@ -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<Record<string, unknown> | 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",
{
+12
View File
@@ -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: [
+8
View File
@@ -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<typeof calendarInput>) {
.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<typeof eventInput>) {
.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<z.input<typeof
})
.where(eq(calendarEvents.id, parsed.id));
await logActivity({ entityType: "calendar.event", entityId: parsed.id, action: "update", payload: parsed.title ? { title: parsed.title } : undefined });
revalidatePath("/calendar");
}
@@ -173,6 +180,7 @@ export async function deleteEvent(input: { id: string }) {
if (!existing) return;
if (!(await canSeeCalendar(user.id, existing.calendarId))) throw new Error("Forbidden");
await logActivity({ entityType: "calendar.event", entityId: parsed.id, action: "delete" });
await db.delete(calendarEvents).where(eq(calendarEvents.id, parsed.id));
revalidatePath("/calendar");
}
+18
View File
@@ -54,6 +54,13 @@ const manifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read", "write"] },
search: { search: searchLists },
resolveUrl: (id) => `/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: [
+9
View File
@@ -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<typeof listInput>) {
.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<typeof itemInput>) {
.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<typeof updateItemInput>) {
})
.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))
+8
View File
@@ -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: [
+9 -1
View File
@@ -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<typeof noteInput>) {
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<typeof updateNoteInput>) {
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)