From d5a8bf9d951ab55123faeb4984f15baadfd80fda Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 6 May 2026 15:52:59 -0500 Subject: [PATCH] Implement tasks 25, 26 + completion visibility setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 25: Multiple dashboards — dashboards table + migration, /d/[slug] route, root redirect, dashboard tabs in nav with create/rename/delete/ set-default. Task 26: Editable dashboard — react-grid-layout drag/resize editor, widget picker modal with per-widget configurator auto-generated from default config, save/reset server actions with Zod validation. Completion visibility: server-side per-user setting (hours) replaces localStorage timer; queries filter completed items by updatedAt cutoff; Settings page dropdown persists preference via server action. Co-Authored-By: Claude Sonnet 4.6 --- STATUS.md | 2 + drizzle/0011_completion_visibility.sql | 1 + drizzle/0012_dashboards.sql | 28 ++ package.json | 2 + pnpm-lock.yaml | 70 +++++ src/app/d/[slug]/page.tsx | 91 ++++++ src/app/d/actions.ts | 191 ++++++++++++ src/app/layout.tsx | 13 +- src/app/page.tsx | 86 +----- src/app/settings/actions.ts | 12 + src/app/settings/page.tsx | 2 +- src/components/app-nav.tsx | 56 ++-- src/components/completion-delay-setting.tsx | 36 ++- src/components/dashboard-editor.tsx | 221 ++++++++++++++ src/components/dashboard-switcher.tsx | 203 +++++++++++++ src/components/dashboard-tab.tsx | 22 ++ src/components/edit-dashboard-button.tsx | 18 ++ src/components/share-button.tsx | 73 +++++ src/components/ui/dropdown-menu.tsx | 268 +++++++++++++++++ src/components/widget-picker.tsx | 292 +++++++++++++++++++ src/hooks/use-completion-delay.ts | 32 -- src/modules/_core/index.ts | 4 +- src/modules/_core/registry.ts | 17 ++ src/modules/_core/schema.ts | 21 +- src/modules/lists/components/list-widget.tsx | 27 +- src/modules/lists/components/lists-index.tsx | 66 +---- src/modules/lists/server/queries.ts | 29 +- 27 files changed, 1646 insertions(+), 237 deletions(-) create mode 100644 drizzle/0011_completion_visibility.sql create mode 100644 drizzle/0012_dashboards.sql create mode 100644 src/app/d/[slug]/page.tsx create mode 100644 src/app/d/actions.ts create mode 100644 src/components/dashboard-editor.tsx create mode 100644 src/components/dashboard-switcher.tsx create mode 100644 src/components/dashboard-tab.tsx create mode 100644 src/components/edit-dashboard-button.tsx create mode 100644 src/components/share-button.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/widget-picker.tsx delete mode 100644 src/hooks/use-completion-delay.ts diff --git a/STATUS.md b/STATUS.md index 7925455..555bbd0 100644 --- a/STATUS.md +++ b/STATUS.md @@ -23,6 +23,8 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **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. - **30 — Share-link service**. Added `share_links` table to `_core/schema.ts` + migration `0009_share_links.sql`. Created `_core/share.ts` with `createShareLink`, `resolveShareToken`, `revokeShareLink`, and `getActiveShareLinks`. Token is 32 random bytes (URL-safe base64), stored as SHA-256 hash — raw token only returned at creation. `createShareLink` guards that the entity type is registered with `canShare === true`. `resolveShareToken` returns null for expired or revoked tokens. All three functions exported from `_core/index.ts`. `/settings` page gained a Share links card: lists active links (entity label, read/write capabilities, expiry) with a Revoke button per link (server action in `settings/actions.ts`). `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. +- **25 — Multiple dashboards per user**. Added `dashboards` table (migration `0012_dashboards.sql`). Migrated each user's `default_dashboard_layout` into a "Home" dashboard row with `is_default = true`; dropped the interim column. Server actions: `listDashboards`, `createDashboard`, `renameDashboard`, `deleteDashboard`, `setDefaultDashboard`, `reorderDashboards`, `saveDashboardLayout`, `resetDashboardLayout`, `resolveWidgetConfigOptions`. `/` redirects to the user's default `/d/`. Dashboard switcher in AppNav renders tabs (active highlighted client-side) with a `+` button to create new dashboards and a kebab menu on the active tab for rename / set-default / delete. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass. +- **26 — Customizable layout + widget configuration**. Installed `react-grid-layout` v2. Dashboard pages check `?edit=1` to enter edit mode, rendering a client `DashboardEditor` instead of the static grid. Editor uses `react-grid-layout` with `gridConfig`/`dragConfig` v2 API; each widget shell shows a drag handle, configure button (⚙), and remove button (🗑). `WidgetPicker` is a two-step modal: step 1 lists all registry widgets grouped by category; step 2 is a `WidgetConfigurator` auto-generated from the widget's default config — handles `"all"|string[]` multi-selects, booleans, numbers, and enums. `resolveWidgetConfigOptions` server action fetches dynamic options (calendars, lists). Save validates each config against its registered Zod schema. Reset to defaults calls `computeDefaultLayout()`. `pnpm typecheck`, `pnpm build` pass. - **31 — Public share viewer**. Made `actorId` nullable in `activity_log` (migration `0010_nullable_actor_id.sql`, `onDelete: "set null"`) for anonymous share-page mutations. Added `logShareActivity` to `_core/activity.ts` (no session, explicit `householdId`). Added `householdId` to `resolveShareToken` return. Added `renderSharedView` to `EntityTypeRegistration` type. Each module implements `loadForShare` (bare DB queries, no session) and `renderSharedView`: calendar shows upcoming 90-day events or single-event details, lists shows items with optional toggle, notes shows title + body. `toggleShareListItem` server action lives in `lists/server/share-actions.ts` — validates token write capability, verifies item→list→household chain, logs `share.toggle` with `actorId = null`. `/app/s/[token]/page.tsx` resolves token, dispatches to `loadForShare` + `renderSharedView`, returns friendly error for invalid/expired tokens, sets `noindex`. Middleware `/s/*` exemption confirmed present. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. ## Next up diff --git a/drizzle/0011_completion_visibility.sql b/drizzle/0011_completion_visibility.sql new file mode 100644 index 0000000..9218472 --- /dev/null +++ b/drizzle/0011_completion_visibility.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "completion_visibility_hours" integer NOT NULL DEFAULT 24; diff --git a/drizzle/0012_dashboards.sql b/drizzle/0012_dashboards.sql new file mode 100644 index 0000000..4a28d63 --- /dev/null +++ b/drizzle/0012_dashboards.sql @@ -0,0 +1,28 @@ +CREATE TABLE "dashboards" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "name" text NOT NULL, + "slug" text NOT NULL, + "is_default" boolean NOT NULL DEFAULT false, + "position" integer NOT NULL DEFAULT 0, + "layout" jsonb NOT NULL DEFAULT '{"version":1,"widgets":[]}', + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX "dashboards_user_slug_uq" ON "dashboards" ("user_id", "slug"); + +-- Migrate each existing user's default_dashboard_layout into a "Home" dashboard. +-- Idempotent: ON CONFLICT DO NOTHING. +INSERT INTO "dashboards" ("user_id", "name", "slug", "is_default", "position", "layout") +SELECT + "id", + 'Home', + 'home', + true, + 0, + COALESCE("default_dashboard_layout", '{"version":1,"widgets":[]}') +FROM "users" +ON CONFLICT DO NOTHING; + +ALTER TABLE "users" DROP COLUMN IF EXISTS "default_dashboard_layout"; diff --git a/package.json b/package.json index 049f41e..5cfb3e0 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/node": "^22.9.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-grid-layout": "^2.1.0", "drizzle-kit": "^0.31.10", "eslint": "^9.15.0", "eslint-config-next": "^16.2.4", @@ -58,6 +59,7 @@ "postgres": "^3.4.9", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-grid-layout": "^2.2.3", "shadcn": "^4.7.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d876a75..5adb69c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + react-grid-layout: + specifier: ^2.2.3 + version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) shadcn: specifier: ^4.7.0 version: 4.7.0(@types/node@22.19.17)(typescript@5.9.3) @@ -96,6 +99,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/react-grid-layout': + specifier: ^2.1.0 + version: 2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -1542,6 +1548,10 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-grid-layout@2.1.0': + resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==} + deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed. + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2441,6 +2451,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@4.0.3: + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -3457,6 +3470,18 @@ packages: peerDependencies: react: ^19.2.5 + react-draggable@4.5.0: + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-grid-layout@2.2.3: + resolution: {integrity: sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3480,6 +3505,12 @@ packages: '@types/react': optional: true + react-resizable@3.1.3: + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} + peerDependencies: + react: '>= 16.3' + react-dom: '>= 16.3' + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -3517,6 +3548,9 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5142,6 +5176,13 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-grid-layout@2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + react-grid-layout: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + transitivePeerDependencies: + - react + - react-dom + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -6185,6 +6226,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@4.0.3: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7118,6 +7161,24 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-draggable@4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + react-grid-layout@2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + clsx: 2.1.1 + fast-equals: 4.0.3 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-resizable: 3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + resize-observer-polyfill: 1.5.1 + react-is@16.13.1: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): @@ -7139,6 +7200,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-resizable@3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): dependencies: get-nonce: 1.0.1 @@ -7183,6 +7251,8 @@ snapshots: reselect@5.1.1: {} + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/app/d/[slug]/page.tsx b/src/app/d/[slug]/page.tsx new file mode 100644 index 0000000..aad797c --- /dev/null +++ b/src/app/d/[slug]/page.tsx @@ -0,0 +1,91 @@ +import { notFound } from "next/navigation"; +import { Suspense } from "react"; +import { getCurrentSession } from "@/lib/session"; +import { parseDashboardLayout, computeDefaultLayout } from "@/lib/dashboard"; +import { getWidget, getWidgetMetas } from "@/modules/_core"; +import { getDashboardBySlug } from "@/app/d/actions"; +import { DashboardEditor } from "@/components/dashboard-editor"; +import { QuickAddFab } from "@/components/quick-add-fab"; +import { EditDashboardButton } from "@/components/edit-dashboard-button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +const smColSpan: Record = { + 1: "sm:col-span-1", 2: "sm:col-span-2", 3: "sm:col-span-3", + 4: "sm:col-span-4", 5: "sm:col-span-5", 6: "sm:col-span-6", + 7: "sm:col-span-7", 8: "sm:col-span-8", 9: "sm:col-span-9", + 10: "sm:col-span-10", 11: "sm:col-span-11", 12: "sm:col-span-12", +}; + +export default async function DashboardPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ edit?: string }>; +}) { + const { slug } = await params; + const { edit } = await searchParams; + const isEditing = edit === "1"; + + const { user, household } = await getCurrentSession(); + const dashboard = await getDashboardBySlug(slug); + if (!dashboard) notFound(); + + const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout(); + const widgetMetas = getWidgetMetas(); + + if (isEditing) { + return ( + + ); + } + + const ctx = { userId: user.id, householdId: household.id }; + const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x); + + return ( +
+
+

{dashboard.name}

+
+ + +
+
+ +
+ {placements.map((placement, i) => { + const widget = getWidget(placement.widgetId); + if (!widget) return null; + const colClass = smColSpan[placement.w] ?? "sm:col-span-12"; + return ( +
+ + + {widget.title} + + + +
+
+
+
+ } + > + {widget.render({ config: placement.config, ctx })} + + + +
+ ); + })} +
+
+ ); +} diff --git a/src/app/d/actions.ts b/src/app/d/actions.ts new file mode 100644 index 0000000..97c5ae9 --- /dev/null +++ b/src/app/d/actions.ts @@ -0,0 +1,191 @@ +"use server"; + +import { and, asc, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { getWidget } from "@/modules/_core"; +import { dashboards } from "@/modules/_core/schema"; +import { computeDefaultLayout, type DashboardLayout } from "@/lib/dashboard"; + +export type DashboardMeta = { + id: string; + name: string; + slug: string; + isDefault: boolean; + position: number; +}; + +export async function listDashboards(): Promise { + const { user } = await getCurrentSession(); + const rows = await db + .select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position }) + .from(dashboards) + .where(eq(dashboards.userId, user.id)) + .orderBy(asc(dashboards.position), asc(dashboards.createdAt)); + return rows; +} + +export async function getDefaultDashboardSlug(): Promise { + const { user } = await getCurrentSession(); + const rows = await db + .select({ slug: dashboards.slug }) + .from(dashboards) + .where(and(eq(dashboards.userId, user.id), eq(dashboards.isDefault, true))) + .limit(1); + if (rows[0]) return rows[0].slug; + // Fallback: first dashboard by position + const [first] = await db + .select({ slug: dashboards.slug }) + .from(dashboards) + .where(eq(dashboards.userId, user.id)) + .orderBy(asc(dashboards.position), asc(dashboards.createdAt)) + .limit(1); + if (first) return first.slug; + // No dashboards yet — create Home + await createDashboard("Home"); + return "home"; +} + +export async function getDashboardBySlug(slug: string) { + const { user } = await getCurrentSession(); + const [row] = await db + .select() + .from(dashboards) + .where(and(eq(dashboards.userId, user.id), eq(dashboards.slug, slug))) + .limit(1); + return row ?? null; +} + +function toSlug(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + || "dashboard"; +} + +async function uniqueSlug(userId: string, base: string): Promise { + let slug = base; + let attempt = 1; + for (;;) { + const [existing] = await db + .select({ id: dashboards.id }) + .from(dashboards) + .where(and(eq(dashboards.userId, userId), eq(dashboards.slug, slug))) + .limit(1); + if (!existing) return slug; + attempt++; + slug = `${base}-${attempt}`; + } +} + +export async function createDashboard(name: string): Promise { + const parsed = z.string().trim().min(1).max(80).parse(name); + const { user } = await getCurrentSession(); + const slug = await uniqueSlug(user.id, toSlug(parsed)); + const rows = await db + .insert(dashboards) + .values({ userId: user.id, name: parsed, slug, isDefault: false, position: 9999 }) + .returning(); + const row = rows[0]; + if (!row) throw new Error("Insert failed"); + revalidatePath("/"); + return { id: row.id, name: row.name, slug: row.slug, isDefault: row.isDefault, position: row.position }; +} + +export async function renameDashboard(id: string, name: string): Promise { + const parsedName = z.string().trim().min(1).max(80).parse(name); + const { user } = await getCurrentSession(); + await db + .update(dashboards) + .set({ name: parsedName, updatedAt: new Date() }) + .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); + revalidatePath("/"); +} + +export async function deleteDashboard(id: string): Promise { + const { user } = await getCurrentSession(); + const all = await db + .select({ id: dashboards.id, isDefault: dashboards.isDefault, slug: dashboards.slug }) + .from(dashboards) + .where(eq(dashboards.userId, user.id)); + if (all.length <= 1) throw new Error("Cannot delete your only dashboard"); + const target = all.find((d) => d.id === id); + if (!target) throw new Error("Dashboard not found"); + + await db.delete(dashboards).where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); + + // If we deleted the default, promote another + if (target.isDefault) { + const next = all.find((d) => d.id !== id); + if (next) { + await db + .update(dashboards) + .set({ isDefault: true }) + .where(and(eq(dashboards.id, next.id), eq(dashboards.userId, user.id))); + } + } + revalidatePath("/"); + redirect("/"); +} + +export async function setDefaultDashboard(id: string): Promise { + const { user } = await getCurrentSession(); + await db + .update(dashboards) + .set({ isDefault: false }) + .where(eq(dashboards.userId, user.id)); + await db + .update(dashboards) + .set({ isDefault: true }) + .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); + revalidatePath("/"); +} + +export async function reorderDashboards(orderedIds: string[]): Promise { + const { user } = await getCurrentSession(); + await Promise.all( + orderedIds.map((id, i) => + db + .update(dashboards) + .set({ position: i }) + .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))), + ), + ); + revalidatePath("/"); +} + +export async function saveDashboardLayout(id: string, layout: DashboardLayout): Promise { + const { user } = await getCurrentSession(); + // Validate each widget's config against its registered schema + for (const placement of layout.widgets) { + const widget = getWidget(placement.widgetId); + if (!widget) continue; + widget.configSchema.parse(placement.config); + } + await db + .update(dashboards) + .set({ layout: layout as unknown as Record, updatedAt: new Date() }) + .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); + revalidatePath("/"); +} + +export async function resetDashboardLayout(id: string): Promise { + const { user } = await getCurrentSession(); + const layout = computeDefaultLayout(); + await db + .update(dashboards) + .set({ layout: layout as unknown as Record, updatedAt: new Date() }) + .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); + revalidatePath("/"); +} + +export async function resolveWidgetConfigOptions(widgetId: string): Promise { + const { user, household } = await getCurrentSession(); + const widget = getWidget(widgetId); + if (!widget?.resolveConfigOptions) return null; + return widget.resolveConfigOptions({ userId: user.id, householdId: household.id }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 37742b8..a2ed1bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,8 +6,9 @@ import { AppNav } from "@/components/app-nav"; import "@/modules"; // registers all module manifests import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; -import { eq } from "drizzle-orm"; -import { users } from "@/modules/_core/schema"; +import { asc, eq } from "drizzle-orm"; +import { dashboards, users } from "@/modules/_core/schema"; +import type { DashboardMeta } from "@/app/d/actions"; import { getQuickAdds } from "@/modules/_core"; import { QuickAddProvider } from "@/components/quick-add-provider"; import { QuickAddSheet } from "@/components/quick-add-sheet"; @@ -40,6 +41,7 @@ export default async function RootLayout({ }) { let theme = "default"; let themeMode = "system"; + let userDashboards: DashboardMeta[] = []; const session = await auth(); if (session?.user?.id) { @@ -52,6 +54,11 @@ export default async function RootLayout({ theme = row.theme; themeMode = row.themeMode; } + userDashboards = await db + .select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position }) + .from(dashboards) + .where(eq(dashboards.userId, session.user.id)) + .orderBy(asc(dashboards.position), asc(dashboards.createdAt)); } // For system mode we can't know the preference on the server — the inline @@ -71,7 +78,7 @@ export default async function RootLayout({ - +
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 85339af..b00dec2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,83 +1,7 @@ -import { Suspense } from "react"; -import { eq } from "drizzle-orm"; -import { db } from "@/lib/db"; -import { computeDefaultLayout, parseDashboardLayout } from "@/lib/dashboard"; -import { getCurrentSession } from "@/lib/session"; -import { getWidget } from "@/modules/_core"; -import { users } from "@/modules/_core/schema"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { QuickAddFab } from "@/components/quick-add-fab"; +import { redirect } from "next/navigation"; +import { getDefaultDashboardSlug } from "@/app/d/actions"; -// Static lookup ensures Tailwind sees all sm:col-span-* classes at build time. -const smColSpan: Record = { - 1: "sm:col-span-1", - 2: "sm:col-span-2", - 3: "sm:col-span-3", - 4: "sm:col-span-4", - 5: "sm:col-span-5", - 6: "sm:col-span-6", - 7: "sm:col-span-7", - 8: "sm:col-span-8", - 9: "sm:col-span-9", - 10: "sm:col-span-10", - 11: "sm:col-span-11", - 12: "sm:col-span-12", -}; - -export default async function DashboardPage() { - const { user, household } = await getCurrentSession(); - - const [row] = await db - .select({ layout: users.defaultDashboardLayout }) - .from(users) - .where(eq(users.id, user.id)) - .limit(1); - - const layout = parseDashboardLayout(row?.layout) ?? computeDefaultLayout(); - - const ctx = { userId: user.id, householdId: household.id }; - - // Sort by y then x so document order matches visual order on mobile (single col). - const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x); - - return ( -
-
-

Dashboard

- -
- -
- {placements.map((placement, i) => { - const widget = getWidget(placement.widgetId); - if (!widget) return null; - - const colClass = smColSpan[placement.w] ?? "sm:col-span-12"; - - return ( -
- - - {widget.title} - - - -
-
-
-
- } - > - {widget.render({ config: placement.config, ctx })} - - - -
- ); - })} -
-
- ); +export default async function RootPage() { + const slug = await getDefaultDashboardSlug(); + redirect(`/d/${slug}`); } diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 9b63d71..dff1cfe 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -26,6 +26,18 @@ export async function setUserTheme({ .where(eq(users.id, user.id)); } +export async function setCompletionVisibilityHours(hours: number): Promise { + const parsed = Number(hours); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 8760) + throw new Error("Invalid hours value"); + const { user } = await getCurrentSession(); + await db + .update(users) + .set({ completionVisibilityHours: parsed }) + .where(eq(users.id, user.id)); + revalidatePath("/settings"); +} + export async function revokeShareLinkAction(formData: FormData): Promise { const id = formData.get("id"); if (typeof id !== "string") throw new Error("Missing id"); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index c6a1665..90c2161 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -34,7 +34,7 @@ export default async function SettingsPage() { Lists - + diff --git a/src/components/app-nav.tsx b/src/components/app-nav.tsx index 55f2e84..260fe43 100644 --- a/src/components/app-nav.tsx +++ b/src/components/app-nav.tsx @@ -1,32 +1,46 @@ import Link from "next/link"; import { Settings } from "lucide-react"; import { getRegistry } from "@/modules/_core/registry"; +import type { DashboardMeta } from "@/app/d/actions"; +import { DashboardSwitcher } from "./dashboard-switcher"; +import { DashboardTab } from "./dashboard-tab"; -export function AppNav() { +export function AppNav({ dashboards = [] }: { dashboards?: DashboardMeta[] }) { const { modules } = getRegistry(); const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : [])); return ( - + + {dashboards.length > 0 && ( +
+ {dashboards.map((d) => ( + + ))} + +
+ )} + ); } diff --git a/src/components/completion-delay-setting.tsx b/src/components/completion-delay-setting.tsx index b582814..5238c9c 100644 --- a/src/components/completion-delay-setting.tsx +++ b/src/components/completion-delay-setting.tsx @@ -1,25 +1,41 @@ "use client"; -import { DELAY_OPTIONS, useCompletionDelay } from "@/hooks/use-completion-delay"; +import { useTransition } from "react"; +import { setCompletionVisibilityHours } from "@/app/settings/actions"; -export function CompletionDelaySetting() { - const { delay, setDelay } = useCompletionDelay(); +const OPTIONS = [ + { label: "1 hour", value: 1 }, + { label: "4 hours", value: 4 }, + { label: "8 hours", value: 8 }, + { label: "24 hours (default)", value: 24 }, + { label: "48 hours", value: 48 }, + { label: "7 days", value: 168 }, + { label: "Never hide", value: 8760 }, +]; + +export function CompletionDelaySetting({ initialHours }: { initialHours: number }) { + const [isPending, startTransition] = useTransition(); + + function handleChange(e: React.ChangeEvent) { + const hours = Number(e.target.value); + startTransition(() => setCompletionVisibilityHours(hours)); + } return (
-

Completion delay

+

Show completed items for

- How long a checked-off item stays visible before disappearing from list cards and the - dashboard. + How long checked-off items remain visible on list cards and the dashboard.

setNewName(e.target.value)} + placeholder="Dashboard name" + className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + onKeyDown={(e) => e.key === "Escape" && setCreatingNew(false)} + /> + + + + ) : ( + + )} + + {dashboards.map((d) => { + const isActive = activeSlug() === d.slug; + if (!isActive) return null; + return ( + 1} + /> + ); + })} +
+ ); +} + +function DashboardKebab({ + dashboard, + canDelete, +}: { + dashboard: DashboardMeta; + canDelete: boolean; +}) { + const [, startTransition] = useTransition(); + const [renaming, setRenaming] = useState(false); + const [newName, setNewName] = useState(dashboard.name); + + function handleRename() { + if (!newName.trim() || newName === dashboard.name) { + setRenaming(false); + return; + } + startTransition(async () => { + await renameDashboard(dashboard.id, newName.trim()); + setRenaming(false); + }); + } + + if (renaming) { + return ( +
{ + e.preventDefault(); + handleRename(); + }} + className="flex items-center gap-1 ml-1" + > + setNewName(e.target.value)} + className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + onKeyDown={(e) => e.key === "Escape" && setRenaming(false)} + /> + + +
+ ); + } + + return ( + + + + + + setRenaming(true)}> + + Rename + + {!dashboard.isDefault && ( + + startTransition(() => setDefaultDashboard(dashboard.id)) + } + > + + Set as default + + )} + {canDelete && ( + <> + + startTransition(() => deleteDashboard(dashboard.id))} + > + + Delete + + + )} + + + ); +} diff --git a/src/components/dashboard-tab.tsx b/src/components/dashboard-tab.tsx new file mode 100644 index 0000000..540f191 --- /dev/null +++ b/src/components/dashboard-tab.tsx @@ -0,0 +1,22 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export function DashboardTab({ slug, name }: { slug: string; name: string }) { + const pathname = usePathname(); + const isActive = pathname === `/d/${slug}` || pathname.startsWith(`/d/${slug}/`); + + return ( + + {name} + + ); +} diff --git a/src/components/edit-dashboard-button.tsx b/src/components/edit-dashboard-button.tsx new file mode 100644 index 0000000..22040af --- /dev/null +++ b/src/components/edit-dashboard-button.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; + +export function EditDashboardButton() { + const router = useRouter(); + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/share-button.tsx b/src/components/share-button.tsx new file mode 100644 index 0000000..c0932ac --- /dev/null +++ b/src/components/share-button.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Link, Check, Copy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { createShareLink } from "@/modules/_core/share"; + +export function ShareButton({ + entityType, + entityId, + canWrite = false, +}: { + entityType: string; + entityId: string; + canWrite?: boolean; +}) { + const [open, setOpen] = useState(false); + const [shareUrl, setShareUrl] = useState(null); + const [copied, setCopied] = useState(false); + const [isPending, startTransition] = useTransition(); + + function share() { + startTransition(async () => { + const result = await createShareLink(entityType, entityId, { + capabilities: { read: true, write: canWrite }, + }); + setShareUrl(result.url); + setOpen(true); + }); + } + + function copyUrl() { + if (!shareUrl) return; + navigator.clipboard.writeText(shareUrl).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + return ( + <> + + + + + + Share link created + +

+ Anyone with this link can {canWrite ? "view and edit" : "view"} this{" "} + {entityType.split(".")[1]}. +

+
+ + +
+
+
+ + ); +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..9d5ebbd --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,268 @@ +"use client" + +import * as React from "react" +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/components/widget-picker.tsx b/src/components/widget-picker.tsx new file mode 100644 index 0000000..3a4a8f0 --- /dev/null +++ b/src/components/widget-picker.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { X, ChevronLeft } from "lucide-react"; +import type { SerializedWidgetMeta } from "@/modules/_core/registry"; +import { resolveWidgetConfigOptions } from "@/app/d/actions"; +import { Button } from "@/components/ui/button"; + +type Step = "pick" | "configure"; + +export function WidgetPicker({ + onClose, + widgetMetas, + onAdd, + initialWidgetId, + initialConfig, +}: { + onClose: () => void; + widgetMetas: SerializedWidgetMeta[]; + onAdd: (widgetId: string, config: unknown) => void; + initialWidgetId?: string; + initialConfig?: unknown; +}) { + const [step, setStep] = useState(initialWidgetId ? "configure" : "pick"); + const [selectedId, setSelectedId] = useState(initialWidgetId ?? null); + const [options, setOptions] = useState(null); + const [config, setConfig] = useState(initialConfig ?? null); + const [, startTransition] = useTransition(); + + function selectWidget(id: string) { + const meta = widgetMetas.find((m) => m.id === id); + if (!meta) return; + setSelectedId(id); + setConfig(meta.defaultConfig); + setOptions(null); + setStep("configure"); + startTransition(async () => { + const opts = await resolveWidgetConfigOptions(id); + setOptions(opts); + }); + } + + function handleAdd() { + if (!selectedId) return; + onAdd(selectedId, config); + } + + const grouped = widgetMetas.reduce>((acc, m) => { + const cat = m.category ?? "Other"; + (acc[cat] ??= []).push(m); + return acc; + }, {}); + + const selected = widgetMetas.find((m) => m.id === selectedId); + + return ( +
e.target === e.currentTarget && onClose()} + > +
+ {/* Header */} +
+ {step === "configure" && !initialWidgetId && ( + + )} +

