fix: dashboard edit previews, migration journal, and notes comments
CI / checks (push) Failing after 2m8s
CI / build (push) Successful in 4m42s

Register drizzle journal entries for 0022/0023 so prod migrations apply.

Key edit-mode widget previews by index so bangs.stats shows live data.

Add comments to notes detail pages.
This commit is contained in:
ginnoir
2026-07-04 22:39:21 -05:00
parent 9e66c12eb7
commit 6183fb62c8
11 changed files with 72 additions and 17 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ src/
### Core primitives every module gets
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed.
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic.
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
+1 -1
View File
@@ -68,7 +68,7 @@ src/
### Core primitives every module gets
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed.
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic.
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
+4 -2
View File
@@ -29,7 +29,9 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **91 — Comments on lists and tasks** (Gitea #30, commit `e1c2a09`). Generic `comments` table + `_core/comments.ts`; reusable `EntityComments` on list detail (list + per-item compact threads). Migration `0023_comments.sql`.
- **92 — Bang stats dashboard widget** (Gitea #31). `bangs.stats` widget with monthly/yearly counts, average days between bangs, recent month breakdown.
- **92 — Bang stats dashboard widget** (Gitea #31, commit `e1c2a09`). `bangs.stats` widget with monthly/yearly counts, average days between bangs, recent month breakdown.
- **P2 batch follow-ups** (release v0.5.4). Migration journal entries for `0022`/`0023` (prod apply fix); dashboard edit previews keyed by placement index (`bangs.stats` live in edit mode); notes comments; comment revalidation on `/notes`.
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
@@ -81,7 +83,7 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 79, 1112, 1519.
**How to resume:** P2 batch #28#31 complete. Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`). Run `pnpm db:migrate` for migrations `0022` and `0023`.
**How to resume:** P2 batch #28#31 complete (released v0.5.4). Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
## Development login/testing notes
+14
View File
@@ -155,6 +155,20 @@
"when": 1751754000000,
"tag": "0021_user_assistant_enabled",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1780392000000,
"tag": "0022_multiple_reminders",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1780393000000,
"tag": "0023_comments",
"breakpoints": true
}
]
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout, widgetContentKey } from "@/lib/dashboard";
import { parseDashboardLayout, widgetContentIndexKey } from "@/lib/dashboard";
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core";
@@ -58,10 +58,10 @@ export default async function DashboardPage({
if (isEditing) {
const ctx = { userId: user.id, householdId: household.id };
const widgetContents = Object.fromEntries(
layout.widgets.map((placement) => [
widgetContentKey(placement),
layout.widgets.map((placement, index) => [
widgetContentIndexKey(index),
<DashboardWidgetContent
key={widgetContentKey(placement)}
key={widgetContentIndexKey(index)}
placement={placement}
ctx={ctx}
/>,
+3 -2
View File
@@ -1,11 +1,12 @@
import { notFound } from "next/navigation";
import { getCurrentSession } from "@/lib/session";
import { NoteEditor } from "@/modules/notes/components/note-editor";
import { getNote } from "@/modules/notes/server/queries";
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const note = await getNote(id).catch(() => null);
const [{ user }, note] = await Promise.all([getCurrentSession(), getNote(id).catch(() => null)]);
if (!note) notFound();
return <NoteEditor note={note} />;
return <NoteEditor note={note} currentUserId={user.id} />;
}
+24 -4
View File
@@ -9,7 +9,7 @@ import { GridLayout } from "react-grid-layout";
import type { Layout } from "react-grid-layout";
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
import { computePresetLayoutFromMetas, widgetContentKey } from "@/lib/dashboard";
import { computePresetLayoutFromMetas, widgetContentIndexKey } from "@/lib/dashboard";
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import {
saveDashboardLayout,
@@ -113,10 +113,18 @@ export function DashboardEditor({
function addWidget(widgetId: string, config: unknown) {
const meta = widgetMetas.find((m) => m.id === widgetId);
if (!meta) return;
const resolvedConfig = resolveWidgetConfig(meta, config);
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
const next = [
...placements,
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h },
{
widgetId,
config: resolvedConfig,
x: 0,
y: maxY,
w: meta.defaultSize.w,
h: meta.defaultSize.h,
},
];
setPlacements(next);
setIsDirty(true);
@@ -125,7 +133,9 @@ export function DashboardEditor({
}
function updateConfig(index: number, config: unknown) {
const next = placements.map((p, i) => (i === index ? { ...p, config } : p));
const meta = widgetMetas.find((m) => m.id === placements[index]?.widgetId);
const resolvedConfig = meta ? resolveWidgetConfig(meta, config) : config;
const next = placements.map((p, i) => (i === index ? { ...p, config: resolvedConfig } : p));
setPlacements(next);
setIsDirty(true);
setConfiguringIndex(null);
@@ -237,7 +247,7 @@ export function DashboardEditor({
>
{placements.map((placement, i) => {
const meta = widgetMetas.find((m) => m.id === placement.widgetId);
const content = widgetContents[widgetContentKey(placement)];
const content = widgetContents[widgetContentIndexKey(i)];
return (
<div
key={placementKey(placement, i)}
@@ -308,3 +318,13 @@ export function DashboardEditor({
</div>
);
}
function resolveWidgetConfig(meta: SerializedWidgetMeta, config: unknown): unknown {
if (config != null && typeof config === "object" && !Array.isArray(config)) {
return {
...(meta.defaultConfig as Record<string, unknown>),
...(config as Record<string, unknown>),
};
}
return meta.defaultConfig;
}
+6 -1
View File
@@ -11,7 +11,12 @@ export type WidgetPlacement = {
};
export function widgetContentKey(placement: Pick<WidgetPlacement, "widgetId" | "config">): string {
return `${placement.widgetId}::${JSON.stringify(placement.config)}`;
return `${placement.widgetId}::${JSON.stringify(placement.config ?? null)}`;
}
/** Stable key for edit-mode widget preview map (index-aligned with layout.widgets). */
export function widgetContentIndexKey(index: number): string {
return String(index);
}
export type DashboardLayout = {
+2
View File
@@ -77,6 +77,7 @@ export async function addComment(input: z.input<typeof commentInput>): Promise<C
if (!row) throw new Error("Comment was not created");
revalidatePath("/lists");
revalidatePath("/notes");
return {
id: row.id,
@@ -104,4 +105,5 @@ export async function deleteComment(input: { id: string }) {
await db.delete(comments).where(eq(comments.id, parsed.id));
revalidatePath("/lists");
revalidatePath("/notes");
}
+3 -1
View File
@@ -16,7 +16,8 @@ async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetC
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
}
async function BangStatsWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) {
async function BangStatsWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
bangStatsConfigSchema.parse(config);
const aggregates = await getBangAggregates(ctx.householdId);
return <BangStatsWidget aggregates={aggregates} />;
}
@@ -62,6 +63,7 @@ const bangsManifest: ModuleManifest = {
defaultPriority: 55,
configSchema: bangStatsConfigSchema,
defaultConfig: {},
resolveConfigOptions: async () => undefined,
render: (props) => <BangStatsWidgetServer {...props} />,
},
],
+10 -1
View File
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Pin, PinOff, Save, Trash2 } from "lucide-react";
import { useState, useTransition } from "react";
import { EntityComments } from "@/components/comments/entity-comments";
import { RichTextContent } from "@/components/rich-text";
import { DetailBackLink } from "@/components/detail-back-link";
import { Button } from "@/components/ui/button";
@@ -25,7 +26,7 @@ const RichTextEditor = dynamic(
},
);
export function NoteEditor({ note }: { note?: NoteDto }) {
export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUserId?: string }) {
const router = useRouter();
const [currentNote, setCurrentNote] = useState(note);
const [title, setTitle] = useState(note?.title ?? "");
@@ -147,6 +148,14 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
<RichTextContent html={body} />
</aside>
</div>
{currentNote && currentUserId ? (
<EntityComments
entityType="notes.note"
entityId={currentNote.id}
currentUserId={currentUserId}
/>
) : null}
</div>
);
}