+ {step === "pick" ? "Add widget" : selected ? `Configure: ${selected.title}` : "Configure"} +

+ +
+ + {/* Body */} +
+ {step === "pick" && ( +
+ {Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([cat, items]) => ( +
+

{cat}

+
+ {items.map((meta) => ( + + ))} +
+
+ ))} +
+ )} + + {step === "configure" && selected && ( + + )} +
+ + {/* Footer */} + {step === "configure" && ( +
+ + +
+ )} +
+
+ ); +} + +// ── Configurator ────────────────────────────────────────────────────────────── + +type FieldOption = { id: string; name: string }; + +function WidgetConfigurator({ + meta, + config, + options, + onChange, +}: { + meta: SerializedWidgetMeta; + config: unknown; + options: unknown; + onChange: (c: unknown) => void; +}) { + const cfg = (config ?? meta.defaultConfig) as Record; + const opts = options as Record | null | undefined; + + function set(key: string, value: unknown) { + onChange({ ...cfg, [key]: value }); + } + + const entries = Object.entries(cfg); + if (entries.length === 0) { + return

No options available.

; + } + + return ( +
+ {entries.map(([key, value]) => { + // "all" | string[] — multi-select with All toggle + if (value === "all" || (Array.isArray(value) && (key.endsWith("Ids") || key.endsWith("ids")))) { + const optKey = key.replace(/Ids?$/i, "s"); + const items: FieldOption[] = (opts?.[optKey] as FieldOption[] | undefined) ?? []; + const isAll = value === "all"; + const selected = isAll ? [] : (value as string[]); + + return ( +
+ + + {!isAll && ( +
+ {items.length === 0 && ( +

None available

+ )} + {items.map((item) => ( + + ))} +
+ )} +
+ ); + } + + // boolean + if (typeof value === "boolean") { + return ( + + ); + } + + // number + if (typeof value === "number") { + return ( +
+ + set(key, Number(e.target.value))} + className="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> +
+ ); + } + + // string (enum / plain) + if (typeof value === "string") { + // Detect enum-like: same key appears in options as an array of strings + const enumOpts = opts?.[key] as string[] | undefined; + if (Array.isArray(enumOpts)) { + return ( +
+ + +
+ ); + } + + // Hardcoded enum fallback for known fields + const knownEnums: Record = { + filter: ["pinned", "all"], + }; + const known = knownEnums[key]; + if (known) { + return ( +
+ + +
+ ); + } + } + + return null; + })} +
+ ); +} diff --git a/src/hooks/use-completion-delay.ts b/src/hooks/use-completion-delay.ts deleted file mode 100644 index 435052d..0000000 --- a/src/hooks/use-completion-delay.ts +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { useState } from "react"; - -const STORAGE_KEY = "completion-delay-ms"; -export const DEFAULT_DELAY_MS = 3000; - -export const DELAY_OPTIONS = [ - { label: "1 second", value: 1000 }, - { label: "3 seconds", value: 3000 }, - { label: "5 seconds", value: 5000 }, - { label: "10 seconds", value: 10000 }, - { label: "30 seconds", value: 30000 }, -]; - -function readDelay(): number { - if (typeof window === "undefined") return DEFAULT_DELAY_MS; - const raw = localStorage.getItem(STORAGE_KEY); - const parsed = raw ? parseInt(raw, 10) : NaN; - return Number.isFinite(parsed) ? parsed : DEFAULT_DELAY_MS; -} - -export function useCompletionDelay() { - const [delay, setDelayState] = useState(readDelay); - - function setDelay(ms: number) { - setDelayState(ms); - localStorage.setItem(STORAGE_KEY, String(ms)); - } - - return { delay, setDelay }; -} diff --git a/src/modules/_core/index.ts b/src/modules/_core/index.ts index a064904..a014e26 100644 --- a/src/modules/_core/index.ts +++ b/src/modules/_core/index.ts @@ -10,8 +10,8 @@ export type { SearchResult, ActivityLogEntry, } from "./module"; -export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry"; -export type { QuickAddItem, SerializedQuickAddItem } from "./registry"; +export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds, getWidgetMetas } from "./registry"; +export type { QuickAddItem, SerializedQuickAddItem, SerializedWidgetMeta } from "./registry"; export { logActivity, logShareActivity } from "./activity"; export { createShareLink, resolveShareToken, revokeShareLink } from "./share"; export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share"; diff --git a/src/modules/_core/registry.ts b/src/modules/_core/registry.ts index 83d4b0c..badb7dc 100644 --- a/src/modules/_core/registry.ts +++ b/src/modules/_core/registry.ts @@ -42,6 +42,23 @@ export type SerializedQuickAddItem = { moduleName: string; }; +export type SerializedWidgetMeta = { + id: string; + title: string; + description: string; + category?: string; + defaultSize: { w: number; h: number }; + minSize?: { w: number; h: number }; + maxSize?: { w: number; h: number }; + defaultConfig: unknown; +}; + +export function getWidgetMetas(): SerializedWidgetMeta[] { + return [...widgets.values()].map(({ id, title, description, category, defaultSize, minSize, maxSize, defaultConfig }) => ({ + id, title, description, category, defaultSize, minSize, maxSize, defaultConfig, + })); +} + export function getQuickAdds(): SerializedQuickAddItem[] { return [...modules.values()].flatMap((manifest) => (manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({ diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index ed9582a..61df661 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -1,5 +1,6 @@ import type { AdapterAccountType } from "@auth/core/adapters"; import { + boolean, index, integer, jsonb, @@ -24,7 +25,7 @@ export const users = pgTable("users", { image: text("image"), theme: text("theme").notNull().default("default"), themeMode: text("theme_mode").notNull().default("system"), - defaultDashboardLayout: jsonb("default_dashboard_layout"), + completionVisibilityHours: integer("completion_visibility_hours").notNull().default(24), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -129,6 +130,24 @@ export const shareLinks = pgTable( ], ); +export const dashboards = pgTable( + "dashboards", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + slug: text("slug").notNull(), + isDefault: boolean("is_default").notNull().default(false), + position: integer("position").notNull().default(0), + layout: jsonb("layout").notNull().default({ version: 1, widgets: [] }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [uniqueIndex("dashboards_user_slug_uq").on(t.userId, t.slug)], +); + export const reminders = pgTable( "reminders", { diff --git a/src/modules/lists/components/list-widget.tsx b/src/modules/lists/components/list-widget.tsx index d99cef2..91db5d3 100644 --- a/src/modules/lists/components/list-widget.tsx +++ b/src/modules/lists/components/list-widget.tsx @@ -1,8 +1,7 @@ "use client"; import Link from "next/link"; -import { useRef, useState, useTransition } from "react"; -import { useCompletionDelay } from "@/hooks/use-completion-delay"; +import { useState, useTransition } from "react"; import { toggleItem } from "../server/actions"; type WidgetItem = { @@ -16,31 +15,9 @@ type WidgetItem = { export function ListWidget({ initialItems }: { initialItems: WidgetItem[] }) { const [items, setItems] = useState(initialItems); const [, startTransition] = useTransition(); - const { delay } = useCompletionDelay(); - const timers = useRef>>(new Map()); function toggle(item: WidgetItem, done: boolean) { - if (done) { - setItems((current) => - current.map((i) => (i.id === item.id ? { ...i, done: true } : i)), - ); - - const t = setTimeout(() => { - setItems((current) => current.filter((i) => i.id !== item.id)); - timers.current.delete(item.id); - }, delay); - timers.current.set(item.id, t); - } else { - const existing = timers.current.get(item.id); - if (existing) { - clearTimeout(existing); - timers.current.delete(item.id); - } - setItems((current) => - current.map((i) => (i.id === item.id ? { ...i, done: false } : i)), - ); - } - + setItems((current) => current.map((i) => (i.id === item.id ? { ...i, done } : i))); startTransition(async () => { await toggleItem({ id: item.id, done }); }); diff --git a/src/modules/lists/components/lists-index.tsx b/src/modules/lists/components/lists-index.tsx index 9b09098..be1f251 100644 --- a/src/modules/lists/components/lists-index.tsx +++ b/src/modules/lists/components/lists-index.tsx @@ -2,11 +2,10 @@ import Link from "next/link"; import { ChevronDown, ChevronRight, ExternalLink, Plus } from "lucide-react"; -import { useMemo, useRef, useState, useTransition } from "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 { useCompletionDelay } from "@/hooks/use-completion-delay"; import type { ListIndexItem, ListWithItemsDto } from "../server/queries"; import { createList, toggleItem } from "../server/actions"; @@ -15,8 +14,6 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) { const [type, setType] = useState("shopping"); const [name, setName] = useState(""); const [, startTransition] = useTransition(); - const { delay } = useCompletionDelay(); - const timers = useRef>>(new Map()); const grouped = useMemo(() => { const groups = new Map(); @@ -47,55 +44,18 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) { } function handleToggle(listId: string, item: ListIndexItem, done: boolean) { - const timerId = `${listId}:${item.id}`; - - if (done) { - // Mark done immediately (strikethrough) and schedule removal - setListRows((current) => - current.map((list) => - list.id !== listId - ? list - : { - ...list, - openCount: list.openCount - 1, - doneCount: list.doneCount + 1, - items: list.items.map((i) => (i.id === item.id ? { ...i, done: true } : i)), - }, - ), - ); - - const t = setTimeout(() => { - setListRows((current) => - current.map((list) => - list.id !== listId - ? list - : { ...list, items: list.items.filter((i) => i.id !== item.id) }, - ), - ); - timers.current.delete(timerId); - }, delay); - timers.current.set(timerId, t); - } else { - // Cancel pending removal and restore item - const existing = timers.current.get(timerId); - if (existing) { - clearTimeout(existing); - timers.current.delete(timerId); - } - setListRows((current) => - current.map((list) => - list.id !== listId - ? list - : { - ...list, - openCount: list.openCount + 1, - doneCount: list.doneCount - 1, - items: list.items.map((i) => (i.id === item.id ? { ...i, done: false } : i)), - }, - ), - ); - } - + setListRows((current) => + current.map((list) => + list.id !== listId + ? list + : { + ...list, + openCount: list.openCount + (done ? -1 : 1), + doneCount: list.doneCount + (done ? 1 : -1), + items: list.items.map((i) => (i.id === item.id ? { ...i, done } : i)), + }, + ), + ); startTransition(async () => { await toggleItem({ id: item.id, done }); }); diff --git a/src/modules/lists/server/queries.ts b/src/modules/lists/server/queries.ts index d60f90f..482c6fb 100644 --- a/src/modules/lists/server/queries.ts +++ b/src/modules/lists/server/queries.ts @@ -1,6 +1,6 @@ "use server"; -import { and, asc, eq, inArray, or, sql } from "drizzle-orm"; +import { and, asc, eq, gt, inArray, or, sql } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; @@ -166,7 +166,7 @@ export type ListIndexItem = { export type ListWithItemsDto = ListDto & { items: ListIndexItem[] }; export async function listListsWithItems(): Promise { - const { household } = await getCurrentSession(); + const { user, household } = await getCurrentSession(); await ensureDefaultListsForHousehold(household.id); const listsRows = await db @@ -178,8 +178,8 @@ export async function listListsWithItems(): Promise { if (listsRows.length === 0) return []; const listIds = listsRows.map((l) => l.id); + const cutoff = completionCutoff(user.completionVisibilityHours); - // Fetch all open items for these lists, ordered so we can take top 10 per list in JS const itemRows = await db .select({ id: listItems.id, @@ -190,8 +190,13 @@ export async function listListsWithItems(): Promise { createdAt: listItems.createdAt, }) .from(listItems) - .where(and(inArray(listItems.listId, listIds), eq(listItems.done, false))) - .orderBy(asc(listItems.position), asc(listItems.createdAt)); + .where( + and( + inArray(listItems.listId, listIds), + or(eq(listItems.done, false), and(eq(listItems.done, true), gt(listItems.updatedAt, cutoff))), + ), + ) + .orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt)); const itemsByList = new Map(); for (const item of itemRows) { @@ -254,8 +259,12 @@ export async function listWidgetItems(input: { if (visibleIds.length === 0) return []; - const conditions = [inArray(listItems.listId, visibleIds)]; - if (!parsed.showCompleted) conditions.push(eq(listItems.done, false)); + const { user } = await getCurrentSession(); + const cutoff = completionCutoff(user.completionVisibilityHours); + + const doneFilter = parsed.showCompleted + ? undefined + : or(eq(listItems.done, false), and(eq(listItems.done, true), gt(listItems.updatedAt, cutoff))); const rows = await db .select({ @@ -267,7 +276,7 @@ export async function listWidgetItems(input: { }) .from(listItems) .innerJoin(lists, eq(listItems.listId, lists.id)) - .where(and(...conditions)) + .where(and(inArray(listItems.listId, visibleIds), doneFilter)) .orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt)) .limit(parsed.limit ?? 10); @@ -318,6 +327,10 @@ function toListDto(list: typeof lists.$inferSelect, items: ListItemDto[]): ListD }; } +function completionCutoff(hours: number): Date { + return new Date(Date.now() - hours * 60 * 60 * 1000); +} + function toItemDto(item: typeof listItems.$inferSelect): ListItemDto { return { id: item.id,