feat: journal dashboard widgets, agent polish, and edit-mode live previews
CI / checks (push) Failing after 2m7s
CI / build (push) Successful in 4m36s

Journal dashboard widgets and quick-add; rich-text quick-add dialogs.

Dashboard draft sync for live edit previews; assistant bubble + API tools.

Journal UX: stress slider, mood grid, query cap fix.
This commit is contained in:
ginnoir
2026-07-04 22:03:45 -05:00
parent 4a924a4107
commit a09747c314
76 changed files with 4594 additions and 611 deletions
+3 -1
View File
@@ -19,7 +19,9 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **86 — Journal module** (ADR 0005 accepted `8e2ddd6`). Per-user `journal_entries` schema + migration `0020_journal_entries.sql`; mood catalog (multi-select), optional stress/pills, rich-text body; index with calendar dots, entry editor, Recharts mood tracker, insights (streak/trends/correlations); `/api/v1/journal/entries` CRUD with bearer token auth; OpenAPI updated. Unit tests: `journal-analytics.test.ts`. E2E: `tests/e2e/journal.spec.ts`. Run `pnpm db:migrate` for migration `0020`.
- **88 — LLM agent chat** (ADR 0006). `/assistant` chat UI; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls (lists, calendar, notes, journal); `POST /api/agent/chat`. Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`.
- **88 — LLM agent chat** (ADR 0006). Opt-in floating chat bubble; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls; `POST /api/agent/chat`. Per-user `assistant_enabled` (default off). Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`. Migration `0021`.
- **Journal + dashboard polish** (follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open.
- **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.
+286
View File
@@ -314,6 +314,103 @@ components:
properties:
occurredOn: { type: string, format: date }
CareLog:
type: object
properties:
id: { type: string, format: uuid }
careType: { type: string }
notes: { type: string, nullable: true }
performedAt: { type: string, format: date-time }
performedBy: { type: string, format: uuid, nullable: true }
CareLogInput:
type: object
required: [careType]
properties:
careType: { type: string }
notes: { type: string, nullable: true }
performedAt: { type: string, format: date-time }
CareSchedule:
type: object
properties:
id: { type: string, format: uuid }
careType: { type: string }
intervalDays: { type: integer }
lastPerformedAt: { type: string, format: date-time, nullable: true }
nextDueAt: { type: string, format: date-time, nullable: true }
enabled: { type: boolean }
daysUntilDue: { type: integer, nullable: true }
isOverdue: { type: boolean }
CareScheduleInput:
type: object
required: [careType, intervalDays]
properties:
careType: { type: string }
intervalDays: { type: integer, minimum: 1, maximum: 365 }
enabled: { type: boolean }
CareScheduleToggleInput:
type: object
required: [enabled]
properties:
enabled: { type: boolean }
ScheduleOnCalendarInput:
type: object
required: [calendarId]
properties:
calendarId: { type: string, format: uuid }
reminderMinutesBefore: { type: integer, nullable: true }
ShareableEntityType:
type: object
properties:
type: { type: string }
label: { type: string }
defaultCapabilities:
type: array
items: { type: string }
ShareLink:
type: object
properties:
id: { type: string, format: uuid }
entityType: { type: string }
entityId: { type: string, format: uuid }
createdAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time, nullable: true }
capabilities:
type: object
properties:
read: { type: boolean }
write: { type: boolean }
ShareLinkCreateInput:
type: object
required: [entityType, entityId]
properties:
entityType: { type: string }
entityId: { type: string, format: uuid }
expiresAt: { type: string, format: date-time, nullable: true }
capabilities:
type: object
properties:
read: { type: boolean }
write: { type: boolean }
ShareLinkCreated:
type: object
properties:
url: { type: string, format: uri }
expiresAt: { type: string, format: date-time, nullable: true }
PushOverdueResult:
type: object
properties:
added: { type: integer }
paths:
/api/v1/calendars:
get:
@@ -791,6 +888,193 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/garden/plants/{id}/care-logs:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: List care logs for a plant
tags: [Garden]
parameters:
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/CareLog" }
post:
summary: Log care performed on a plant
tags: [Garden]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CareLogInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/CareLog" }
/api/v1/garden/plants/{id}/care-schedules:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: List care schedules for a plant
tags: [Garden]
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/CareSchedule" }
post:
summary: Create or update a care schedule
tags: [Garden]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CareScheduleInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/CareSchedule" }
/api/v1/garden/care-schedules/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
patch:
summary: Enable or disable a care schedule
tags: [Garden]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CareScheduleToggleInput" }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
delete:
summary: Delete a care schedule
tags: [Garden]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/garden/care-schedules/{id}/calendar:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
post:
summary: Add next due care to calendar
tags: [Garden]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ScheduleOnCalendarInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/garden/overdue-care/push:
post:
summary: Push overdue care items to task list
tags: [Garden]
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/PushOverdueResult" }
/api/v1/share-links:
get:
summary: List share links or shareable entity types
tags: [Sharing]
parameters:
- name: entityTypes
in: query
schema: { type: string, enum: ["true"] }
description: When true, returns shareable entity types instead of links
- name: entityType
in: query
schema: { type: string }
- name: entityId
in: query
schema: { type: string, format: uuid }
responses:
"200":
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: "#/components/schemas/ShareLink" }
- type: array
items: { $ref: "#/components/schemas/ShareableEntityType" }
post:
summary: Create a share link
tags: [Sharing]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ShareLinkCreateInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/ShareLinkCreated" }
/api/v1/share-links/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
delete:
summary: Revoke a share link
tags: [Sharing]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/openapi:
get:
summary: OpenAPI specification (YAML)
tags: [Meta]
responses:
"200":
description: OpenAPI 3.1 YAML document
content:
application/yaml:
schema: { type: string }
/api/v1/bangs:
get:
summary: Bang stats (total + recent)
@@ -916,4 +1200,6 @@ tags:
- name: Notes
- name: Journal
- name: Garden
- name: Sharing
- name: Bangs
- name: Meta
+48 -47
View File
@@ -2,55 +2,56 @@
Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea (`ginnoir/famapp`).
| Design | Gitea | Title | URL |
| --- | --- | --- | --- |
| 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 |
| 2 | 2 | Dashboard edit mode renders live widgets at true size | https://gitea.ginnoir.com/ginnoir/famapp/issues/2 |
| 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 |
| 4 | 4 | Bangs are not editable (add edit and delete) | https://gitea.ginnoir.com/ginnoir/famapp/issues/4 |
| 5 | 5 | Notes overflow horizontally on mobile | https://gitea.ginnoir.com/ginnoir/famapp/issues/5 |
| 6 | 6 | Missing back navigation on detail pages (audit + shared affordance) | https://gitea.ginnoir.com/ginnoir/famapp/issues/6 |
| 7 | 28 | Calendar reminders overhaul | https://gitea.ginnoir.com/ginnoir/famapp/issues/28 |
| 8 | 29 | Lists index: inline task add + list property edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/29 |
| 9 | 30 | Comments on lists and tasks | https://gitea.ginnoir.com/ginnoir/famapp/issues/30 |
| 10 | 7 | Notes editor overhaul (shared rich-text component) | https://gitea.ginnoir.com/ginnoir/famapp/issues/7 |
| 10.1 | 8 | Research ADR: rich-text editor library and storage format | https://gitea.ginnoir.com/ginnoir/famapp/issues/8 |
| 10.2 | 9 | Shared rich-text editor component | https://gitea.ginnoir.com/ginnoir/famapp/issues/9 |
| 10.3 | 10 | Wire shared editor into notes create/edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/10 |
| 10.4 | 11 | Render note formatting on all notes surfaces | https://gitea.ginnoir.com/ginnoir/famapp/issues/11 |
| 10.5 | 12 | Fix notes mobile horizontal overflow | https://gitea.ginnoir.com/ginnoir/famapp/issues/12 |
| 11 | 31 | Bang stats dashboard widget | https://gitea.ginnoir.com/ginnoir/famapp/issues/31 |
| 12 | 35 | Appearance / theming options | https://gitea.ginnoir.com/ginnoir/famapp/issues/35 |
| 13 | 19 | Journal module | https://gitea.ginnoir.com/ginnoir/famapp/issues/19 |
| 13.1 | 20 | Research ADR: journal/mood tracking build-vs-adopt | https://gitea.ginnoir.com/ginnoir/famapp/issues/20 |
| 13.2 | 21 | Journal schema + CRUD | https://gitea.ginnoir.com/ginnoir/famapp/issues/21 |
| 13.3 | 22 | Journal index (recent, browse, entry calendar) | https://gitea.ginnoir.com/ginnoir/famapp/issues/22 |
| 13.4 | 23 | Journal entry detail and create | https://gitea.ginnoir.com/ginnoir/famapp/issues/23 |
| 13.5 | 24 | Journal mood tracker view | https://gitea.ginnoir.com/ginnoir/famapp/issues/24 |
| 13.6 | 25 | Journal insights/stats views | https://gitea.ginnoir.com/ginnoir/famapp/issues/25 |
| 13.7 | 26 | Journal E2E happy path | https://gitea.ginnoir.com/ginnoir/famapp/issues/26 |
| 13.8 | 27 | Journal API endpoints | https://gitea.ginnoir.com/ginnoir/famapp/issues/27 |
| 14 | 13 | API + LLM agent chat | https://gitea.ginnoir.com/ginnoir/famapp/issues/13 |
| 14.1 | 14 | Research ADR: API auth, surface shape, and LLM agent architecture | https://gitea.ginnoir.com/ginnoir/famapp/issues/14 |
| 14.2 | 15 | API surface + token auth for existing modules | https://gitea.ginnoir.com/ginnoir/famapp/issues/15 |
| 14.3 | 16 | LLM agent chat UI | https://gitea.ginnoir.com/ginnoir/famapp/issues/16 |
| 14.4 | 17 | Map agent tool-calling to API | https://gitea.ginnoir.com/ginnoir/famapp/issues/17 |
| 14.5 | 18 | Agent smoke tests with mock provider | https://gitea.ginnoir.com/ginnoir/famapp/issues/18 |
| 15 | 32 | Pets module | https://gitea.ginnoir.com/ginnoir/famapp/issues/32 |
| 16 | 33 | Shopping/pantry module | https://gitea.ginnoir.com/ginnoir/famapp/issues/33 |
| 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 |
| 18 | 36 | GPS locations for calendar events | https://gitea.ginnoir.com/ginnoir/famapp/issues/36 |
| 19 | 37 | Lists and notes cohesion (research proposal) | https://gitea.ginnoir.com/ginnoir/famapp/issues/37 |
| Design | Gitea | Title | URL |
| ------ | ----- | ------------------------------------------------------------------- | -------------------------------------------------- |
| 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 |
| 2 | 2 | Dashboard edit mode renders live widgets at true size | https://gitea.ginnoir.com/ginnoir/famapp/issues/2 |
| 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 |
| 4 | 4 | Bangs are not editable (add edit and delete) | https://gitea.ginnoir.com/ginnoir/famapp/issues/4 |
| 5 | 5 | Notes overflow horizontally on mobile | https://gitea.ginnoir.com/ginnoir/famapp/issues/5 |
| 6 | 6 | Missing back navigation on detail pages (audit + shared affordance) | https://gitea.ginnoir.com/ginnoir/famapp/issues/6 |
| 7 | 28 | Calendar reminders overhaul | https://gitea.ginnoir.com/ginnoir/famapp/issues/28 |
| 8 | 29 | Lists index: inline task add + list property edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/29 |
| 9 | 30 | Comments on lists and tasks | https://gitea.ginnoir.com/ginnoir/famapp/issues/30 |
| 10 | 7 | Notes editor overhaul (shared rich-text component) | https://gitea.ginnoir.com/ginnoir/famapp/issues/7 |
| 10.1 | 8 | Research ADR: rich-text editor library and storage format | https://gitea.ginnoir.com/ginnoir/famapp/issues/8 |
| 10.2 | 9 | Shared rich-text editor component | https://gitea.ginnoir.com/ginnoir/famapp/issues/9 |
| 10.3 | 10 | Wire shared editor into notes create/edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/10 |
| 10.4 | 11 | Render note formatting on all notes surfaces | https://gitea.ginnoir.com/ginnoir/famapp/issues/11 |
| 10.5 | 12 | Fix notes mobile horizontal overflow | https://gitea.ginnoir.com/ginnoir/famapp/issues/12 |
| 11 | 31 | Bang stats dashboard widget | https://gitea.ginnoir.com/ginnoir/famapp/issues/31 |
| 12 | 35 | Appearance / theming options | https://gitea.ginnoir.com/ginnoir/famapp/issues/35 |
| 13 | 19 | Journal module | https://gitea.ginnoir.com/ginnoir/famapp/issues/19 |
| 13.1 | 20 | Research ADR: journal/mood tracking build-vs-adopt | https://gitea.ginnoir.com/ginnoir/famapp/issues/20 |
| 13.2 | 21 | Journal schema + CRUD | https://gitea.ginnoir.com/ginnoir/famapp/issues/21 |
| 13.3 | 22 | Journal index (recent, browse, entry calendar) | https://gitea.ginnoir.com/ginnoir/famapp/issues/22 |
| 13.4 | 23 | Journal entry detail and create | https://gitea.ginnoir.com/ginnoir/famapp/issues/23 |
| 13.5 | 24 | Journal mood tracker view | https://gitea.ginnoir.com/ginnoir/famapp/issues/24 |
| 13.6 | 25 | Journal insights/stats views | https://gitea.ginnoir.com/ginnoir/famapp/issues/25 |
| 13.7 | 26 | Journal E2E happy path | https://gitea.ginnoir.com/ginnoir/famapp/issues/26 |
| 13.8 | 27 | Journal API endpoints | https://gitea.ginnoir.com/ginnoir/famapp/issues/27 |
| 14 | 13 | API + LLM agent chat | https://gitea.ginnoir.com/ginnoir/famapp/issues/13 |
| 14.1 | 14 | Research ADR: API auth, surface shape, and LLM agent architecture | https://gitea.ginnoir.com/ginnoir/famapp/issues/14 |
| 14.2 | 15 | API surface + token auth for existing modules | https://gitea.ginnoir.com/ginnoir/famapp/issues/15 |
| 14.3 | 16 | LLM agent chat UI | https://gitea.ginnoir.com/ginnoir/famapp/issues/16 |
| 14.4 | 17 | Map agent tool-calling to API | https://gitea.ginnoir.com/ginnoir/famapp/issues/17 |
| 14.5 | 18 | Agent smoke tests with mock provider | https://gitea.ginnoir.com/ginnoir/famapp/issues/18 |
| 15 | 32 | Pets module | https://gitea.ginnoir.com/ginnoir/famapp/issues/32 |
| 16 | 33 | Shopping/pantry module | https://gitea.ginnoir.com/ginnoir/famapp/issues/33 |
| 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 |
| 18 | 36 | GPS locations for calendar events | https://gitea.ginnoir.com/ginnoir/famapp/issues/36 |
| 19 | 37 | Lists and notes cohesion (research proposal) | https://gitea.ginnoir.com/ginnoir/famapp/issues/37 |
| — | 38 | Revisit journal day-switch animations (smoother paper transition) | https://gitea.ginnoir.com/ginnoir/famapp/issues/38 |
## Dependencies (P1)
| Issue | Depends on |
| --- | --- |
| #5 (notes overflow) | #7 (rich-text epic) |
| #12 (mobile overflow child) | #8 (rich-text research ADR) |
| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) |
| #16 (agent UI) | #15 (API surface) |
| #17 (tool-calling) | #15 (API surface) |
| #27 (journal API) | #15 (API surface) |
| Issue | Depends on |
| --------------------------- | -------------------------------------- |
| #5 (notes overflow) | #7 (rich-text epic) |
| #12 (mobile overflow child) | #8 (rich-text research ADR) |
| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) |
| #16 (agent UI) | #15 (API surface) |
| #17 (tool-calling) | #15 (API surface) |
| #27 (journal API) | #15 (API surface) |
Native Gitea issue dependencies API 404s on this instance; dependencies are also noted as comments on the issues above.
+3 -3
View File
@@ -25,9 +25,9 @@ Edit mode currently shows empty placeholders; on save, content-heavy widgets (e.
## Acceptance criteria
- [ ] Edit mode shows real widget content for registered widgets.
- [ ] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
- [ ] E2E covers real content visible in edit mode.
- [x] Edit mode shows real widget content for registered widgets.
- [x] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
- [x] E2E covers real content visible in edit mode.
## Notes
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "assistant_enabled" boolean DEFAULT false NOT NULL;
+7
View File
@@ -148,6 +148,13 @@
"when": 1751750400000,
"tag": "0020_journal_entries",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1751754000000,
"tag": "0021_user_assistant_enabled",
"breakpoints": true
}
]
}
+50 -1
View File
@@ -1,10 +1,13 @@
import { z } from "zod";
import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth";
import { getAssistantEnabled } from "@/lib/assistant-preference";
import { isLlmConfigured } from "@/lib/llm";
import { encodeSseEvent } from "@/modules/agent/server/progress";
import { runAgentChat } from "@/modules/agent/server/run";
const chatInput = z.object({
stream: z.boolean().optional(),
messages: z
.array(
z.object({
@@ -18,10 +21,15 @@ const chatInput = z.object({
export async function POST(request: Request) {
const auth = await resolveApiAuth(request);
if (!auth) {
if (!auth?.userId) {
return apiError("Unauthorized", 401);
}
const assistantEnabled = await getAssistantEnabled(auth.userId);
if (!assistantEnabled) {
return apiError("Assistant not enabled", 403);
}
let body: unknown;
try {
body = await request.json();
@@ -34,6 +42,47 @@ export async function POST(request: Request) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
}
if (parsed.data.stream) {
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: Parameters<typeof encodeSseEvent>[0]) => {
controller.enqueue(encoder.encode(encodeSseEvent(event)));
};
try {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
onProgress: send,
});
send({
type: "done",
message: {
role: "assistant",
content: result.message.content,
},
toolCalls: result.toolCalls,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Agent request failed";
send({ type: "error", message });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
try {
const result = await runAgentChat({
messages: parsed.data.messages,
@@ -0,0 +1,13 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { scheduleOnCalendarForScope } from "@/modules/garden/server/actions";
import { scheduleOnCalendarInput } from "@/modules/garden/server/schemas";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = scheduleOnCalendarInput.parse({ ...(body as object), scheduleId: id });
await scheduleOnCalendarForScope(scope, parsed);
return apiJson({ ok: true }, 201);
});
}
@@ -0,0 +1,26 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
deleteCareScheduleForScope,
toggleCareScheduleForScope,
} from "@/modules/garden/server/actions";
import { careScheduleToggleInput } from "@/modules/garden/server/schemas";
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = careScheduleToggleInput.parse({ ...(body as object), id });
await toggleCareScheduleForScope(scope, parsed);
return apiJson({ ok: true });
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteCareScheduleForScope(scope, { id });
return apiJson({ ok: true });
});
}
@@ -0,0 +1,9 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { pushOverdueToTaskListForScope } from "@/modules/garden/server/actions";
export async function POST(request: Request) {
return withApiHandler(request, async (scope) => {
const result = await pushOverdueToTaskListForScope(scope);
return apiJson(result, 201);
});
}
@@ -0,0 +1,37 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { logCareForScope } from "@/modules/garden/server/actions";
import { careLogInput } from "@/modules/garden/server/schemas";
import { getCareLogsForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const url = new URL(req.url);
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? z.coerce.number().int().min(1).max(100).parse(limitRaw) : 20;
const logs = await getCareLogsForScope(scope.householdId, id, limit);
return apiJson(logs);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = careLogInput.parse({ ...(body as object), plantId: id });
const log = await logCareForScope(scope, parsed);
return apiJson(
{
id: log.id,
careType: log.careType,
notes: log.notes,
performedAt: log.performedAt.toISOString(),
performedBy: log.performedBy,
},
201,
);
});
}
@@ -0,0 +1,35 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { upsertCareScheduleForScope } from "@/modules/garden/server/actions";
import { careScheduleInput } from "@/modules/garden/server/schemas";
import { getCareSchedulesForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const schedules = await getCareSchedulesForScope(scope.householdId, id);
return apiJson(schedules);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = careScheduleInput.parse({ ...(body as object), plantId: id });
const schedule = await upsertCareScheduleForScope(scope, parsed);
return apiJson(
{
id: schedule.id,
careType: schedule.careType,
intervalDays: schedule.intervalDays,
lastPerformedAt: schedule.lastPerformedAt?.toISOString() ?? null,
nextDueAt: schedule.nextDueAt?.toISOString() ?? null,
enabled: schedule.enabled,
},
201,
);
});
}
+5 -2
View File
@@ -4,8 +4,11 @@ import { journalEntryInput } from "@/modules/journal/server/schemas";
import { listJournalEntriesForScope } from "@/modules/journal/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope) => {
const entries = await listJournalEntriesForScope(scope);
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const limitParam = url.searchParams.get("limit");
const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 20, 1), 100) : undefined;
const entries = await listJournalEntriesForScope(scope, limit ? { limit } : undefined);
return apiJson(entries);
});
}
+14
View File
@@ -0,0 +1,14 @@
import { readFile } from "fs/promises";
import path from "path";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) {
return withApiHandler(request, async () => {
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
const spec = await readFile(specPath, "utf8");
return new Response(spec, {
status: 200,
headers: { "Content-Type": "application/yaml; charset=utf-8" },
});
});
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { revokeShareLinkForScope } from "@/modules/_core/share-api";
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await revokeShareLinkForScope(scope, id);
return apiJson({ ok: true });
});
}
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
createShareLinkForScope,
listShareLinksForScope,
listShareableEntityTypes,
} from "@/modules/_core/share-api";
const createShareLinkInput = z.object({
entityType: z.string().trim().min(1),
entityId: z.string().uuid(),
expiresAt: z.string().datetime().nullable().optional(),
capabilities: z
.object({
read: z.boolean().optional(),
write: z.boolean().optional(),
})
.optional(),
});
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const entityType = url.searchParams.get("entityType") ?? undefined;
const entityId = url.searchParams.get("entityId") ?? undefined;
if (url.searchParams.get("entityTypes") === "true") {
return apiJson(listShareableEntityTypes());
}
const links = await listShareLinksForScope(scope, { entityType, entityId });
return apiJson(links);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = createShareLinkInput.parse(body);
const expiresAt = parsed.expiresAt ? new Date(parsed.expiresAt) : null;
const result = await createShareLinkForScope(scope, parsed.entityType, parsed.entityId, {
expiresAt,
capabilities: parsed.capabilities,
});
return apiJson(
{
url: result.url,
expiresAt: result.expiresAt?.toISOString() ?? null,
},
201,
);
});
}
-6
View File
@@ -1,6 +0,0 @@
import { AssistantChat } from "@/modules/agent/components/assistant-chat";
import { isLlmConfigured } from "@/lib/llm";
export default function AssistantPage() {
return <AssistantChat configured={isLlmConfigured()} />;
}
+6 -2
View File
@@ -2,7 +2,8 @@ import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout, widgetContentKey } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core";
import { getDashboardBySlug } from "@/app/d/actions";
import { DashboardEditor } from "@/components/dashboard-editor";
@@ -49,7 +50,9 @@ export default async function DashboardPage({
const dashboard = await getDashboardBySlug(slug);
if (!dashboard) notFound();
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const storedLayout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const draftLayout = isEditing ? await readEditorDraftLayout(dashboard.id) : null;
const layout = normalizeDashboardLayout(draftLayout ?? storedLayout);
const widgetMetas = getWidgetMetas();
if (isEditing) {
@@ -67,6 +70,7 @@ export default async function DashboardPage({
return (
<DashboardEditor
key={`${dashboard.id}:${JSON.stringify(layout.widgets)}`}
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
layout={layout}
widgetMetas={widgetMetas}
+32
View File
@@ -10,6 +10,7 @@ import { getWidget } from "@/modules/_core";
import { dashboards } from "@/modules/_core/schema";
import { type DashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { clearEditorDraftLayout, writeEditorDraftLayout } from "@/lib/dashboard-editor-draft";
export type DashboardMeta = {
id: string;
@@ -156,9 +157,39 @@ export async function saveDashboardLayout(id: string, layout: DashboardLayout):
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/");
}
export async function syncEditorDraftLayout(id: string, layout: DashboardLayout): Promise<void> {
const { user } = await getCurrentSession();
const [row] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
.limit(1);
if (!row) throw new Error("Dashboard not found");
for (const placement of layout.widgets) {
const widget = getWidget(placement.widgetId);
if (!widget) continue;
widget.configSchema.parse(placement.config);
}
await writeEditorDraftLayout(id, layout);
}
export async function discardEditorDraftLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const [row] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
.limit(1);
if (!row) throw new Error("Dashboard not found");
await clearEditorDraftLayout(id);
}
export async function resetDashboardLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const layout = computeDefaultLayout();
@@ -166,6 +197,7 @@ export async function resetDashboardLayout(id: string): Promise<void> {
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/");
}
+139
View File
@@ -818,6 +818,86 @@
bottom: 18px;
}
/* ── Assistant chat bubble (opt-in per user) ─────────────────────── */
.assistant-bubble {
position: fixed;
right: 18px;
bottom: 24px;
z-index: 70;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
pointer-events: none;
}
:where(html[data-nav="bottom"]) .assistant-bubble {
bottom: calc(62px + max(env(safe-area-inset-bottom, 0px), 8px) + 12px);
}
:where(html[data-nav="fab"]) .assistant-bubble {
bottom: calc(18px + 52px + 12px);
}
.assistant-bubble > * {
pointer-events: auto;
}
.assistant-bubble-panel {
display: flex;
flex-direction: column;
width: min(360px, calc(100vw - 24px));
height: min(480px, calc(100vh - 140px));
border-radius: var(--r-md);
border: 0.5px solid var(--hair);
background: color-mix(in oklab, var(--card) 94%, transparent);
backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 12px 40px rgba(31, 27, 22, 0.16),
0 2px 8px rgba(31, 27, 22, 0.08);
padding: 12px;
overflow: hidden;
}
.assistant-bubble-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
flex-shrink: 0;
}
.assistant-bubble-close {
appearance: none;
border: 0;
background: transparent;
color: var(--ink-mute);
border-radius: 8px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.assistant-bubble-close:hover {
background: var(--shade);
color: var(--ink);
}
.assistant-bubble-trigger {
width: 52px;
height: 52px;
border-radius: 999px;
border: 0;
background: var(--ink);
color: var(--paper);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow:
0 4px 14px rgba(31, 27, 22, 0.22),
0 1px 2px rgba(31, 27, 22, 0.18);
}
.assistant-bubble-trigger:hover {
background: var(--ink-2);
}
/* ── Buttons (used inside .topbar etc; <Button> in shadcn comes from
button.tsx and uses these tokens via the theme bridge) ───── */
.btn {
@@ -1430,6 +1510,65 @@ select {
animation: emojiFloat 1.8s ease-out both;
}
@keyframes journal-paper-out {
to {
opacity: 0;
transform: translateX(-28px) rotate(-1.2deg);
}
}
@keyframes journal-paper-in {
from {
opacity: 0;
transform: translateX(32px) rotate(0.8deg);
}
to {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
}
.journal-entries-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.journal-entries-stack[data-phase="exit"] .journal-entry-row {
animation: journal-paper-out 0.2s ease-in forwards;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
opacity: 0;
animation: journal-paper-in 0.26s ease-out forwards;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(1) {
animation-delay: 0ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(2) {
animation-delay: 45ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(3) {
animation-delay: 90ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(4) {
animation-delay: 135ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(5) {
animation-delay: 180ms;
}
@media (prefers-reduced-motion: reduce) {
.journal-entries-stack[data-phase="exit"] .journal-entry-row,
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
animation: none;
opacity: 1;
transform: none;
}
}
/* ── Calendar min-height CSS variable ───────────────────────────── */
/* Used by calendar-shell so the grid fills the viewport correctly */
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
+7 -3
View File
@@ -1,9 +1,13 @@
import { recordedDayKey } from "@/modules/journal/day-key";
import { Suspense } from "react";
import { JournalIndex } from "@/modules/journal/components/journal-index";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalPage() {
const entries = await listJournalEntries();
const entryDays = [...new Set(entries.map((entry) => recordedDayKey(entry.recordedAt)))];
return <JournalIndex entries={entries} entryDays={entryDays} />;
return (
<Suspense>
<JournalIndex entries={entries} />
</Suspense>
);
}
+8
View File
@@ -17,6 +17,8 @@ import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster";
import { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
import { isLlmConfigured } from "@/lib/llm";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -99,6 +101,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
let userDashboards: DashboardMeta[] = [];
let signedIn = false;
let assistantEnabled = false;
const session = await auth();
if (session?.user?.id) {
@@ -110,6 +113,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
themeFontPair: users.themeFontPair,
themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle,
assistantEnabled: users.assistantEnabled,
})
.from(users)
.where(eq(users.id, session.user.id))
@@ -120,6 +124,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
fontPair = row.themeFontPair as FontPair;
density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle;
assistantEnabled = row.assistantEnabled;
}
userDashboards = await db
.select({
@@ -172,6 +177,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<CommandPalette />
<InstallPrompt />
<PwaRegister />
{signedIn && assistantEnabled && session?.user?.id ? (
<AssistantBubble configured={isLlmConfigured()} userId={session.user.id} />
) : null}
<AppToaster position="bottom-right" />
</QuickAddProvider>
</body>
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { signIn } from "@/lib/auth";
import { createDevSession } from "@/lib/dev-login";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
export async function signInWithSso() {
await signIn("authentik", { redirectTo: "/" });
}
export async function devLogin() {
if (!isDevLoginEnabled()) {
throw new Error("Dev login is not enabled");
}
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}
+6 -32
View File
@@ -1,10 +1,7 @@
import { signIn } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login";
import { BrandMark } from "@/components/brand-mark";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { devLogin, signInWithSso } from "./actions";
export default function LoginPage() {
const devLoginEnabled = isDevLoginEnabled();
@@ -23,41 +20,18 @@ export default function LoginPage() {
</div>
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
<form
action={async () => {
"use server";
await signIn("authentik", { redirectTo: "/" });
}}
>
<form action={signInWithSso}>
<Button type="submit" size="lg" className="w-full">
Sign in with SSO
</Button>
</form>
{devLoginEnabled && (
<form
action={async () => {
"use server";
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
// trustHost, and proxy headers. Set both so the session is found
// regardless — this is dev-only code, correctness > elegance.
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}}
className="mt-3"
>
{devLoginEnabled ? (
<form action={devLogin} className="mt-3">
<Button type="submit" variant="outline" size="lg" className="w-full">
Dev login
</Button>
</form>
)}
) : null}
</div>
</main>
);
+14
View File
@@ -0,0 +1,14 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session";
export async function setAssistantEnabled(enabled: boolean): Promise<void> {
const { user } = await getCurrentSession();
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
revalidatePath("/settings");
revalidatePath("/", "layout");
}
+48 -23
View File
@@ -13,11 +13,23 @@ import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
import { ApiTokenSettings } from "@/components/api-token-settings";
import { AssistantOptIn } from "@/components/assistant-opt-in";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
import Link from "next/link";
import { NavIcon } from "@/components/nav-icon";
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus, KeyRound } from "lucide-react";
import {
Mail,
Globe,
History,
Sun,
Bell,
Pencil,
Lock,
Plus,
KeyRound,
MessageCircle,
} from "lucide-react";
const VALID_SECTIONS = new Set<SectionId>([
"household",
@@ -289,31 +301,44 @@ function AppearanceSection({
themeDashLayout: string;
themeCalView: string;
themeNavStyle: string;
assistantEnabled: boolean;
};
}) {
return (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<Sun className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<ThemePicker
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
initialMode={user.themeMode as "light" | "dark" | "system"}
initialFontPair={
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
}
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
initialCalView={user.themeCalView as "month" | "week" | "day"}
initialNavStyle={
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
}
signedIn
/>
</CardContent>
</Card>
<>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<Sun className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<ThemePicker
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
initialMode={user.themeMode as "light" | "dark" | "system"}
initialFontPair={
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
}
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
initialCalView={user.themeCalView as "month" | "week" | "day"}
initialNavStyle={
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
}
signedIn
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Assistant</CardTitle>
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<AssistantOptIn enabled={user.assistantEnabled} />
</CardContent>
</Card>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { setAssistantEnabled } from "@/app/settings/assistant-actions";
import { Switch } from "@/components/ui/switch";
export function AssistantOptIn({ enabled }: { enabled: boolean }) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
function toggle(next: boolean) {
startTransition(async () => {
await setAssistantEnabled(next);
router.refresh();
});
}
return (
<label className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">AI assistant</div>
<p className="muted text-[12px] mt-0.5">
Off by default. Turn on to show a chat bubble in the bottom-right corner.
</p>
</div>
<Switch
checked={enabled}
disabled={isPending}
onCheckedChange={toggle}
aria-label="AI assistant"
/>
</label>
);
}
+29 -7
View File
@@ -11,7 +11,12 @@ import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lu
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
import { computePresetLayoutFromMetas, widgetContentKey } from "@/lib/dashboard";
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions";
import {
saveDashboardLayout,
resetDashboardLayout,
syncEditorDraftLayout,
discardEditorDraftLayout,
} from "@/app/d/actions";
import { Button } from "@/components/ui/button";
import { WidgetPicker } from "./widget-picker";
@@ -58,8 +63,18 @@ export function DashboardEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.slug]);
function publishDraft(next: WidgetPlacement[]) {
startTransition(async () => {
await syncEditorDraftLayout(dashboard.id, { version: 1, widgets: next });
router.refresh();
});
}
function handleCancel() {
router.push(`/d/${dashboard.slug}`);
startTransition(async () => {
await discardEditorDraftLayout(dashboard.id);
router.push(`/d/${dashboard.slug}`);
});
}
function handleSave() {
@@ -89,26 +104,32 @@ export function DashboardEditor({
}
function removeWidget(index: number) {
setPlacements((current) => current.filter((_, i) => i !== index));
const next = placements.filter((_, i) => i !== index);
setPlacements(next);
setIsDirty(true);
publishDraft(next);
}
function addWidget(widgetId: string, config: unknown) {
const meta = widgetMetas.find((m) => m.id === widgetId);
if (!meta) return;
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
setPlacements((current) => [
...current,
const next = [
...placements,
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h },
]);
];
setPlacements(next);
setIsDirty(true);
setPickerOpen(false);
publishDraft(next);
}
function updateConfig(index: number, config: unknown) {
setPlacements((current) => current.map((p, i) => (i === index ? { ...p, config } : p)));
const next = placements.map((p, i) => (i === index ? { ...p, config } : p));
setPlacements(next);
setIsDirty(true);
setConfiguringIndex(null);
publishDraft(next);
}
function applyPreset(preset: PresetId) {
@@ -116,6 +137,7 @@ export function DashboardEditor({
setPlacements(next.widgets);
setIsDirty(true);
setPresetMenuOpen(false);
publishDraft(next.widgets);
}
const gridItems: Layout = placements.map((p, i) => ({
+3 -3
View File
@@ -1,5 +1,3 @@
"use client";
const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
@@ -18,7 +16,9 @@ export function DashboardGreeting({ firstName }: { firstName: string }) {
{greeting}
{firstName && `, ${firstName}`}.
</h1>
<p className="muted mt-1 text-[13px]">{today}</p>
<p className="muted mt-1 text-[13px]" suppressHydrationWarning>
{today}
</p>
</div>
);
}
+5
View File
@@ -6,6 +6,7 @@ import { CalendarCreateDialog } from "@/components/quick-add/dialogs/calendar-cr
import { CalendarEventCreateDialog } from "@/components/quick-add/dialogs/calendar-event-create-dialog";
import { ContainerCreateDialog } from "@/components/quick-add/dialogs/container-create-dialog";
import { GardenCareLogDialog } from "@/components/quick-add/dialogs/garden-care-log-dialog";
import { JournalEntryCreateDialog } from "@/components/quick-add/dialogs/journal-entry-create-dialog";
import { ListCreateDialog } from "@/components/quick-add/dialogs/list-create-dialog";
import { ListItemCreateDialog } from "@/components/quick-add/dialogs/list-item-create-dialog";
import { NoteCreateDialog } from "@/components/quick-add/dialogs/note-create-dialog";
@@ -29,6 +30,10 @@ export function QuickAddCreateHost() {
onOpenChange={onOpenChange}
/>
<NoteCreateDialog open={activeCreateKey === "notes.note"} onOpenChange={onOpenChange} />
<JournalEntryCreateDialog
open={activeCreateKey === "journal.entry"}
onOpenChange={onOpenChange}
/>
<ListItemCreateDialog
open={activeCreateKey === "lists.add-shopping"}
onOpenChange={onOpenChange}
@@ -1,5 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -20,8 +21,21 @@ import {
SelectValue,
} from "@/components/ui/select";
import { createEvent } from "@/modules/calendar/server/actions";
import { richTextToPlainText } from "@/components/rich-text";
import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-24 rounded-lg border border-input px-3 py-2 text-sm text-muted-foreground animate-pulse">
Loading editor
</div>
),
},
);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -30,7 +44,7 @@ type Props = {
export function CalendarEventCreateDialog({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-2xl">
{open ? <CalendarEventCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
@@ -71,7 +85,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
endAt: new Date(endAt),
allDay: false,
location: location || null,
notes: notes || null,
notes: richTextToPlainText(notes) ? notes : null,
remindMinutesBefore: remind ? 30 : null,
});
onDone();
@@ -146,13 +160,15 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-event-notes">Notes</Label>
<textarea
<RichTextEditor
id="qa-event-notes"
className="min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
aria-label="Notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
onChange={setNotes}
disabled={isPending}
placeholder="Add details…"
/>
</div>
<label className="flex items-center gap-2 text-sm">
@@ -0,0 +1,168 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { MoodPicker } from "@/modules/journal/components/mood-picker";
import { StressSlider } from "@/modules/journal/components/stress-slider";
import { createJournalEntry } from "@/modules/journal/server/actions";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-40 rounded-[var(--r-md)] border-[0.5px] px-3 py-2 text-sm muted animate-pulse">
Loading editor
</div>
),
},
);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function JournalEntryCreateDialog({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
{open ? <JournalEntryCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
);
}
function JournalEntryCreateForm({ onDone }: { onDone: () => void }) {
const router = useRouter();
const [recordedAt, setRecordedAt] = useState(toLocalDateTimeValue(null));
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [moods, setMoods] = useState<string[]>([]);
const [stress, setStress] = useState(5);
const [trackStress, setTrackStress] = useState(true);
const [pillsTaken, setPillsTaken] = useState(false);
const [trackPills, setTrackPills] = useState(true);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
await createJournalEntry({
recordedAt: new Date(recordedAt),
title: title.trim() || null,
body,
moods,
stress: trackStress ? stress : null,
pillsTaken: trackPills ? pillsTaken : null,
});
router.refresh();
onDone();
});
}
return (
<>
<DialogHeader>
<DialogTitle>New journal entry</DialogTitle>
</DialogHeader>
<form id="quick-add-journal-form" onSubmit={handleSubmit} className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="qa-journal-recorded">When</Label>
<Input
id="qa-journal-recorded"
type="datetime-local"
value={recordedAt}
onChange={(e) => setRecordedAt(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="qa-journal-title">Title</Label>
<Input
id="qa-journal-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="space-y-1.5">
<Label>Moods</Label>
<MoodPicker value={moods} onChange={setMoods} disabled={isPending} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="qa-journal-stress">Stress (110)</Label>
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>Track</span>
<Switch checked={trackStress} onCheckedChange={setTrackStress} />
</div>
</div>
<StressSlider
id="qa-journal-stress"
value={stress}
onChange={setStress}
disabled={!trackStress || isPending}
/>
{!trackStress ? (
<div className="text-[12px] text-muted-foreground">Not tracked</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="qa-journal-pills">Pills today</Label>
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>Track</span>
<Switch checked={trackPills} onCheckedChange={setTrackPills} />
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Switch
id="qa-journal-pills"
checked={pillsTaken}
disabled={!trackPills || isPending}
onCheckedChange={setPillsTaken}
/>
<span className="text-[13px]">{pillsTaken ? "Taken" : "Not taken"}</span>
</div>
</div>
</div>
<div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-journal-body">Reflection</Label>
<RichTextEditor
id="qa-journal-body"
aria-label="Reflection"
value={body}
onChange={setBody}
disabled={isPending}
placeholder="What happened today?"
/>
</div>
</form>
<DialogFooter>
<Button type="submit" form="quick-add-journal-form" disabled={isPending}>
Save entry
</Button>
</DialogFooter>
</>
);
}
function toLocalDateTimeValue(value: string | null) {
const date = value ? new Date(value) : new Date();
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
@@ -1,5 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -13,6 +14,18 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createNote } from "@/modules/notes/server/actions";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-32 rounded-lg border border-input px-3 py-2 text-sm text-muted-foreground animate-pulse">
Loading editor
</div>
),
},
);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -21,7 +34,7 @@ type Props = {
export function NoteCreateDialog({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-2xl">
{open ? <NoteCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
@@ -62,14 +75,15 @@ function NoteCreateForm({ onDone }: { onDone: () => void }) {
required
/>
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-note-body">Body</Label>
<textarea
<RichTextEditor
id="qa-note-body"
aria-label="Body"
className="min-h-32 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={body}
onChange={(e) => setBody(e.target.value)}
onChange={setBody}
disabled={isPending}
placeholder="Start writing…"
/>
</div>
<div className="space-y-1.5">
+5 -3
View File
@@ -103,10 +103,12 @@ export function WidgetPicker({
key={meta.id}
type="button"
onClick={() => selectWidget(meta.id)}
className="w-full text-left rounded-md px-3 py-2 hover:bg-accent transition-colors"
className="group w-full text-left rounded-md px-3 py-2 transition-colors hover:bg-[var(--shade)] focus-visible:bg-[var(--shade)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hair-2)]"
>
<p className="text-sm font-medium">{meta.title}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p>
<p className="text-sm font-medium text-[var(--ink)]">{meta.title}</p>
<p className="text-xs text-[var(--ink-mute)] group-hover:text-[var(--ink-2)] group-focus-visible:text-[var(--ink-2)]">
{meta.description}
</p>
</button>
))}
</div>
+13
View File
@@ -0,0 +1,13 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
export async function getAssistantEnabled(userId: string): Promise<boolean> {
const [row] = await db
.select({ assistantEnabled: users.assistantEnabled })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return row?.assistantEnabled ?? false;
}
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { cookies } from "next/headers";
import { parseDashboardLayout, type DashboardLayout } from "./dashboard";
function draftCookieName(dashboardId: string) {
return `dashboard-editor-draft-${dashboardId}`;
}
export async function readEditorDraftLayout(dashboardId: string): Promise<DashboardLayout | null> {
const jar = await cookies();
const raw = jar.get(draftCookieName(dashboardId))?.value;
if (!raw) return null;
try {
return parseDashboardLayout(JSON.parse(raw));
} catch {
return null;
}
}
export async function writeEditorDraftLayout(dashboardId: string, layout: DashboardLayout) {
const jar = await cookies();
jar.set(draftCookieName(dashboardId), JSON.stringify(layout), {
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60,
path: "/",
});
}
export async function clearEditorDraftLayout(dashboardId: string) {
const jar = await cookies();
jar.delete(draftCookieName(dashboardId));
}
+30 -1
View File
@@ -1,8 +1,9 @@
import "server-only";
import { getRegistry } from "@/modules/_core";
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import type { DashboardLayout, PresetId } from "./dashboard";
import type { DashboardLayout, PresetId, WidgetPlacement } from "./dashboard";
import { computePresetLayoutFromMetas } from "./dashboard";
import { getWidget } from "@/modules/_core";
/** Build a layout matching one of the design's three dashboard arrangements
* using the live module registry. Server-only the registry is empty on
@@ -29,3 +30,31 @@ function computePresetLayout(preset: PresetId): DashboardLayout {
export function computeDefaultLayout(): DashboardLayout {
return computePresetLayout("classic");
}
export function normalizeDashboardLayout(layout: DashboardLayout): DashboardLayout {
return {
version: layout.version,
widgets: layout.widgets.map((placement) => normalizeWidgetPlacement(placement)),
};
}
function normalizeWidgetPlacement(placement: WidgetPlacement): WidgetPlacement {
const widget = getWidget(placement.widgetId);
if (!widget) return placement;
const merged =
placement.config != null &&
typeof placement.config === "object" &&
!Array.isArray(placement.config)
? {
...(widget.defaultConfig as Record<string, unknown>),
...(placement.config as Record<string, unknown>),
}
: widget.defaultConfig;
const parsed = widget.configSchema.safeParse(merged);
return {
...placement,
config: parsed.success ? parsed.data : widget.defaultConfig,
};
}
+21 -1
View File
@@ -7,7 +7,27 @@ import * as notesSchema from "@/modules/notes/schema";
import * as gardenSchema from "@/modules/garden/schema";
import * as journalSchema from "@/modules/journal/schema";
const client = postgres(process.env["DATABASE_URL"]!);
type PostgresClient = ReturnType<typeof postgres>;
const globalForDb = globalThis as typeof globalThis & {
__famappPostgres?: PostgresClient;
};
function createClient(): PostgresClient {
const url = process.env["DATABASE_URL"];
if (!url) throw new Error("DATABASE_URL is not set");
return postgres(url, {
max: process.env.NODE_ENV === "production" ? 10 : 1,
idle_timeout: 20,
connect_timeout: 10,
});
}
const client = globalForDb.__famappPostgres ?? createClient();
if (process.env.NODE_ENV !== "production") {
globalForDb.__famappPostgres = client;
}
const schema = {
...coreSchema,
+1
View File
@@ -35,6 +35,7 @@ export const users = pgTable("users", {
notifPush: boolean("notif_push").notNull().default(true),
notifInApp: boolean("notif_inapp").notNull().default(true),
notifNtfy: boolean("notif_ntfy").notNull().default(false),
assistantEnabled: boolean("assistant_enabled").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
+133
View File
@@ -0,0 +1,133 @@
import { createHash, randomBytes } from "crypto";
import { and, eq, isNull } from "drizzle-orm";
import type { ApiAuthContext } from "@/lib/api-auth";
import { db } from "@/lib/db";
import { getEntityType, getRegistry } from "./registry";
import { shareLinks } from "./schema";
import { ensureEntityShareAuthorized } from "./share-authorization";
import type { ShareLinkCapabilities } from "./share";
export type CreateShareLinkResult = {
url: string;
token: string;
expiresAt: Date | null;
};
export type EntityShareLink = {
id: string;
entityType: string;
entityId: string;
createdAt: string;
expiresAt: string | null;
capabilities: ShareLinkCapabilities;
};
function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
function buildUrl(token: string): string {
const base =
process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000";
return `${base}/s/${token}`;
}
export function listShareableEntityTypes() {
return getRegistry()
.entityTypes.filter((entity) => entity.share?.canShare)
.map((entity) => ({
type: entity.type,
label: entity.label.singular,
defaultCapabilities: entity.share?.defaultCapabilities ?? ["read"],
}));
}
export async function createShareLinkForScope(
scope: ApiAuthContext,
entityType: string,
entityId: string,
opts: {
expiresAt?: Date | null;
capabilities?: Partial<ShareLinkCapabilities>;
} = {},
): Promise<CreateShareLinkResult> {
const registration = getEntityType(entityType);
if (!registration?.share?.canShare) {
throw new Error(`Entity type "${entityType}" is not shareable`);
}
if (!scope.userId) {
throw new Error("Share links require an authenticated user session");
}
await ensureEntityShareAuthorized(registration, entityId, {
householdId: scope.householdId,
userId: scope.userId,
});
const rawToken = randomBytes(32).toString("base64url");
const tokenHash = hashToken(rawToken);
const capabilities: ShareLinkCapabilities = {
read: opts.capabilities?.read ?? true,
write: opts.capabilities?.write ?? false,
};
await db.insert(shareLinks).values({
householdId: scope.householdId,
entityType,
entityId,
token: tokenHash,
capabilities,
createdBy: scope.userId,
expiresAt: opts.expiresAt ?? null,
});
return { url: buildUrl(rawToken), token: rawToken, expiresAt: opts.expiresAt ?? null };
}
export async function revokeShareLinkForScope(scope: ApiAuthContext, id: string): Promise<void> {
const [row] = await db
.select({ id: shareLinks.id })
.from(shareLinks)
.where(and(eq(shareLinks.id, id), eq(shareLinks.householdId, scope.householdId)))
.limit(1);
if (!row) throw new Error("Share link not found");
await db.update(shareLinks).set({ revokedAt: new Date() }).where(eq(shareLinks.id, id));
}
export async function listShareLinksForScope(
scope: ApiAuthContext,
filters?: { entityType?: string; entityId?: string },
): Promise<EntityShareLink[]> {
const now = new Date();
const conditions = [eq(shareLinks.householdId, scope.householdId), isNull(shareLinks.revokedAt)];
if (filters?.entityType) conditions.push(eq(shareLinks.entityType, filters.entityType));
if (filters?.entityId) conditions.push(eq(shareLinks.entityId, filters.entityId));
const rows = await db
.select({
id: shareLinks.id,
entityType: shareLinks.entityType,
entityId: shareLinks.entityId,
createdAt: shareLinks.createdAt,
expiresAt: shareLinks.expiresAt,
capabilities: shareLinks.capabilities,
})
.from(shareLinks)
.where(and(...conditions))
.orderBy(shareLinks.createdAt);
return rows
.filter((row) => !row.expiresAt || row.expiresAt > now)
.map((row) => ({
id: row.id,
entityType: row.entityType,
entityId: row.entityId,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt?.toISOString() ?? null,
capabilities: row.capabilities,
}));
}
@@ -0,0 +1,54 @@
export type AssistantChatMessage = {
role: "user" | "assistant";
content: string;
};
const STORAGE_VERSION = "v1";
const MAX_MESSAGES = 40;
function storageKey(userId: string) {
return `assistant-chat:${STORAGE_VERSION}:${userId}`;
}
function isValidMessage(value: unknown): value is AssistantChatMessage {
if (!value || typeof value !== "object") return false;
const row = value as Record<string, unknown>;
return (
(row.role === "user" || row.role === "assistant") &&
typeof row.content === "string" &&
row.content.trim().length > 0
);
}
export function loadAssistantChat(userId: string): AssistantChatMessage[] {
try {
const raw = localStorage.getItem(storageKey(userId));
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isValidMessage).slice(-MAX_MESSAGES);
} catch {
return [];
}
}
export function saveAssistantChat(userId: string, messages: AssistantChatMessage[]): void {
try {
const trimmed = messages.slice(-MAX_MESSAGES);
if (trimmed.length === 0) {
localStorage.removeItem(storageKey(userId));
return;
}
localStorage.setItem(storageKey(userId), JSON.stringify(trimmed));
} catch {
// Private browsing, quota exceeded, or disabled storage.
}
}
export function clearAssistantChat(userId: string): void {
try {
localStorage.removeItem(storageKey(userId));
} catch {
// Ignore storage errors.
}
}
@@ -0,0 +1,66 @@
import type { AgentProgressEvent } from "./server/progress";
type DoneEvent = Extract<AgentProgressEvent, { type: "done" }>;
export async function consumeAgentChatStream(
response: Response,
onEvent: (event: AgentProgressEvent) => void,
): Promise<DoneEvent> {
if (!response.ok) {
let message = "Assistant request failed";
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) message = payload.error;
} catch {
// ignore parse errors
}
throw new Error(message);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Assistant returned an empty stream");
const decoder = new TextDecoder();
let buffer = "";
let doneEvent: DoneEvent | null = null;
const handleEvent = (event: AgentProgressEvent) => {
onEvent(event);
if (event.type === "error") throw new Error(event.message);
if (event.type === "done") doneEvent = event;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const chunk = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
parseSseChunk(chunk, handleEvent);
boundary = buffer.indexOf("\n\n");
}
}
if (buffer.trim()) {
parseSseChunk(buffer, handleEvent);
}
if (!doneEvent) {
throw new Error("Assistant stream ended without a final response");
}
return doneEvent;
}
function parseSseChunk(chunk: string, onEvent: (event: AgentProgressEvent) => void) {
for (const line of chunk.split("\n")) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice("data: ".length);
if (!payload) continue;
onEvent(JSON.parse(payload) as AgentProgressEvent);
}
}
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { MessageCircle, X } from "lucide-react";
import { AssistantPanel } from "./assistant-panel";
type Props = {
configured: boolean;
userId: string;
};
export function AssistantBubble({ configured, userId }: Props) {
const [open, setOpen] = useState(false);
return (
<div className="assistant-bubble" data-open={open ? "true" : "false"}>
{open ? (
<div
className="assistant-bubble-panel"
role="dialog"
aria-label="Assistant"
aria-modal="false"
>
<div className="assistant-bubble-header">
<div>
<div className="serif text-[15px] font-medium tracking-tight">Assistant</div>
<div className="muted text-[11px]">Household helper</div>
</div>
<button
type="button"
className="assistant-bubble-close"
aria-label="Close assistant"
onClick={() => setOpen(false)}
>
<X className="size-4" />
</button>
</div>
<AssistantPanel key={userId} configured={configured} userId={userId} />
</div>
) : null}
<button
type="button"
className="assistant-bubble-trigger"
aria-label={open ? "Close assistant" : "Open assistant"}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
</button>
</div>
);
}
@@ -1,134 +0,0 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type ChatMessage = {
role: "user" | "assistant";
content: string;
};
type Props = {
configured: boolean;
};
export function AssistantChat({ configured }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const listRef = useRef<HTMLDivElement>(null);
function scrollToBottom() {
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight;
});
}
function sendMessage() {
const text = input.trim();
if (!text || isPending) return;
const nextMessages: ChatMessage[] = [...messages, { role: "user", content: text }];
setInput("");
setError(null);
setMessages(nextMessages);
scrollToBottom();
startTransition(async () => {
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages }),
});
const payload = (await response.json()) as {
error?: string;
message?: ChatMessage;
};
if (!response.ok) {
throw new Error(payload.error ?? "Assistant request failed");
}
if (!payload.message?.content) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [...current, payload.message!]);
scrollToBottom();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
}
});
}
return (
<div className="mx-auto flex w-full max-w-2xl min-h-[70vh] flex-col gap-4 min-w-0">
<div>
<h2 className="serif text-[22px] tracking-tight">Assistant</h2>
<p className="muted text-[13px] mt-1">
{configured
? "Connected to your homelab LLM. I can update lists, calendar, notes, and journal via the API."
: "LLM endpoint not configured — using the built-in mock provider for testing."}
</p>
</div>
<div
ref={listRef}
className="flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-4 min-h-[320px] max-h-[60vh]"
style={{ borderColor: "var(--hair)" }}
>
{messages.length === 0 ? (
<p className="muted text-[13px]">
Try &quot;add milk to the shopping list&quot; or &quot;what&apos;s on the calendar this
week?&quot;
</p>
) : (
<div className="grid gap-3">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg px-3 py-2 text-[13px] leading-relaxed whitespace-pre-wrap ${
message.role === "user"
? "ml-8 bg-[var(--shade)]"
: "mr-8 border-[0.5px] bg-[var(--card)]"
}`}
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-1">{message.role === "user" ? "You" : "Assistant"}</div>
{message.content}
</div>
))}
</div>
)}
</div>
{error ? <p className="text-[13px] text-destructive">{error}</p> : null}
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
sendMessage();
}}
>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…"
disabled={isPending}
aria-label="Message"
/>
<Button type="submit" disabled={isPending || !input.trim()}>
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
Send
</Button>
</form>
</div>
);
}
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { consumeAgentChatStream } from "../assistant-chat-stream";
import {
clearAssistantChat,
loadAssistantChat,
saveAssistantChat,
type AssistantChatMessage,
} from "../assistant-chat-storage";
type Props = {
configured: boolean;
userId: string;
};
export function AssistantPanel({ configured, userId }: Props) {
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [activityLabel, setActivityLabel] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
saveAssistantChat(userId, messages);
}, [messages, userId]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
function scrollToBottom() {
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight;
});
}
function clearChat() {
abortRef.current?.abort();
setMessages([]);
setError(null);
setActivityLabel(null);
setIsPending(false);
clearAssistantChat(userId);
}
async function sendMessage() {
const text = input.trim();
if (!text || isPending) return;
const nextMessages: AssistantChatMessage[] = [...messages, { role: "user", content: text }];
setInput("");
setError(null);
setMessages(nextMessages);
setIsPending(true);
setActivityLabel("Understanding your request…");
scrollToBottom();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages, stream: true }),
signal: controller.signal,
});
const result = await consumeAgentChatStream(response, (event) => {
if (event.type === "thinking" || event.type === "tool" || event.type === "responding") {
setActivityLabel(event.label);
scrollToBottom();
}
});
if (!result.message.content) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [...current, result.message]);
scrollToBottom();
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsPending(false);
setActivityLabel(null);
abortRef.current = null;
}
}
const showEmptyState = messages.length === 0 && !isPending;
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<p className="muted min-w-0 text-[12px] leading-relaxed">
{configured
? "Ask me to update lists, calendar, notes, or journal."
: "Mock provider active — set LLM_BASE_URL for your homelab model."}
</p>
{messages.length > 0 ? (
<button
type="button"
onClick={clearChat}
disabled={isPending}
className="shrink-0 text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Clear
</button>
) : null}
</div>
<div
ref={listRef}
className="min-h-0 flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--shade)] p-3"
style={{ borderColor: "var(--hair)" }}
>
{showEmptyState ? (
<p className="muted text-[12px]">
Try &quot;add milk to the shopping list&quot; or &quot;what&apos;s on the calendar this
week?&quot;
</p>
) : (
<div className="grid gap-2">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg px-2.5 py-2 text-[12.5px] leading-relaxed whitespace-pre-wrap ${
message.role === "user"
? "ml-6 bg-[var(--card)]"
: "mr-6 border-[0.5px] bg-[var(--card)]"
}`}
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-0.5 text-[10px]">
{message.role === "user" ? "You" : "Assistant"}
</div>
{message.content}
</div>
))}
{isPending ? (
<div
className="mr-6 rounded-lg border-[0.5px] bg-[var(--card)] px-2.5 py-2"
style={{ borderColor: "var(--hair)" }}
aria-live="polite"
aria-busy="true"
>
<div className="eyebrow mb-1 text-[10px]">Assistant</div>
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{activityLabel ?? "Working…"}</span>
</div>
</div>
) : null}
</div>
)}
</div>
{error ? <p className="text-[12px] text-destructive">{error}</p> : null}
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
sendMessage();
}}
>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…"
disabled={isPending}
aria-label="Assistant message"
className="h-9"
/>
<Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send">
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
</Button>
</form>
</div>
);
}
-1
View File
@@ -3,7 +3,6 @@ import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "agent",
name: "Assistant",
nav: { href: "/assistant", label: "Assistant", icon: "message-circle" },
entities: [],
};
+19
View File
@@ -0,0 +1,19 @@
export type AgentProgressEvent =
| { type: "thinking"; label: string; round: number }
| { type: "tool"; name: string; label: string }
| { type: "responding"; label: string }
| {
type: "done";
message: { role: "assistant"; content: string };
toolCalls: { name: string; status: number }[];
}
| { type: "error"; message: string };
export function thinkingLabel(round: number): string {
if (round === 0) return "Understanding your request…";
return "Reviewing what I found…";
}
export function encodeSseEvent(event: AgentProgressEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
+13
View File
@@ -1,6 +1,8 @@
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
import { describeToolActivity } from "../tool-labels";
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
import { thinkingLabel, type AgentProgressEvent } from "./progress";
const MAX_TOOL_ROUNDS = 8;
@@ -19,14 +21,18 @@ export type AgentChatResult = {
toolCalls: AgentToolCallSummary[];
};
export type AgentProgressHandler = (event: AgentProgressEvent) => void;
export async function runAgentChat(options: {
messages: ClientChatMessage[];
request: Request;
llm?: LlmClient;
executeTool?: ToolExecutor;
onProgress?: AgentProgressHandler;
}): Promise<AgentChatResult> {
const llm = options.llm ?? createLlmClient();
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const onProgress = options.onProgress;
const transcript: ChatMessage[] = [
{ role: "system", content: AGENT_SYSTEM_PROMPT },
@@ -41,6 +47,8 @@ export async function runAgentChat(options: {
const toolCalls: AgentToolCallSummary[] = [];
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
onProgress?.({ type: "thinking", label: thinkingLabel(round), round });
const completion = await llm.chatCompletion({
messages: transcript,
tools: AGENT_TOOLS,
@@ -50,6 +58,7 @@ export async function runAgentChat(options: {
transcript.push(assistantMessage);
if (!assistantMessage.tool_calls?.length) {
onProgress?.({ type: "responding", label: "Writing a reply…" });
return {
message: {
role: "assistant",
@@ -60,6 +69,9 @@ export async function runAgentChat(options: {
}
for (const toolCall of assistantMessage.tool_calls) {
const label = describeToolActivity(toolCall.function.name, toolCall.function.arguments);
onProgress?.({ type: "tool", name: toolCall.function.name, label });
let result: string;
let status: number;
@@ -86,6 +98,7 @@ export async function runAgentChat(options: {
}
}
onProgress?.({ type: "responding", label: "Wrapping up…" });
return {
message: {
role: "assistant",
+355 -6
View File
@@ -66,6 +66,38 @@ async function dispatchTool(
if (typeof args.qty === "string") body.qty = args.qty;
return callApi(request, origin, "POST", `/api/v1/lists/${listId}/items`, body);
}
case "update_list_item": {
const listId = requireString(args, "listId");
const itemId = requireString(args, "itemId");
const body: Record<string, unknown> = {};
if (typeof args.done === "boolean") body.done = args.done;
if (typeof args.text === "string") body.text = args.text;
if (typeof args.qty === "string") body.qty = args.qty;
return callApi(request, origin, "PATCH", `/api/v1/lists/${listId}/items/${itemId}`, body);
}
case "delete_list_item": {
const listId = requireString(args, "listId");
const itemId = requireString(args, "itemId");
return callApi(request, origin, "DELETE", `/api/v1/lists/${listId}/items/${itemId}`);
}
case "create_list": {
const body = {
type: requireString(args, "type"),
name: requireString(args, "name"),
};
return callApi(request, origin, "POST", "/api/v1/lists", body);
}
case "update_list": {
const listId = requireString(args, "listId");
const body: Record<string, unknown> = {};
if (typeof args.name === "string") body.name = args.name;
if (typeof args.archived === "boolean") body.archived = args.archived;
return callApi(request, origin, "PATCH", `/api/v1/lists/${listId}`, body);
}
case "delete_list": {
const listId = requireString(args, "listId");
return callApi(request, origin, "DELETE", `/api/v1/lists/${listId}`);
}
case "list_calendars":
return callApi(request, origin, "GET", "/api/v1/calendars");
case "list_events": {
@@ -79,29 +111,265 @@ async function dispatchTool(
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
}
case "create_event": {
const body = {
const body: Record<string, unknown> = {
calendarId: requireString(args, "calendarId"),
title: requireString(args, "title"),
startAt: requireString(args, "startAt"),
endAt: requireString(args, "endAt"),
allDay: typeof args.allDay === "boolean" ? args.allDay : false,
location: typeof args.location === "string" ? args.location : undefined,
notes: typeof args.notes === "string" ? args.notes : undefined,
};
if (typeof args.location === "string") body.location = args.location;
if (typeof args.notes === "string") body.notes = args.notes;
if (typeof args.remindMinutesBefore === "number") {
body.remindMinutesBefore = args.remindMinutesBefore;
}
return callApi(request, origin, "POST", "/api/v1/events", body);
}
case "update_event": {
const eventId = requireString(args, "eventId");
const body: Record<string, unknown> = {};
if (typeof args.title === "string") body.title = args.title;
if (typeof args.startAt === "string") body.startAt = args.startAt;
if (typeof args.endAt === "string") body.endAt = args.endAt;
if (typeof args.allDay === "boolean") body.allDay = args.allDay;
if (typeof args.location === "string") body.location = args.location;
if (typeof args.notes === "string") body.notes = args.notes;
if (typeof args.remindMinutesBefore === "number") {
body.remindMinutesBefore = args.remindMinutesBefore;
}
return callApi(request, origin, "PATCH", `/api/v1/events/${eventId}`, body);
}
case "delete_event": {
const eventId = requireString(args, "eventId");
return callApi(request, origin, "DELETE", `/api/v1/events/${eventId}`);
}
case "create_calendar": {
const body: Record<string, unknown> = {
name: requireString(args, "name"),
};
if (typeof args.color === "string") body.color = args.color;
if (args.visibility === "private" || args.visibility === "household") {
body.visibility = args.visibility;
}
return callApi(request, origin, "POST", "/api/v1/calendars", body);
}
case "list_notes":
return callApi(request, origin, "GET", "/api/v1/notes");
case "create_note": {
const body = {
const body: Record<string, unknown> = {
title: requireString(args, "title"),
body: typeof args.body === "string" ? args.body : "",
pinned: typeof args.pinned === "boolean" ? args.pinned : false,
};
if (typeof args.remindAt === "string") body.remindAt = args.remindAt;
return callApi(request, origin, "POST", "/api/v1/notes", body);
}
case "list_journal_entries":
return callApi(request, origin, "GET", "/api/v1/journal/entries");
case "update_note": {
const noteId = requireString(args, "noteId");
const body: Record<string, unknown> = {};
if (typeof args.title === "string") body.title = args.title;
if (typeof args.body === "string") body.body = args.body;
if (typeof args.pinned === "boolean") body.pinned = args.pinned;
if (typeof args.remindAt === "string") body.remindAt = args.remindAt;
if (args.remindAt === null) body.remindAt = null;
return callApi(request, origin, "PATCH", `/api/v1/notes/${noteId}`, body);
}
case "delete_note": {
const noteId = requireString(args, "noteId");
return callApi(request, origin, "DELETE", `/api/v1/notes/${noteId}`);
}
case "list_journal_entries": {
const limit = typeof args.limit === "number" ? String(args.limit) : undefined;
const qs = limit ? `?limit=${encodeURIComponent(limit)}` : "";
return callApi(request, origin, "GET", `/api/v1/journal/entries${qs}`);
}
case "create_journal_entry": {
const body: Record<string, unknown> = {
recordedAt: requireString(args, "recordedAt"),
};
if (typeof args.title === "string") body.title = args.title;
if (typeof args.body === "string") body.body = args.body;
if (Array.isArray(args.moods)) body.moods = args.moods;
if (typeof args.stress === "number") body.stress = args.stress;
if (typeof args.pillsTaken === "boolean") body.pillsTaken = args.pillsTaken;
return callApi(request, origin, "POST", "/api/v1/journal/entries", body);
}
case "update_journal_entry": {
const entryId = requireString(args, "entryId");
const body: Record<string, unknown> = {};
if (typeof args.recordedAt === "string") body.recordedAt = args.recordedAt;
if (typeof args.title === "string") body.title = args.title;
if (typeof args.body === "string") body.body = args.body;
if (Array.isArray(args.moods)) body.moods = args.moods;
if (typeof args.stress === "number") body.stress = args.stress;
if (typeof args.pillsTaken === "boolean") body.pillsTaken = args.pillsTaken;
return callApi(request, origin, "PATCH", `/api/v1/journal/entries/${entryId}`, body);
}
case "delete_journal_entry": {
const entryId = requireString(args, "entryId");
return callApi(request, origin, "DELETE", `/api/v1/journal/entries/${entryId}`);
}
case "list_bangs": {
const limit = typeof args.limit === "number" ? String(args.limit) : undefined;
const qs = limit ? `?limit=${encodeURIComponent(limit)}` : "";
return callApi(request, origin, "GET", `/api/v1/bangs${qs}`);
}
case "add_bang": {
const body: Record<string, unknown> = {};
if (typeof args.occurredOn === "string") body.occurredOn = args.occurredOn;
return callApi(request, origin, "POST", "/api/v1/bangs", body);
}
case "update_bang": {
const bangId = requireString(args, "bangId");
const body = { occurredOn: requireString(args, "occurredOn") };
return callApi(request, origin, "PATCH", `/api/v1/bangs/${bangId}`, body);
}
case "delete_bang": {
const bangId = requireString(args, "bangId");
return callApi(request, origin, "DELETE", `/api/v1/bangs/${bangId}`);
}
case "list_garden_containers":
return callApi(request, origin, "GET", "/api/v1/garden/containers");
case "list_garden_plants": {
const containerId = typeof args.containerId === "string" ? args.containerId : undefined;
const qs = containerId ? `?containerId=${encodeURIComponent(containerId)}` : "";
return callApi(request, origin, "GET", `/api/v1/garden/plants${qs}`);
}
case "get_garden_plant": {
const plantId = requireString(args, "plantId");
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}`);
}
case "create_garden_container": {
const body: Record<string, unknown> = { name: requireString(args, "name") };
if (typeof args.type === "string") body.type = args.type;
if (typeof args.locationNotes === "string") body.locationNotes = args.locationNotes;
return callApi(request, origin, "POST", "/api/v1/garden/containers", body);
}
case "update_garden_container": {
const containerId = requireString(args, "containerId");
const body: Record<string, unknown> = {};
if (typeof args.name === "string") body.name = args.name;
if (typeof args.type === "string") body.type = args.type;
if (typeof args.locationNotes === "string") body.locationNotes = args.locationNotes;
return callApi(request, origin, "PATCH", `/api/v1/garden/containers/${containerId}`, body);
}
case "delete_garden_container": {
const containerId = requireString(args, "containerId");
return callApi(request, origin, "DELETE", `/api/v1/garden/containers/${containerId}`);
}
case "create_garden_plant": {
const body: Record<string, unknown> = { name: requireString(args, "name") };
if (typeof args.category === "string") body.category = args.category;
if (typeof args.containerId === "string") body.containerId = args.containerId;
if (typeof args.healthStatus === "string") body.healthStatus = args.healthStatus;
if (typeof args.notes === "string") body.notes = args.notes;
return callApi(request, origin, "POST", "/api/v1/garden/plants", body);
}
case "update_garden_plant": {
const plantId = requireString(args, "plantId");
const body: Record<string, unknown> = {};
if (typeof args.name === "string") body.name = args.name;
if (typeof args.category === "string") body.category = args.category;
if (typeof args.containerId === "string") body.containerId = args.containerId;
if (typeof args.healthStatus === "string") body.healthStatus = args.healthStatus;
if (typeof args.notes === "string") body.notes = args.notes;
return callApi(request, origin, "PATCH", `/api/v1/garden/plants/${plantId}`, body);
}
case "delete_garden_plant": {
const plantId = requireString(args, "plantId");
return callApi(request, origin, "DELETE", `/api/v1/garden/plants/${plantId}`);
}
case "log_garden_care": {
const plantId = requireString(args, "plantId");
const body: Record<string, unknown> = { careType: requireString(args, "careType") };
if (typeof args.notes === "string") body.notes = args.notes;
if (typeof args.performedAt === "string") body.performedAt = args.performedAt;
return callApi(request, origin, "POST", `/api/v1/garden/plants/${plantId}/care-logs`, body);
}
case "list_garden_care_logs": {
const plantId = requireString(args, "plantId");
const limit = typeof args.limit === "number" ? `?limit=${args.limit}` : "";
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}/care-logs${limit}`);
}
case "list_garden_care_schedules": {
const plantId = requireString(args, "plantId");
return callApi(request, origin, "GET", `/api/v1/garden/plants/${plantId}/care-schedules`);
}
case "upsert_garden_care_schedule": {
const plantId = requireString(args, "plantId");
const body: Record<string, unknown> = {
careType: requireString(args, "careType"),
intervalDays: requireNumber(args, "intervalDays"),
};
if (typeof args.enabled === "boolean") body.enabled = args.enabled;
return callApi(
request,
origin,
"POST",
`/api/v1/garden/plants/${plantId}/care-schedules`,
body,
);
}
case "delete_garden_care_schedule": {
const scheduleId = requireString(args, "scheduleId");
return callApi(request, origin, "DELETE", `/api/v1/garden/care-schedules/${scheduleId}`);
}
case "toggle_garden_care_schedule": {
const scheduleId = requireString(args, "scheduleId");
const body = { enabled: requireBoolean(args, "enabled") };
return callApi(request, origin, "PATCH", `/api/v1/garden/care-schedules/${scheduleId}`, body);
}
case "push_overdue_garden_care":
return callApi(request, origin, "POST", "/api/v1/garden/overdue-care/push");
case "schedule_garden_care_on_calendar": {
const scheduleId = requireString(args, "scheduleId");
const body: Record<string, unknown> = {
calendarId: requireString(args, "calendarId"),
};
if (typeof args.reminderMinutesBefore === "number") {
body.reminderMinutesBefore = args.reminderMinutesBefore;
}
return callApi(
request,
origin,
"POST",
`/api/v1/garden/care-schedules/${scheduleId}/calendar`,
body,
);
}
case "list_shareable_entity_types":
return callApi(request, origin, "GET", "/api/v1/share-links?entityTypes=true");
case "list_share_links": {
const qs = new URLSearchParams();
if (typeof args.entityType === "string") qs.set("entityType", args.entityType);
if (typeof args.entityId === "string") qs.set("entityId", args.entityId);
const query = qs.toString();
return callApi(
request,
origin,
"GET",
query ? `/api/v1/share-links?${query}` : "/api/v1/share-links",
);
}
case "create_share_link": {
const body: Record<string, unknown> = {
entityType: requireString(args, "entityType"),
entityId: requireString(args, "entityId"),
};
if (typeof args.expiresAt === "string") body.expiresAt = args.expiresAt;
if (typeof args.write === "boolean") {
body.capabilities = { write: args.write };
}
return callApi(request, origin, "POST", "/api/v1/share-links", body);
}
case "revoke_share_link": {
const linkId = requireString(args, "linkId");
return callApi(request, origin, "DELETE", `/api/v1/share-links/${linkId}`);
}
case "get_api_docs":
return getApiDocs(args);
case "call_api":
return callApiFallback(args, request, origin);
default:
return { status: 400, body: { error: `Unknown tool: ${name}` } };
}
@@ -115,6 +383,87 @@ function requireString(args: Record<string, unknown>, key: string): string {
return value;
}
function requireNumber(args: Record<string, unknown>, key: string): number {
const value = args[key];
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`Missing required argument: ${key}`);
}
return value;
}
function requireBoolean(args: Record<string, unknown>, key: string): boolean {
const value = args[key];
if (typeof value !== "boolean") {
throw new Error(`Missing required argument: ${key}`);
}
return value;
}
async function getApiDocs(args: Record<string, unknown>): Promise<ApiCallResult> {
const { readFile } = await import("fs/promises");
const path = await import("path");
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
const spec = await readFile(specPath, "utf8");
const search = typeof args.search === "string" ? args.search.trim().toLowerCase() : "";
if (!search) {
return {
status: 200,
body: {
spec,
hint: "Pass search to filter paths, or use call_api with a /api/v1/* path.",
},
};
}
const lines = spec.split("\n");
const matches = lines.filter((line) => line.toLowerCase().includes(search));
return {
status: 200,
body: {
search,
matchCount: matches.length,
matches: matches.slice(0, 100),
hint: "Use call_api with method and path from the matches above.",
},
};
}
async function callApiFallback(
args: Record<string, unknown>,
request: Request,
origin: string,
): Promise<ApiCallResult> {
const method = requireString(args, "method").toUpperCase();
if (!["GET", "POST", "PATCH", "DELETE"].includes(method)) {
return { status: 400, body: { error: "method must be GET, POST, PATCH, or DELETE" } };
}
let apiPath = requireString(args, "path");
if (!apiPath.startsWith("/api/v1/")) {
return { status: 400, body: { error: "path must start with /api/v1/" } };
}
if (apiPath.includes("..")) {
return { status: 400, body: { error: "invalid path" } };
}
if (typeof args.query === "object" && args.query !== null && !Array.isArray(args.query)) {
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(args.query as Record<string, unknown>)) {
if (value !== undefined && value !== null) qs.set(key, String(value));
}
const query = qs.toString();
if (query) apiPath += `?${query}`;
}
const body =
typeof args.body === "object" && args.body !== null && !Array.isArray(args.body)
? (args.body as Record<string, unknown>)
: undefined;
return callApi(request, origin, method, apiPath, body);
}
async function callApi(
request: Request,
origin: string,
+134
View File
@@ -0,0 +1,134 @@
function parseArgs(argsJson: string): Record<string, unknown> {
if (!argsJson.trim()) return {};
try {
const parsed: unknown = JSON.parse(argsJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// ignore malformed tool args in UI copy
}
return {};
}
function str(args: Record<string, unknown>, key: string): string | undefined {
const value = args[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function describeToolActivity(name: string, argsJson = ""): string {
const args = parseArgs(argsJson);
switch (name) {
case "list_lists":
return str(args, "type") ? `Looking up your ${args.type} lists…` : "Looking up your lists…";
case "list_list_items":
return "Reading list items…";
case "add_list_item": {
const text = str(args, "text");
return text ? `Adding “${text}” to a list…` : "Adding an item to a list…";
}
case "update_list_item":
return typeof args.done === "boolean" && args.done
? "Marking a list item complete…"
: "Updating a list item…";
case "delete_list_item":
return "Removing a list item…";
case "create_list":
return str(args, "name") ? `Creating list “${args.name}”…` : "Creating a new list…";
case "update_list":
return "Updating a list…";
case "delete_list":
return "Deleting a list…";
case "list_calendars":
return "Looking up calendars…";
case "list_events":
return "Checking calendar events…";
case "create_event":
return str(args, "title") ? `Creating event “${args.title}”…` : "Creating a calendar event…";
case "update_event":
return "Updating a calendar event…";
case "delete_event":
return "Deleting a calendar event…";
case "create_calendar":
return str(args, "name") ? `Creating calendar “${args.name}”…` : "Creating a calendar…";
case "list_notes":
return "Looking up notes…";
case "create_note":
return str(args, "title") ? `Creating note “${args.title}”…` : "Creating a note…";
case "update_note":
return "Updating a note…";
case "delete_note":
return "Deleting a note…";
case "list_journal_entries":
return "Reading journal entries…";
case "create_journal_entry":
return "Saving a journal entry…";
case "update_journal_entry":
return "Updating a journal entry…";
case "delete_journal_entry":
return "Deleting a journal entry…";
case "list_bangs":
return "Checking bang counter…";
case "add_bang":
return "Recording a bang…";
case "update_bang":
return "Updating a bang entry…";
case "delete_bang":
return "Deleting a bang entry…";
case "list_garden_containers":
return "Looking up garden containers…";
case "list_garden_plants":
return "Looking up plants…";
case "get_garden_plant":
return "Loading plant details…";
case "create_garden_container":
return str(args, "name") ? `Adding container “${args.name}”…` : "Adding a garden container…";
case "update_garden_container":
return "Updating a garden container…";
case "delete_garden_container":
return "Removing a garden container…";
case "create_garden_plant":
return str(args, "name") ? `Adding plant “${args.name}”…` : "Adding a plant…";
case "update_garden_plant":
return "Updating a plant…";
case "delete_garden_plant":
return "Removing a plant…";
case "log_garden_care": {
const care = str(args, "careType");
return care ? `Logging ${care} care…` : "Logging plant care…";
}
case "list_garden_care_logs":
return "Reading care history…";
case "list_garden_care_schedules":
return "Checking care schedules…";
case "upsert_garden_care_schedule":
return "Updating a care schedule…";
case "delete_garden_care_schedule":
return "Removing a care schedule…";
case "toggle_garden_care_schedule":
return "Toggling a care schedule…";
case "push_overdue_garden_care":
return "Pushing overdue garden tasks…";
case "schedule_garden_care_on_calendar":
return "Adding garden care to calendar…";
case "list_shareable_entity_types":
return "Checking what can be shared…";
case "list_share_links":
return "Looking up share links…";
case "create_share_link":
return "Creating a share link…";
case "revoke_share_link":
return "Revoking a share link…";
case "get_api_docs":
return str(args, "search")
? `Reading API docs for “${args.search}”…`
: "Reading API documentation…";
case "call_api": {
const path = str(args, "path");
return path ? `Calling ${path}` : "Calling the API…";
}
default:
return `Running ${name.replaceAll("_", " ")}`;
}
}
+640 -2
View File
@@ -1,4 +1,7 @@
import type { AgentToolDefinition } from "@/lib/llm";
import { MOOD_CATALOG } from "@/modules/journal/mood-catalog";
const JOURNAL_MOOD_IDS = MOOD_CATALOG.map((mood) => mood.id).join(", ");
export const AGENT_TOOLS: AgentToolDefinition[] = [
{
@@ -46,6 +49,85 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
},
},
},
{
type: "function",
function: {
name: "update_list_item",
description:
"Update a list item — mark done/undone, rename, or change quantity. Use list_list_items first to resolve itemId.",
parameters: {
type: "object",
properties: {
listId: { type: "string", description: "UUID of the list" },
itemId: { type: "string", description: "UUID of the item" },
done: { type: "boolean", description: "Mark completed (true) or open (false)" },
text: { type: "string", description: "New item text" },
qty: { type: "string", description: "New quantity" },
},
required: ["listId", "itemId"],
},
},
},
{
type: "function",
function: {
name: "delete_list_item",
description: "Remove an item from a list permanently.",
parameters: {
type: "object",
properties: {
listId: { type: "string", description: "UUID of the list" },
itemId: { type: "string", description: "UUID of the item" },
},
required: ["listId", "itemId"],
},
},
},
{
type: "function",
function: {
name: "create_list",
description: "Create a new list (e.g. type shopping or tasks).",
parameters: {
type: "object",
properties: {
type: { type: "string", description: "List type, e.g. shopping or tasks" },
name: { type: "string", description: "Display name" },
},
required: ["type", "name"],
},
},
},
{
type: "function",
function: {
name: "update_list",
description: "Rename a list or archive/unarchive it.",
parameters: {
type: "object",
properties: {
listId: { type: "string" },
name: { type: "string" },
archived: { type: "boolean" },
},
required: ["listId"],
},
},
},
{
type: "function",
function: {
name: "delete_list",
description: "Permanently delete a list and all its items.",
parameters: {
type: "object",
properties: {
listId: { type: "string" },
},
required: ["listId"],
},
},
},
{
type: "function",
function: {
@@ -88,11 +170,66 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
allDay: { type: "boolean" },
location: { type: "string" },
notes: { type: "string" },
remindMinutesBefore: {
type: "number",
description: "Optional reminder N minutes before start",
},
},
required: ["calendarId", "title", "startAt", "endAt"],
},
},
},
{
type: "function",
function: {
name: "update_event",
description: "Update or reschedule a calendar event.",
parameters: {
type: "object",
properties: {
eventId: { type: "string" },
title: { type: "string" },
startAt: { type: "string" },
endAt: { type: "string" },
allDay: { type: "boolean" },
location: { type: "string" },
notes: { type: "string" },
remindMinutesBefore: { type: "number" },
},
required: ["eventId"],
},
},
},
{
type: "function",
function: {
name: "delete_event",
description: "Delete a calendar event.",
parameters: {
type: "object",
properties: {
eventId: { type: "string" },
},
required: ["eventId"],
},
},
},
{
type: "function",
function: {
name: "create_calendar",
description: "Create a new calendar.",
parameters: {
type: "object",
properties: {
name: { type: "string" },
color: { type: "string" },
visibility: { type: "string", description: "private or household" },
},
required: ["name"],
},
},
},
{
type: "function",
function: {
@@ -112,11 +249,44 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
title: { type: "string" },
body: { type: "string" },
pinned: { type: "boolean" },
remindAt: { type: "string", description: "ISO 8601 reminder datetime, or null to clear" },
},
required: ["title"],
},
},
},
{
type: "function",
function: {
name: "update_note",
description: "Update a note's title, body, pin state, or reminder.",
parameters: {
type: "object",
properties: {
noteId: { type: "string" },
title: { type: "string" },
body: { type: "string" },
pinned: { type: "boolean" },
remindAt: { type: "string", description: "ISO 8601 datetime or null" },
},
required: ["noteId"],
},
},
},
{
type: "function",
function: {
name: "delete_note",
description: "Delete a note.",
parameters: {
type: "object",
properties: {
noteId: { type: "string" },
},
required: ["noteId"],
},
},
},
{
type: "function",
function: {
@@ -130,10 +300,478 @@ export const AGENT_TOOLS: AgentToolDefinition[] = [
},
},
},
{
type: "function",
function: {
name: "create_journal_entry",
description:
"Create a journal entry for the current user. Supports moods, stress (1-10), and pillsTaken.",
parameters: {
type: "object",
properties: {
recordedAt: { type: "string", description: "ISO 8601 date/time for the entry" },
title: { type: "string" },
body: { type: "string" },
moods: {
type: "array",
items: { type: "string" },
description: `Mood ids: ${JOURNAL_MOOD_IDS}`,
},
stress: { type: "number", description: "Stress level 1-10" },
pillsTaken: { type: "boolean", description: "Whether pills were taken" },
},
required: ["recordedAt"],
},
},
},
{
type: "function",
function: {
name: "update_journal_entry",
description: "Update a journal entry including moods, stress, and pillsTaken.",
parameters: {
type: "object",
properties: {
entryId: { type: "string" },
recordedAt: { type: "string" },
title: { type: "string" },
body: { type: "string" },
moods: {
type: "array",
items: { type: "string" },
description: `Mood ids: ${JOURNAL_MOOD_IDS}`,
},
stress: { type: "number" },
pillsTaken: { type: "boolean" },
},
required: ["entryId"],
},
},
},
{
type: "function",
function: {
name: "delete_journal_entry",
description: "Delete a journal entry.",
parameters: {
type: "object",
properties: {
entryId: { type: "string" },
},
required: ["entryId"],
},
},
},
{
type: "function",
function: {
name: "list_bangs",
description: "Get bang counter stats and recent entries for the household.",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "Recent entries to include (default 10)" },
},
},
},
},
{
type: "function",
function: {
name: "add_bang",
description: "Record a new bang. Date defaults to today if omitted.",
parameters: {
type: "object",
properties: {
occurredOn: { type: "string", description: "YYYY-MM-DD" },
},
},
},
},
{
type: "function",
function: {
name: "update_bang",
description: "Change the date of an existing bang entry.",
parameters: {
type: "object",
properties: {
bangId: { type: "string" },
occurredOn: { type: "string", description: "YYYY-MM-DD" },
},
required: ["bangId", "occurredOn"],
},
},
},
{
type: "function",
function: {
name: "delete_bang",
description: "Delete a bang entry.",
parameters: {
type: "object",
properties: {
bangId: { type: "string" },
},
required: ["bangId"],
},
},
},
{
type: "function",
function: {
name: "list_garden_containers",
description: "List garden containers (pots, beds, etc.).",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "list_garden_plants",
description: "List plants. Optionally filter by containerId.",
parameters: {
type: "object",
properties: {
containerId: { type: "string", description: "UUID of container" },
},
},
},
},
{
type: "function",
function: {
name: "get_garden_plant",
description: "Get full detail for a plant by id.",
parameters: {
type: "object",
properties: { plantId: { type: "string" } },
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "create_garden_container",
description: "Create a garden container.",
parameters: {
type: "object",
properties: {
name: { type: "string" },
type: { type: "string", description: "e.g. pot, bed, greenhouse" },
locationNotes: { type: "string" },
},
required: ["name"],
},
},
},
{
type: "function",
function: {
name: "update_garden_container",
description: "Update a garden container.",
parameters: {
type: "object",
properties: {
containerId: { type: "string" },
name: { type: "string" },
type: { type: "string" },
locationNotes: { type: "string" },
},
required: ["containerId"],
},
},
},
{
type: "function",
function: {
name: "delete_garden_container",
description: "Delete a garden container.",
parameters: {
type: "object",
properties: { containerId: { type: "string" } },
required: ["containerId"],
},
},
},
{
type: "function",
function: {
name: "create_garden_plant",
description: "Add a new plant to the garden.",
parameters: {
type: "object",
properties: {
name: { type: "string" },
category: { type: "string" },
containerId: { type: "string" },
healthStatus: { type: "string" },
notes: { type: "string" },
},
required: ["name"],
},
},
},
{
type: "function",
function: {
name: "update_garden_plant",
description: "Update plant details (name, health, container, notes, etc.).",
parameters: {
type: "object",
properties: {
plantId: { type: "string" },
name: { type: "string" },
category: { type: "string" },
containerId: { type: "string" },
healthStatus: { type: "string" },
notes: { type: "string" },
},
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "delete_garden_plant",
description: "Delete a plant.",
parameters: {
type: "object",
properties: { plantId: { type: "string" } },
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "log_garden_care",
description: "Record care performed on a plant (water, fertilize, prune, etc.).",
parameters: {
type: "object",
properties: {
plantId: { type: "string" },
careType: { type: "string", description: "e.g. water, fertilize, prune" },
notes: { type: "string" },
performedAt: { type: "string", description: "ISO 8601; defaults to now" },
},
required: ["plantId", "careType"],
},
},
},
{
type: "function",
function: {
name: "list_garden_care_logs",
description: "List recent care logs for a plant.",
parameters: {
type: "object",
properties: {
plantId: { type: "string" },
limit: { type: "number" },
},
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "list_garden_care_schedules",
description: "List care schedules (recurring reminders) for a plant.",
parameters: {
type: "object",
properties: { plantId: { type: "string" } },
required: ["plantId"],
},
},
},
{
type: "function",
function: {
name: "upsert_garden_care_schedule",
description: "Create or update a recurring care schedule for a plant.",
parameters: {
type: "object",
properties: {
plantId: { type: "string" },
careType: { type: "string" },
intervalDays: { type: "number", description: "Days between care" },
enabled: { type: "boolean" },
},
required: ["plantId", "careType", "intervalDays"],
},
},
},
{
type: "function",
function: {
name: "delete_garden_care_schedule",
description: "Delete a care schedule by schedule id.",
parameters: {
type: "object",
properties: { scheduleId: { type: "string" } },
required: ["scheduleId"],
},
},
},
{
type: "function",
function: {
name: "toggle_garden_care_schedule",
description: "Enable or disable a care schedule.",
parameters: {
type: "object",
properties: {
scheduleId: { type: "string" },
enabled: { type: "boolean" },
},
required: ["scheduleId", "enabled"],
},
},
},
{
type: "function",
function: {
name: "push_overdue_garden_care",
description: "Push overdue garden care items to the household task list.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "schedule_garden_care_on_calendar",
description: "Add a care schedule's next due date as a calendar event.",
parameters: {
type: "object",
properties: {
scheduleId: { type: "string" },
calendarId: { type: "string" },
reminderMinutesBefore: { type: "number" },
},
required: ["scheduleId", "calendarId"],
},
},
},
{
type: "function",
function: {
name: "list_shareable_entity_types",
description: "List entity types that can be shared via public links.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "list_share_links",
description: "List active share links. Optionally filter by entityType and entityId.",
parameters: {
type: "object",
properties: {
entityType: { type: "string" },
entityId: { type: "string" },
},
},
},
},
{
type: "function",
function: {
name: "create_share_link",
description:
"Create a temporary public share link for an entity. Entity types: calendar, calendar.event, list, note, garden.plant, garden.container.",
parameters: {
type: "object",
properties: {
entityType: { type: "string" },
entityId: { type: "string" },
expiresAt: { type: "string", description: "ISO 8601 expiry, or omit for no expiry" },
write: { type: "boolean", description: "Allow write via link (default false)" },
},
required: ["entityType", "entityId"],
},
},
},
{
type: "function",
function: {
name: "revoke_share_link",
description: "Revoke an active share link by its id.",
parameters: {
type: "object",
properties: { linkId: { type: "string" } },
required: ["linkId"],
},
},
},
{
type: "function",
function: {
name: "get_api_docs",
description:
"Read famapp REST API documentation (OpenAPI). Use when unsure which endpoint to call or no dedicated tool exists. Pass search to filter relevant paths.",
parameters: {
type: "object",
properties: {
search: {
type: "string",
description: "Optional keyword to filter paths (e.g. garden, share, journal)",
},
},
},
},
},
{
type: "function",
function: {
name: "call_api",
description:
"Fallback: call any /api/v1/* endpoint directly. Use get_api_docs first when unsure. Only household-scoped v1 routes.",
parameters: {
type: "object",
properties: {
method: {
type: "string",
enum: ["GET", "POST", "PATCH", "DELETE"],
description: "HTTP method",
},
path: {
type: "string",
description: "API path starting with /api/v1/ (e.g. /api/v1/notes)",
},
query: {
type: "object",
description: "Optional query string parameters as key-value pairs",
},
body: {
type: "object",
description: "Optional JSON body for POST/PATCH",
},
},
required: ["method", "path"],
},
},
},
];
export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help Matt and his wife manage calendar events, shopping and task lists, notes, and personal journal entries.
export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help Matt and his wife manage calendar events, shopping and task lists, notes, journal entries, garden plants, share links, and the bang counter.
Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly.
When adding shopping items, resolve the shopping list id via list_lists if needed. Use ISO 8601 datetimes for calendar tools.`;
When no dedicated tool fits, or you are unsure how to do something:
1. Call get_api_docs with a relevant search term to find the right /api/v1/* endpoint.
2. Call call_api with the documented method, path, query, and body.
Lists: resolve list ids via list_lists. To complete items, list_list_items then update_list_item with done: true.
Journal: per-user private entries. Valid mood ids: ${JOURNAL_MOOD_IDS}. stress is 1-10. pillsTaken is boolean.
Garden: care types are free text (water, fertilize, prune, etc.). Use list_garden_plants to find plant ids.
Sharing: journal entries are not shareable. Shareable types: calendar, calendar.event, list, note, garden.plant, garden.container.
Calendar: use ISO 8601 datetimes. Bang dates use YYYY-MM-DD.`;
@@ -8,7 +8,9 @@ import listPlugin from "@fullcalendar/list";
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2, X } from "lucide-react";
import dynamic from "next/dynamic";
import { useMemo, useState, useEffect, useTransition } from "react";
import { richTextToPlainText } from "@/components/rich-text";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -34,6 +36,18 @@ import {
updateEvent,
} from "../server/actions";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-24 rounded-lg border border-input px-3 py-2 text-sm text-muted-foreground animate-pulse">
Loading editor
</div>
),
},
);
type EventDraft = {
id?: string;
calendarId: string;
@@ -180,7 +194,7 @@ export function CalendarShell({
endAt: new Date(selectedEvent.endAt),
allDay: selectedEvent.allDay,
location: selectedEvent.location || null,
notes: selectedEvent.notes || null,
notes: richTextToPlainText(selectedEvent.notes) ? selectedEvent.notes : null,
};
startTransition(async () => {
@@ -513,13 +527,17 @@ export function CalendarShell({
value={selectedEvent.location}
onChange={(event) => setSelectedEvent({ ...selectedEvent, location: event.target.value })}
/>
<Label htmlFor="event-notes">Notes</Label>
<textarea
id="event-notes"
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={selectedEvent.notes}
onChange={(event) => setSelectedEvent({ ...selectedEvent, notes: event.target.value })}
/>
<div className="space-y-1.5 min-w-0">
<Label htmlFor="event-notes">Notes</Label>
<RichTextEditor
id="event-notes"
aria-label="Notes"
value={selectedEvent.notes}
onChange={(notes) => setSelectedEvent({ ...selectedEvent, notes })}
disabled={isPending}
placeholder="Add details…"
/>
</div>
{!selectedEvent.id && (
<label className="flex items-center gap-2 text-sm">
<input
@@ -696,7 +714,7 @@ export function CalendarShell({
style={{
borderRadius: isMobile ? "var(--r-xl) var(--r-xl) 0 0" : "var(--r-lg)",
width: isMobile ? "100%" : undefined,
maxWidth: isMobile ? undefined : "512px",
maxWidth: isMobile ? undefined : "672px",
maxHeight: "90dvh",
padding: "1rem",
paddingBottom: isMobile ? "calc(1rem + env(safe-area-inset-bottom))" : "1rem",
@@ -1,4 +1,5 @@
import { Calendar as CalendarIcon, MapPin } from "lucide-react";
import { RichTextContent } from "@/components/rich-text";
import type { CalendarShareData, EventShareData } from "../server/share-queries";
import { ShareEyebrow } from "@/components/share/share-eyebrow";
import { MiniDayCard } from "@/components/share/mini-day-card";
@@ -92,7 +93,7 @@ export function EventSharedView({ data }: { data: EventShareData }) {
</ShareDetailCard>
{data.notes && (
<p
<div
className="serif"
style={{
fontSize: 16,
@@ -103,11 +104,10 @@ export function EventSharedView({ data }: { data: EventShareData }) {
padding: "18px 22px",
margin: "14px 0 24px",
textWrap: "pretty",
whiteSpace: "pre-wrap",
}}
>
{data.notes}
</p>
<RichTextContent html={data.notes} />
</div>
)}
</>
);
+137 -81
View File
@@ -6,16 +6,26 @@ import { z } from "zod";
import type { ApiAuthContext } from "@/lib/api-auth";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivity, logActivityForScope } from "@/modules/_core/activity";
import { logActivityForScope } from "@/modules/_core/activity";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { listItems } from "@/modules/lists/schema";
import { notifyListChanged } from "@/modules/lists/server/realtime";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
import { createCalendarEvent } from "./calendar-bridge";
import { buildCareReminderBody, buildCareTitle } from "./care-utils";
import { updateScheduleAfterCare } from "./care-schedule";
import { addGardenCareTask, getList, listLists } from "./lists-bridge";
import { containerInput, containerUpdateInput, plantInput, plantUpdateInput } from "./schemas";
import { createEventForScope } from "@/modules/calendar/server/actions";
import { addItemForScope } from "@/modules/lists/server/actions";
import { getListForScope, listListsForScope } from "@/modules/lists/server/queries";
import {
careLogInput,
careScheduleInput,
careScheduleToggleInput,
containerInput,
containerUpdateInput,
plantInput,
plantUpdateInput,
scheduleOnCalendarInput,
} from "./schemas";
function toScope(ctx: ApiAuthContext) {
return { householdId: ctx.householdId, userId: ctx.userId };
@@ -420,17 +430,9 @@ async function assertCanAccessPlant(id: string, householdId: string) {
// ─── Care log actions ─────────────────────────────────────────────────────────
const careLogInput = z.object({
plantId: z.string().uuid(),
careType: z.string().trim().min(1).max(40),
notes: z.string().trim().max(2000).nullable().optional(),
performedAt: z.string().optional(),
});
export async function logCare(input: z.input<typeof careLogInput>) {
export async function logCareForScope(scope: ApiAuthContext, input: z.input<typeof careLogInput>) {
const parsed = careLogInput.parse(input);
const { household, user } = await getCurrentSession();
await assertCanAccessPlant(parsed.plantId, household.id);
await assertCanAccessPlant(parsed.plantId, scope.householdId);
const performedAt = parsed.performedAt ? new Date(parsed.performedAt) : new Date();
@@ -438,9 +440,9 @@ export async function logCare(input: z.input<typeof careLogInput>) {
.insert(gardenCareLogs)
.values({
plantId: parsed.plantId,
householdId: household.id,
householdId: scope.householdId,
careType: parsed.careType,
performedBy: user.id,
performedBy: scope.userId,
notes: parsed.notes ?? null,
performedAt,
})
@@ -448,15 +450,16 @@ export async function logCare(input: z.input<typeof careLogInput>) {
if (!log) throw new Error("Care log was not created");
await updateScheduleAfterCare(parsed.plantId, parsed.careType, user.id, household.id);
await logActivity({
if (scope.userId) {
await updateScheduleAfterCare(parsed.plantId, parsed.careType, scope.userId, scope.householdId);
}
await logActivityForScope(toScope(scope), {
entityType: "garden.plant",
entityId: parsed.plantId,
action: "update",
payload: { careType: parsed.careType },
});
// Mark linked task item done if one exists for this plant + care type
const linkedItems = await db
.select({ id: listItems.id, listId: listItems.listId })
.from(listItems)
@@ -482,19 +485,19 @@ export async function logCare(input: z.input<typeof careLogInput>) {
return log;
}
export async function logCare(input: z.input<typeof careLogInput>) {
const { household, user } = await getCurrentSession();
return logCareForScope({ householdId: household.id, userId: user.id, role: null }, input);
}
// ─── Care schedule actions ────────────────────────────────────────────────────
const careScheduleInput = z.object({
plantId: z.string().uuid(),
careType: z.string().trim().min(1).max(40),
intervalDays: z.number().int().min(1).max(365),
enabled: z.boolean().optional(),
});
export async function upsertCareSchedule(input: z.input<typeof careScheduleInput>) {
export async function upsertCareScheduleForScope(
scope: ApiAuthContext,
input: z.input<typeof careScheduleInput>,
) {
const parsed = careScheduleInput.parse(input);
const { household, user } = await getCurrentSession();
await assertCanAccessPlant(parsed.plantId, household.id);
await assertCanAccessPlant(parsed.plantId, scope.householdId);
const now = new Date();
const enabled = parsed.enabled ?? true;
@@ -517,34 +520,41 @@ export async function upsertCareSchedule(input: z.input<typeof careScheduleInput
.insert(gardenCareSchedules)
.values({
plantId: parsed.plantId,
householdId: household.id,
householdId: scope.householdId,
careType: parsed.careType,
intervalDays: parsed.intervalDays,
lastPerformedAt: existing?.lastPerformedAt ?? null,
nextDueAt,
enabled,
})
.onConflictDoUpdate({
target: [gardenCareSchedules.plantId, gardenCareSchedules.careType],
set: { intervalDays: parsed.intervalDays, nextDueAt, enabled, updatedAt: now },
set: {
intervalDays: parsed.intervalDays,
nextDueAt,
enabled,
updatedAt: new Date(),
},
})
.returning();
if (!schedule) throw new Error("Schedule was not created");
const [plantRow] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, parsed.plantId))
.limit(1);
if (!schedule) throw new Error("Care schedule was not saved");
await cancelReminder("garden.schedule", schedule.id);
if (enabled) {
if (enabled && schedule.nextDueAt && scope.userId) {
const [plantRow] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, parsed.plantId))
.limit(1);
await scheduleReminder({
householdId: household.id,
householdId: scope.householdId,
entityType: "garden.schedule",
entityId: schedule.id,
fireAt: nextDueAt,
createdBy: user.id,
fireAt: schedule.nextDueAt,
createdBy: scope.userId,
title: "Garden care reminder",
body: plantRow ? buildCareReminderBody(parsed.careType, plantRow.name) : undefined,
});
@@ -554,38 +564,61 @@ export async function upsertCareSchedule(input: z.input<typeof careScheduleInput
return schedule;
}
export async function deleteCareSchedule(input: { id: string }) {
export async function upsertCareSchedule(input: z.input<typeof careScheduleInput>) {
const { household, user } = await getCurrentSession();
return upsertCareScheduleForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
}
export async function deleteCareScheduleForScope(scope: ApiAuthContext, input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({ id: gardenCareSchedules.id, plantId: gardenCareSchedules.plantId })
.from(gardenCareSchedules)
.where(
and(eq(gardenCareSchedules.id, parsed.id), eq(gardenCareSchedules.householdId, household.id)),
and(
eq(gardenCareSchedules.id, parsed.id),
eq(gardenCareSchedules.householdId, scope.householdId),
),
)
.limit(1);
if (!row) throw new Error("Forbidden");
if (!row) throw new Error("Care schedule not found");
await cancelReminder("garden.schedule", parsed.id);
await db.delete(gardenCareSchedules).where(eq(gardenCareSchedules.id, parsed.id));
revalidatePath(`/garden/plants/${row.plantId}`);
}
export async function toggleCareSchedule(input: { id: string; enabled: boolean }) {
const parsed = z.object({ id: z.string().uuid(), enabled: z.boolean() }).parse(input);
export async function deleteCareSchedule(input: { id: string }) {
const { household, user } = await getCurrentSession();
return deleteCareScheduleForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
}
export async function toggleCareScheduleForScope(
scope: ApiAuthContext,
input: z.input<typeof careScheduleToggleInput>,
) {
const parsed = careScheduleToggleInput.parse(input);
const [row] = await db
.select()
.from(gardenCareSchedules)
.where(
and(eq(gardenCareSchedules.id, parsed.id), eq(gardenCareSchedules.householdId, household.id)),
and(
eq(gardenCareSchedules.id, parsed.id),
eq(gardenCareSchedules.householdId, scope.householdId),
),
)
.limit(1);
if (!row) throw new Error("Forbidden");
if (!row) throw new Error("Care schedule not found");
await db
.update(gardenCareSchedules)
@@ -594,7 +627,7 @@ export async function toggleCareSchedule(input: { id: string; enabled: boolean }
await cancelReminder("garden.schedule", parsed.id);
if (parsed.enabled && row.nextDueAt) {
if (parsed.enabled && row.nextDueAt && scope.userId) {
const [togglePlantRow] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
@@ -602,11 +635,11 @@ export async function toggleCareSchedule(input: { id: string; enabled: boolean }
.limit(1);
await scheduleReminder({
householdId: household.id,
householdId: scope.householdId,
entityType: "garden.schedule",
entityId: parsed.id,
fireAt: row.nextDueAt,
createdBy: user.id,
createdBy: scope.userId,
title: "Garden care reminder",
body: togglePlantRow ? buildCareReminderBody(row.careType, togglePlantRow.name) : undefined,
});
@@ -615,17 +648,21 @@ export async function toggleCareSchedule(input: { id: string; enabled: boolean }
revalidatePath(`/garden/plants/${row.plantId}`);
}
export async function toggleCareSchedule(input: { id: string; enabled: boolean }) {
const { household, user } = await getCurrentSession();
return toggleCareScheduleForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
}
// ─── Calendar integration ─────────────────────────────────────────────────────
const scheduleOnCalendarInput = z.object({
scheduleId: z.string().uuid(),
calendarId: z.string().uuid(),
reminderMinutesBefore: z.number().int().min(0).max(1440).optional(),
});
export async function scheduleOnCalendar(input: z.input<typeof scheduleOnCalendarInput>) {
export async function scheduleOnCalendarForScope(
scope: ApiAuthContext,
input: z.input<typeof scheduleOnCalendarInput>,
) {
const parsed = scheduleOnCalendarInput.parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({
@@ -638,36 +675,47 @@ export async function scheduleOnCalendar(input: z.input<typeof scheduleOnCalenda
.where(
and(
eq(gardenCareSchedules.id, parsed.scheduleId),
eq(gardenCareSchedules.householdId, household.id),
eq(gardenCareSchedules.householdId, scope.householdId),
),
)
.limit(1);
if (!row) throw new Error("Forbidden");
if (!row) throw new Error("Care schedule not found");
if (!row.nextDueAt) throw new Error("This schedule has no upcoming due date set.");
const startAt = row.nextDueAt;
const endAt = new Date(startAt.getTime() + 30 * 60 * 1000);
const title = buildCareTitle(row.careType, row.plantName);
await createCalendarEvent({
calendarId: parsed.calendarId,
title,
startAt,
endAt,
allDay: false,
notes: `Scheduled from garden. Next due: ${startAt.toLocaleDateString()}`,
remindMinutesBefore: parsed.reminderMinutesBefore ?? null,
});
await createEventForScope(
{ householdId: scope.householdId, userId: scope.userId },
{
calendarId: parsed.calendarId,
title,
startAt,
endAt,
allDay: false,
notes: `Scheduled from garden. Next due: ${startAt.toLocaleDateString()}`,
remindMinutesBefore: parsed.reminderMinutesBefore ?? null,
},
);
revalidatePath("/calendar");
}
export async function scheduleOnCalendar(input: z.input<typeof scheduleOnCalendarInput>) {
const { household, user } = await getCurrentSession();
return scheduleOnCalendarForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
}
// ─── Lists integration ────────────────────────────────────────────────────────
export async function pushOverdueToTaskList(): Promise<{ added: number }> {
const { household } = await getCurrentSession();
export async function pushOverdueToTaskListForScope(
scope: ApiAuthContext,
): Promise<{ added: number }> {
const overduePairs = await db
.select({
plantId: gardenPlants.id,
@@ -679,7 +727,7 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> {
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(
and(
eq(gardenCareSchedules.householdId, household.id),
eq(gardenCareSchedules.householdId, scope.householdId),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
@@ -687,11 +735,11 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> {
if (overduePairs.length === 0) return { added: 0 };
const taskLists = await listLists({ type: "task" });
const taskLists = await listListsForScope(scope.householdId, { type: "task" });
const taskList = taskLists[0];
if (!taskList) return { added: 0 };
const listDetail = await getList(taskList.id);
const listDetail = await getListForScope(scope.householdId, taskList.id);
const existingTexts = new Set(listDetail.items.map((i) => i.text));
let added = 0;
@@ -703,13 +751,12 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> {
? Math.abs(Math.floor((Date.now() - pair.nextDueAt.getTime()) / (1000 * 60 * 60 * 24)))
: 0;
await addGardenCareTask({
await addItemForScope(scope, {
listId: taskList.id,
text: title,
notes: `Overdue by ${daysOverdue} day(s)`,
dueAt: pair.nextDueAt,
gardenPlantId: pair.plantId,
gardenCareType: pair.careType,
metadata: { gardenPlantId: pair.plantId, gardenCareType: pair.careType },
});
existingTexts.add(title);
added++;
@@ -717,3 +764,12 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> {
return { added };
}
export async function pushOverdueToTaskList(): Promise<{ added: number }> {
const { household, user } = await getCurrentSession();
return pushOverdueToTaskListForScope({
householdId: household.id,
userId: user.id,
role: null,
});
}
+19 -6
View File
@@ -404,9 +404,11 @@ export type CareScheduleDto = {
isOverdue: boolean;
};
export async function getCareLogs(plantId: string, limit = 20): Promise<CareLogDto[]> {
const { household } = await getCurrentSession();
export async function getCareLogsForScope(
householdId: string,
plantId: string,
limit = 20,
): Promise<CareLogDto[]> {
const rows = await db
.select({
id: gardenCareLogs.id,
@@ -416,7 +418,7 @@ export async function getCareLogs(plantId: string, limit = 20): Promise<CareLogD
performedBy: gardenCareLogs.performedBy,
})
.from(gardenCareLogs)
.where(and(eq(gardenCareLogs.plantId, plantId), eq(gardenCareLogs.householdId, household.id)))
.where(and(eq(gardenCareLogs.plantId, plantId), eq(gardenCareLogs.householdId, householdId)))
.orderBy(desc(gardenCareLogs.performedAt))
.limit(limit);
@@ -429,16 +431,22 @@ export async function getCareLogs(plantId: string, limit = 20): Promise<CareLogD
}));
}
export async function getCareSchedules(plantId: string): Promise<CareScheduleDto[]> {
export async function getCareLogs(plantId: string, limit = 20): Promise<CareLogDto[]> {
const { household } = await getCurrentSession();
return getCareLogsForScope(household.id, plantId, limit);
}
export async function getCareSchedulesForScope(
householdId: string,
plantId: string,
): Promise<CareScheduleDto[]> {
const rows = await db
.select()
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.plantId, plantId),
eq(gardenCareSchedules.householdId, household.id),
eq(gardenCareSchedules.householdId, householdId),
),
)
.orderBy(gardenCareSchedules.careType);
@@ -462,6 +470,11 @@ export async function getCareSchedules(plantId: string): Promise<CareScheduleDto
});
}
export async function getCareSchedules(plantId: string): Promise<CareScheduleDto[]> {
const { household } = await getCurrentSession();
return getCareSchedulesForScope(household.id, plantId);
}
// ─── Widget queries ───────────────────────────────────────────────────────────
export type CareDueWidgetRow = {
+25
View File
@@ -27,3 +27,28 @@ export const plantInput = z.object({
});
export const plantUpdateInput = plantInput.partial();
export const careLogInput = z.object({
plantId: z.string().uuid(),
careType: z.string().trim().min(1).max(40),
notes: z.string().trim().max(2000).nullable().optional(),
performedAt: z.string().optional(),
});
export const careScheduleInput = z.object({
plantId: z.string().uuid(),
careType: z.string().trim().min(1).max(40),
intervalDays: z.number().int().min(1).max(365),
enabled: z.boolean().optional(),
});
export const careScheduleToggleInput = z.object({
id: z.string().uuid(),
enabled: z.boolean(),
});
export const scheduleOnCalendarInput = z.object({
scheduleId: z.string().uuid(),
calendarId: z.string().uuid(),
reminderMinutesBefore: z.number().int().min(0).max(1440).optional(),
});
@@ -0,0 +1,74 @@
"use client";
import { useRef, useState, type MouseEvent, type ReactNode, type TouchEvent } from "react";
import { cn } from "@/lib/utils";
const LONG_PRESS_MS = 480;
type Props = {
label?: string;
clickable?: boolean;
children: ReactNode;
};
export function DayMoodHint({ label, clickable = false, children }: Props) {
const [showHint, setShowHint] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressClickRef = useRef(false);
function clearTimer() {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}
function handleTouchStart(event: TouchEvent) {
if (!label) return;
suppressClickRef.current = false;
clearTimer();
timerRef.current = setTimeout(() => {
suppressClickRef.current = true;
setShowHint(true);
}, LONG_PRESS_MS);
event.stopPropagation();
}
function handleTouchEnd() {
clearTimer();
setShowHint(false);
}
function handleClickCapture(event: MouseEvent) {
if (!suppressClickRef.current) return;
event.preventDefault();
event.stopPropagation();
suppressClickRef.current = false;
}
return (
<span
className={cn(
"relative block min-w-0",
clickable ? "cursor-pointer" : label ? "cursor-help" : undefined,
)}
title={label}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
onClickCapture={handleClickCapture}
>
{children}
{showHint && label ? (
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-30 mb-1 max-w-[11rem] -translate-x-1/2 rounded-md border-[0.5px] bg-[var(--card)] px-2 py-1 text-center text-[10px] leading-snug text-foreground shadow-sm"
style={{ borderColor: "var(--hair)" }}
>
{label}
</span>
) : null}
</span>
);
}
@@ -4,21 +4,69 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { blendMoodColors, moodLabelsForIds } from "../mood-catalog";
import { recordedDayKey } from "../day-key";
import { DayMoodHint } from "./day-mood-hint";
type EntryDot = {
id: string;
moods: string[];
};
type Props = {
entryDays: string[];
entries: Array<{ id: string; recordedAt: string; moods: string[] }>;
selectedDay?: string | null;
onSelectDay?: (day: string | null) => void;
viewDate?: Date;
onViewDateChange?: (date: Date) => void;
};
const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export function EntryCalendar({ entryDays, selectedDay, onSelectDay }: Props) {
const [viewDate, setViewDate] = useState(() => new Date());
function dayEntryMoodLabel(dayEntries: EntryDot[]): string | undefined {
if (dayEntries.length === 0) return undefined;
const parts = dayEntries
.map((entry) => (entry.moods.length > 0 ? moodLabelsForIds(entry.moods) : null))
.filter((part): part is string => Boolean(part));
if (parts.length === 0) return "Entries without moods";
return parts.join(" · ");
}
export function EntryCalendar({
entries,
selectedDay,
onSelectDay,
viewDate: controlledViewDate,
onViewDateChange,
}: Props) {
const [internalViewDate, setInternalViewDate] = useState(() => new Date());
const viewDate = controlledViewDate ?? internalViewDate;
function setViewDate(next: Date) {
if (onViewDateChange) onViewDateChange(next);
else setInternalViewDate(next);
}
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const daySet = useMemo(() => new Set(entryDays), [entryDays]);
const entriesByDay = useMemo(() => {
const map = new Map<string, EntryDot[]>();
for (const entry of entries) {
const recorded = new Date(entry.recordedAt);
if (recorded.getFullYear() !== year || recorded.getMonth() !== month) continue;
const dayKey = recordedDayKey(entry.recordedAt);
const list = map.get(dayKey) ?? [];
list.push({ id: entry.id, moods: entry.moods });
map.set(dayKey, list);
}
return map;
}, [entries, year, month]);
const cells = useMemo(() => {
const first = new Date(year, month, 1);
@@ -35,7 +83,7 @@ export function EntryCalendar({ entryDays, selectedDay, onSelectDay }: Props) {
}, [year, month]);
function shiftMonth(delta: number) {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + delta, 1));
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + delta, 1));
}
return (
@@ -64,28 +112,38 @@ export function EntryCalendar({ entryDays, selectedDay, onSelectDay }: Props) {
<div className="grid grid-cols-7 gap-1">
{cells.map((cell) => {
if (cell.day === null) return <div key={cell.key} />;
const hasEntry = daySet.has(cell.key);
const dayEntries = entriesByDay.get(cell.key) ?? [];
const hasEntries = dayEntries.length > 0;
const isSelected = selectedDay === cell.key;
const moodLabel = dayEntryMoodLabel(dayEntries);
const content = (
<span
className="relative flex h-8 w-full items-center justify-center rounded-md text-[12px]"
className="relative flex h-9 w-full flex-col items-center justify-between rounded-md px-0.5 py-1 text-[12px]"
style={{
background: isSelected ? "var(--shade)" : "transparent",
color: "var(--ink)",
}}
>
{cell.day}
{hasEntry ? (
<span
className="absolute bottom-1 size-1.5 rounded-full"
style={{ background: "var(--accent)" }}
/>
<span>{cell.day}</span>
{hasEntries ? (
<span className="flex max-w-full flex-wrap items-center justify-center gap-0.5">
{dayEntries.map((entry) => (
<span
key={entry.id}
className="size-1.5 shrink-0 rounded-full"
style={{
background: blendMoodColors(entry.moods) ?? "var(--accent)",
}}
/>
))}
</span>
) : null}
</span>
);
if (!hasEntry) {
if (!hasEntries) {
return (
<div key={cell.key} className="min-w-0">
{content}
@@ -95,21 +153,24 @@ export function EntryCalendar({ entryDays, selectedDay, onSelectDay }: Props) {
if (onSelectDay) {
return (
<button
key={cell.key}
type="button"
className="min-w-0"
onClick={() => onSelectDay(isSelected ? null : cell.key)}
>
{content}
</button>
<DayMoodHint key={cell.key} label={moodLabel} clickable>
<button
type="button"
className="min-w-0 w-full"
onClick={() => onSelectDay(isSelected ? null : cell.key)}
>
{content}
</button>
</DayMoodHint>
);
}
return (
<Link key={cell.key} href={`/journal?day=${cell.key}`} className="min-w-0">
{content}
</Link>
<DayMoodHint key={cell.key} label={moodLabel} clickable>
<Link href={`/journal?day=${cell.key}`} className="min-w-0 block w-full">
{content}
</Link>
</DayMoodHint>
);
})}
</div>
@@ -13,6 +13,7 @@ import { getMoodById } from "../mood-catalog";
import type { JournalEntryDto } from "../server/queries";
import { createJournalEntry, deleteJournalEntry, updateJournalEntry } from "../server/actions";
import { MoodPicker } from "./mood-picker";
import { StressSlider } from "./stress-slider";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
@@ -33,9 +34,9 @@ export function JournalEntryEditor({ entry }: { entry?: JournalEntryDto }) {
const [body, setBody] = useState(entry?.body ?? "");
const [moods, setMoods] = useState<string[]>(entry?.moods ?? []);
const [stress, setStress] = useState(entry?.stress ?? 5);
const [trackStress, setTrackStress] = useState(entry?.stress != null);
const [trackStress, setTrackStress] = useState(entry ? entry.stress != null : true);
const [pillsTaken, setPillsTaken] = useState(entry?.pillsTaken ?? false);
const [trackPills, setTrackPills] = useState(entry?.pillsTaken != null);
const [trackPills, setTrackPills] = useState(entry ? entry.pillsTaken != null : true);
const [isPending, startTransition] = useTransition();
function saveEntry() {
@@ -138,17 +139,13 @@ export function JournalEntryEditor({ entry }: { entry?: JournalEntryDto }) {
<Switch checked={trackStress} onCheckedChange={setTrackStress} />
</div>
</div>
<input
<StressSlider
id="journal-stress"
type="range"
min={1}
max={10}
value={stress}
onChange={setStress}
disabled={!trackStress || isPending}
onChange={(event) => setStress(Number(event.target.value))}
className="w-full"
/>
<div className="text-[12px] muted">{trackStress ? `${stress}/10` : "Not tracked"}</div>
{!trackStress ? <div className="text-[12px] muted">Not tracked</div> : null}
</div>
<div className="space-y-2">
@@ -0,0 +1,117 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { richTextToPlainText } from "@/components/rich-text";
import { getMoodById } from "../mood-catalog";
import { recordedDayKey } from "../day-key";
import type { JournalEntryDto } from "../server/queries";
type Phase = "idle" | "exit" | "enter";
type PanelState = {
day: string | null;
entries: JournalEntryDto[];
phase: Phase;
};
const EXIT_MS = 200;
type Props = {
selectedDay: string | null;
entries: JournalEntryDto[];
emptyMessage: string;
};
function entrySignature(items: JournalEntryDto[]): string {
return items.map((entry) => entry.id).join(",");
}
export function JournalEntryList({ selectedDay, entries, emptyMessage }: Props) {
const visibleEntries = useMemo(() => {
const filtered = selectedDay
? entries.filter((entry) => recordedDayKey(entry.recordedAt) === selectedDay)
: entries;
return filtered.slice(0, selectedDay ? 50 : 10);
}, [entries, selectedDay]);
const [panel, setPanel] = useState<PanelState>({
day: selectedDay,
entries: visibleEntries,
phase: "idle",
});
const displayedSigRef = useRef(entrySignature(visibleEntries));
const displayedDayRef = useRef(selectedDay);
useEffect(() => {
const nextSig = entrySignature(visibleEntries);
const sameDay = selectedDay === displayedDayRef.current;
const sameEntries = nextSig === displayedSigRef.current;
if (sameDay && sameEntries) return;
setPanel((current) => ({ ...current, phase: "exit" }));
const timer = window.setTimeout(() => {
displayedDayRef.current = selectedDay;
displayedSigRef.current = nextSig;
setPanel({ day: selectedDay, entries: visibleEntries, phase: "enter" });
requestAnimationFrame(() => {
setPanel((current) => ({ ...current, phase: "idle" }));
});
}, EXIT_MS);
return () => window.clearTimeout(timer);
}, [selectedDay, visibleEntries]);
return (
<div className="journal-entries-stack min-w-0" data-phase={panel.phase}>
{panel.entries.length === 0 ? (
<div
className="journal-entry-row rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-8 text-center text-[13px] muted"
style={{ borderColor: "var(--hair)" }}
>
{emptyMessage}
</div>
) : (
panel.entries.map((entry) => <JournalEntryRow key={entry.id} entry={entry} />)
)}
</div>
);
}
function JournalEntryRow({ entry }: { entry: JournalEntryDto }) {
const when = new Date(entry.recordedAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
const moodLabel = entry.moods
.map((id) => getMoodById(id))
.filter(Boolean)
.map((mood) => mood!.emoji)
.join(" ");
return (
<Link
href={`/journal/${entry.id}`}
className="journal-entry-row block rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] px-4 py-3 hover:bg-[var(--shade)] min-w-0"
style={{ borderColor: "var(--hair)" }}
>
<div className="flex items-start justify-between gap-3 min-w-0">
<div className="min-w-0">
<h4 className="serif text-[15px] font-medium truncate">
{entry.title || "Untitled entry"}
</h4>
<p className="muted text-[12px] mt-0.5">{when}</p>
</div>
<span className="text-[16px] shrink-0">{moodLabel || "—"}</span>
</div>
{entry.body ? (
<p className="muted text-[12.5px] mt-2 truncate">{richTextToPlainText(entry.body, 140)}</p>
) : null}
</Link>
);
}
@@ -1,29 +1,58 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Plus, LineChart, Sparkles } from "lucide-react";
import { richTextToPlainText } from "@/components/rich-text";
import { buttonVariants } from "@/components/ui/button";
import { getMoodById } from "../mood-catalog";
import { recordedDayKey } from "../day-key";
import { dayKeyToMonthStart, formatDayKeyLabel } from "../day-key";
import type { JournalEntryDto } from "../server/queries";
import { EntryCalendar } from "./entry-calendar";
import { MoodColorGrid } from "./mood-color-grid";
import { JournalEntryList } from "./journal-entry-list";
import { initialJournalViewDate } from "../view-date";
type Props = {
entries: JournalEntryDto[];
entryDays: string[];
};
export function JournalIndex({ entries, entryDays }: Props) {
const [selectedDay, setSelectedDay] = useState<string | null>(null);
function syncDayInUrl(day: string | null) {
const next = day ? `/journal?day=${day}` : "/journal";
window.history.replaceState(window.history.state, "", next);
}
const visibleEntries = useMemo(() => {
const filtered = selectedDay
? entries.filter((entry) => recordedDayKey(entry.recordedAt) === selectedDay)
: entries;
return filtered.slice(0, selectedDay ? 50 : 10);
}, [entries, selectedDay]);
export function JournalIndex({ entries }: Props) {
const searchParams = useSearchParams();
const dayParam = searchParams.get("day");
const [selectedDay, setSelectedDay] = useState<string | null>(dayParam);
const [viewDate, setViewDate] = useState(() => {
const fromParam = dayParam ? dayKeyToMonthStart(dayParam) : null;
return fromParam ?? initialJournalViewDate(entries);
});
useEffect(() => {
function onPopState() {
const day = new URLSearchParams(window.location.search).get("day");
setSelectedDay(day);
if (day) {
const monthStart = dayKeyToMonthStart(day);
if (monthStart) setViewDate(monthStart);
}
}
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
function handleSelectDay(day: string | null) {
setSelectedDay(day);
if (day) {
const monthStart = dayKeyToMonthStart(day);
if (monthStart) setViewDate(monthStart);
}
syncDayInUrl(day);
}
return (
<div className="mx-auto grid w-full max-w-5xl gap-5 min-w-0">
@@ -53,68 +82,38 @@ export function JournalIndex({ entries, entryDays }: Props) {
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px] min-w-0">
<div className="grid gap-3 min-w-0">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px] lg:items-start min-w-0">
<div className="flex min-w-0 w-full flex-col gap-3 self-start">
<div className="eyebrow">
{selectedDay ? `Entries on ${selectedDay}` : "Recent entries"}
{selectedDay ? `Entries on ${formatDayKeyLabel(selectedDay)}` : "Recent entries"}
</div>
{visibleEntries.length === 0 ? (
<div
className="rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-8 text-center text-[13px] muted"
style={{ borderColor: "var(--hair)" }}
>
No entries yet.
</div>
) : (
visibleEntries.map((entry) => <JournalEntryRow key={entry.id} entry={entry} />)
)}
<JournalEntryList
selectedDay={selectedDay}
entries={entries}
emptyMessage={selectedDay ? "No entries on this day." : "No entries yet."}
/>
{entries.length > 10 && !selectedDay ? (
<p className="muted text-[12px]">Showing latest 10 of {entries.length} entries.</p>
) : null}
</div>
<EntryCalendar
entryDays={entryDays}
selectedDay={selectedDay}
onSelectDay={setSelectedDay}
/>
<div className="flex w-full flex-col gap-3 self-start lg:w-[280px] min-w-0">
<EntryCalendar
entries={entries}
selectedDay={selectedDay}
onSelectDay={handleSelectDay}
viewDate={viewDate}
onViewDateChange={setViewDate}
/>
<MoodColorGrid
entries={entries}
viewDate={viewDate}
selectedDay={selectedDay}
onSelectDay={handleSelectDay}
variant="compact"
/>
</div>
</div>
</div>
);
}
function JournalEntryRow({ entry }: { entry: JournalEntryDto }) {
const when = new Date(entry.recordedAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
const moodLabel = entry.moods
.map((id) => getMoodById(id))
.filter(Boolean)
.map((mood) => mood!.emoji)
.join(" ");
return (
<Link
href={`/journal/${entry.id}`}
className="block rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] px-4 py-3 hover:bg-[var(--shade)] min-w-0"
style={{ borderColor: "var(--hair)" }}
>
<div className="flex items-start justify-between gap-3 min-w-0">
<div className="min-w-0">
<h4 className="serif text-[15px] font-medium truncate">
{entry.title || "Untitled entry"}
</h4>
<p className="muted text-[12px] mt-0.5">{when}</p>
</div>
<span className="text-[16px] shrink-0">{moodLabel || "—"}</span>
</div>
{entry.body ? (
<p className="muted text-[12.5px] mt-2 truncate">{richTextToPlainText(entry.body, 140)}</p>
) : null}
</Link>
);
}
@@ -0,0 +1,175 @@
"use client";
import { useMemo } from "react";
import { blendMoodColors, MOOD_CATALOG, moodLabelsForIds } from "../mood-catalog";
import { recordedDayKey } from "../day-key";
import type { JournalEntryDto } from "../server/queries";
import { DayMoodHint } from "./day-mood-hint";
const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
type Props = {
entries: JournalEntryDto[];
viewDate: Date;
selectedDay?: string | null;
onSelectDay?: (day: string | null) => void;
clickableDays?: Set<string>;
variant?: "compact" | "full";
};
function buildMonthMoodMap(entries: JournalEntryDto[], year: number, month: number) {
const map = new Map<string, Set<string>>();
for (const entry of entries) {
const recorded = new Date(entry.recordedAt);
if (recorded.getFullYear() !== year || recorded.getMonth() !== month) continue;
const dayKey = recordedDayKey(entry.recordedAt);
const moods = map.get(dayKey) ?? new Set<string>();
for (const moodId of entry.moods) moods.add(moodId);
map.set(dayKey, moods);
}
return map;
}
export function MoodColorGrid({
entries,
viewDate,
selectedDay,
onSelectDay,
clickableDays,
variant = "compact",
}: Props) {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const isCompact = variant === "compact";
const moodByDay = useMemo(() => buildMonthMoodMap(entries, year, month), [entries, year, month]);
const cells = useMemo(() => {
const first = new Date(year, month, 1);
const startOffset = (first.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const items: Array<{
key: string;
day: number | null;
moodIds: string[];
blend: string | null;
moodLabel: string;
}> = [];
for (let i = 0; i < startOffset; i += 1) {
items.push({ key: `pad-${i}`, day: null, moodIds: [], blend: null, moodLabel: "" });
}
for (let day = 1; day <= daysInMonth; day += 1) {
const dayKey = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const moodIds = [...(moodByDay.get(dayKey) ?? new Set<string>())];
items.push({
key: dayKey,
day,
moodIds,
blend: blendMoodColors(moodIds),
moodLabel: moodLabelsForIds(moodIds),
});
}
return items;
}, [year, month, moodByDay]);
const hasAnyMoods = cells.some((cell) => cell.moodIds.length > 0);
const cellClass = isCompact ? "h-7" : "h-10";
return (
<div
className="rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-3 min-w-0"
style={{ borderColor: "var(--hair)" }}
>
<div className="mb-2">
<div className="eyebrow">{isCompact ? "Mood tracker" : "Moods this month"}</div>
</div>
<div className={`mb-3 flex flex-wrap gap-1.5 ${isCompact ? "" : "gap-2"}`}>
{MOOD_CATALOG.map((mood) => (
<div
key={mood.id}
className={`inline-flex items-center gap-1 ${isCompact ? "text-[10px]" : "text-[11px]"}`}
title={mood.label}
>
<span
className={`inline-block shrink-0 rounded-full ${isCompact ? "size-2" : "size-2.5"}`}
style={{ background: mood.color }}
/>
{!isCompact ? <span className="text-muted-foreground">{mood.label}</span> : null}
</div>
))}
</div>
{!hasAnyMoods ? (
<p className="muted mb-2 text-[11px]">
No moods logged this month use the calendar above to browse other months.
</p>
) : null}
<div className="grid grid-cols-7 gap-1 text-center text-[10px] muted mb-1">
{WEEKDAY_LABELS.map((label) => (
<div key={label}>{isCompact ? label.charAt(0) : label}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((cell) => {
if (cell.day === null) {
return <div key={cell.key} />;
}
const hasMoods = cell.moodIds.length > 0;
const isSelected = selectedDay === cell.key;
const isClickable =
Boolean(onSelectDay) && (clickableDays ? clickableDays.has(cell.key) : hasMoods);
const hintLabel =
cell.moodLabel || (clickableDays?.has(cell.key) ? "View entries" : undefined);
const square = (
<span
className={`relative flex w-full items-center justify-center rounded-[4px] text-[11px] tabular-nums ${cellClass}`}
style={{
background: cell.blend ?? "var(--shade)",
opacity: hasMoods ? 1 : 0.3,
color: hasMoods ? "var(--ink)" : "var(--muted-foreground)",
outline: isSelected ? "1.5px solid var(--ink)" : undefined,
outlineOffset: isSelected ? "1px" : undefined,
}}
>
{cell.day}
</span>
);
if (!isClickable) {
return (
<DayMoodHint key={cell.key} label={hintLabel}>
<div className="min-w-0">{square}</div>
</DayMoodHint>
);
}
return (
<DayMoodHint key={cell.key} label={hintLabel} clickable>
<button
type="button"
className="min-w-0 w-full"
onClick={() =>
onSelectDay!(clickableDays ? cell.key : isSelected ? null : cell.key)
}
aria-pressed={isSelected}
>
{square}
</button>
</DayMoodHint>
);
})}
</div>
</div>
);
}
@@ -1,17 +1,14 @@
"use client";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { entryMoodScore } from "../server/analytics";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { buildEntryDaySet } from "../day-key";
import { MoodColorGrid } from "./mood-color-grid";
import { MoodYearGrid } from "./mood-year-grid";
import { initialJournalViewDate, initialJournalViewYear } from "../view-date";
import type { JournalEntryDto } from "../server/queries";
type Props = {
@@ -19,112 +16,68 @@ type Props = {
};
export function MoodTrackerView({ entries }: Props) {
const moodSeries = entries
.map((entry) => ({
at: new Date(entry.recordedAt).getTime(),
label: new Date(entry.recordedAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}),
moodScore: entryMoodScore(entry),
stress: entry.stress,
}))
.filter((point) => point.moodScore !== null)
.sort((a, b) => a.at - b.at);
const router = useRouter();
const entryDays = useMemo(() => buildEntryDaySet(entries), [entries]);
const [viewYear, setViewYear] = useState(() => initialJournalViewYear(entries));
const [viewDate, setViewDate] = useState(() => initialJournalViewDate(entries));
const stressPoints = entries
.filter((entry) => entry.stress !== null && entryMoodScore(entry) !== null)
.map((entry) => ({
stress: entry.stress as number,
moodScore: entryMoodScore(entry) as number,
}));
function goToDay(day: string | null) {
if (day) router.push(`/journal?day=${day}`);
}
function shiftYear(delta: number) {
setViewYear((current) => current + delta);
}
function shiftMonth(delta: number) {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + delta, 1));
}
return (
<div className="grid gap-6 min-w-0">
<section
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 min-w-0 overflow-hidden"
style={{ borderColor: "var(--hair)" }}
>
<h3 className="serif text-[18px] mb-3">Mood over time</h3>
{moodSeries.length === 0 ? (
<p className="muted text-[13px]">Log moods on entries to see this chart.</p>
) : (
<div className="h-64 w-full min-w-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={moodSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--hair)" />
<XAxis
dataKey="at"
type="number"
domain={["dataMin", "dataMax"]}
tickFormatter={(value) =>
new Date(value).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
tick={{ fontSize: 11 }}
/>
<YAxis domain={[0, 5]} tick={{ fontSize: 11 }} />
<Tooltip
labelFormatter={(value) =>
new Date(Number(value)).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
}
/>
<Line
type="monotone"
dataKey="moodScore"
stroke="var(--accent)"
strokeWidth={2}
dot={{ r: 3 }}
name="Mood score"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</section>
<Tabs defaultValue="year" className="min-w-0">
<TabsList>
<TabsTrigger value="year">Year</TabsTrigger>
<TabsTrigger value="month">Month</TabsTrigger>
</TabsList>
<section
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 min-w-0 overflow-hidden"
style={{ borderColor: "var(--hair)" }}
>
<h3 className="serif text-[18px] mb-3">Stress vs mood</h3>
{stressPoints.length === 0 ? (
<p className="muted text-[13px]">Track stress and moods to see correlation.</p>
) : (
<div className="h-64 w-full min-w-0">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" stroke="var(--hair)" />
<XAxis
type="number"
dataKey="stress"
name="Stress"
domain={[1, 10]}
tick={{ fontSize: 11 }}
/>
<YAxis
type="number"
dataKey="moodScore"
name="Mood"
domain={[0, 5]}
tick={{ fontSize: 11 }}
/>
<Tooltip cursor={{ strokeDasharray: "3 3" }} />
<Scatter data={stressPoints} fill="var(--accent)" />
</ScatterChart>
</ResponsiveContainer>
<TabsContent value="year" className="grid gap-3 min-w-0">
<div className="flex items-center justify-between">
<Button type="button" variant="ghost" size="sm" onClick={() => shiftYear(-1)}>
<ChevronLeft className="size-3.5" />
</Button>
<div className="text-[14px] font-medium">{viewYear}</div>
<Button type="button" variant="ghost" size="sm" onClick={() => shiftYear(1)}>
<ChevronRight className="size-3.5" />
</Button>
</div>
<MoodYearGrid
entries={entries}
viewYear={viewYear}
onSelectDay={goToDay}
clickableDays={entryDays}
/>
</TabsContent>
<TabsContent value="month" className="grid gap-3 min-w-0">
<div className="flex items-center justify-between">
<Button type="button" variant="ghost" size="sm" onClick={() => shiftMonth(-1)}>
<ChevronLeft className="size-3.5" />
</Button>
<div className="text-[14px] font-medium">
{viewDate.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
</div>
)}
</section>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => shiftMonth(1)}>
<ChevronRight className="size-3.5" />
</Button>
</div>
<MoodColorGrid
entries={entries}
viewDate={viewDate}
variant="full"
onSelectDay={goToDay}
clickableDays={entryDays}
/>
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,86 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { buildEntryDaySet } from "../day-key";
import { initialJournalViewDate, initialJournalViewYear } from "../view-date";
import type { JournalEntryDto } from "../server/queries";
import { MoodColorGrid } from "./mood-color-grid";
import { MoodYearGrid } from "./mood-year-grid";
type Props = {
entries: JournalEntryDto[];
view: "year" | "month";
};
export function MoodTrackerWidget({ entries, view }: Props) {
const router = useRouter();
const entryDays = useMemo(() => buildEntryDaySet(entries), [entries]);
const [viewYear, setViewYear] = useState(() => initialJournalViewYear(entries));
const [viewDate, setViewDate] = useState(() => initialJournalViewDate(entries));
function goToDay(day: string | null) {
if (day) router.push(`/journal?day=${day}`);
}
if (view === "year") {
return (
<div className="grid gap-2 min-w-0 min-h-0">
<div className="flex items-center justify-between shrink-0">
<Button type="button" variant="ghost" size="sm" onClick={() => setViewYear((y) => y - 1)}>
<ChevronLeft className="size-3.5" />
</Button>
<div className="text-[13px] font-medium">{viewYear}</div>
<Button type="button" variant="ghost" size="sm" onClick={() => setViewYear((y) => y + 1)}>
<ChevronRight className="size-3.5" />
</Button>
</div>
<MoodYearGrid
entries={entries}
viewYear={viewYear}
onSelectDay={goToDay}
clickableDays={entryDays}
/>
</div>
);
}
return (
<div className="grid gap-2 min-w-0 min-h-0">
<div className="flex items-center justify-between shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() - 1, 1))
}
>
<ChevronLeft className="size-3.5" />
</Button>
<div className="text-[13px] font-medium">
{viewDate.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + 1, 1))
}
>
<ChevronRight className="size-3.5" />
</Button>
</div>
<MoodColorGrid
entries={entries}
viewDate={viewDate}
variant="compact"
onSelectDay={goToDay}
clickableDays={entryDays}
/>
</div>
);
}
@@ -0,0 +1,270 @@
"use client";
import { Fragment, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { blendMoodColors, MOOD_CATALOG, moodLabelsForIds } from "../mood-catalog";
import { buildMoodMapForYear, isValidCalendarDate } from "../mood-map";
import type { JournalEntryDto } from "../server/queries";
import { DayMoodHint } from "./day-mood-hint";
const MONTH_INITIALS = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
const MONTH_SHORT = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const DAYS_IN_GRID = 31;
const MOBILE_QUERY = "(max-width: 767px)";
type Orientation = "vertical" | "horizontal";
type YearCell = {
key: string;
valid: boolean;
dayKey?: string;
moodIds: string[];
blend: string | null;
moodLabel: string;
};
type Props = {
entries: JournalEntryDto[];
viewYear: number;
selectedDay?: string | null;
onSelectDay?: (day: string | null) => void;
clickableDays?: Set<string>;
};
function buildYearCells(viewYear: number, moodByDay: Map<string, Set<string>>): YearCell[][] {
return Array.from({ length: 12 }, (_, month) =>
Array.from({ length: DAYS_IN_GRID }, (_, index) => {
const day = index + 1;
if (!isValidCalendarDate(viewYear, month, day)) {
return {
key: `${viewYear}-${month + 1}-${day}-invalid`,
valid: false,
moodIds: [],
blend: null,
moodLabel: "",
};
}
const dayKey = `${viewYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const moodIds = [...(moodByDay.get(dayKey) ?? new Set<string>())];
return {
key: dayKey,
valid: true,
dayKey,
moodIds,
blend: blendMoodColors(moodIds),
moodLabel: moodLabelsForIds(moodIds),
};
}),
);
}
function useYearGridOrientation(): [RefObject<HTMLDivElement | null>, Orientation] {
const containerRef = useRef<HTMLDivElement>(null);
const [orientation, setOrientation] = useState<Orientation>("horizontal");
useEffect(() => {
const update = () => {
setOrientation(window.matchMedia(MOBILE_QUERY).matches ? "vertical" : "horizontal");
};
update();
const mq = window.matchMedia(MOBILE_QUERY);
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
return [containerRef, orientation];
}
function YearCellSquare({
cell,
selectedDay,
onSelectDay,
clickableDays,
}: {
cell: YearCell;
selectedDay?: string | null;
onSelectDay?: (day: string | null) => void;
clickableDays?: Set<string>;
}) {
if (!cell.valid) {
return <div className="aspect-square w-full rounded-[2px] bg-[var(--shade)] opacity-15" />;
}
const dayKey = cell.dayKey!;
const hasMoods = cell.moodIds.length > 0;
const isSelected = selectedDay === dayKey;
const isClickable =
Boolean(onSelectDay) && (clickableDays ? clickableDays.has(dayKey) : hasMoods);
const hintLabel = cell.moodLabel || (clickableDays?.has(dayKey) ? "View entries" : undefined);
const square = (
<span
className="block aspect-square w-full rounded-[2px]"
style={{
background: cell.blend ?? "var(--shade)",
opacity: hasMoods ? 1 : 0.2,
outline: isSelected ? "1.5px solid var(--ink)" : undefined,
outlineOffset: isSelected ? "0px" : undefined,
}}
/>
);
if (!isClickable) {
return (
<DayMoodHint label={hintLabel}>
<div className="min-w-0">{square}</div>
</DayMoodHint>
);
}
return (
<DayMoodHint label={hintLabel} clickable>
<button
type="button"
className="min-w-0 w-full p-0"
onClick={() => onSelectDay!(clickableDays ? dayKey : isSelected ? null : dayKey)}
aria-pressed={isSelected}
>
{square}
</button>
</DayMoodHint>
);
}
export function MoodYearGrid({
entries,
viewYear,
selectedDay,
onSelectDay,
clickableDays,
}: Props) {
const [containerRef, orientation] = useYearGridOrientation();
const isHorizontal = orientation === "horizontal";
const moodByDay = useMemo(() => buildMoodMapForYear(entries, viewYear), [entries, viewYear]);
const monthRows = useMemo(() => buildYearCells(viewYear, moodByDay), [viewYear, moodByDay]);
const hasAnyMoods = monthRows.some((month) =>
month.some((cell) => cell.valid && cell.moodIds.length > 0),
);
const gridStyle = isHorizontal
? {
gridTemplateColumns: "minmax(2rem, 2.4rem) repeat(31, minmax(0, 1fr))",
}
: {
gridTemplateColumns: "minmax(1.1rem, 1.4rem) repeat(12, minmax(0, 1fr))",
};
return (
<div
className="rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-2.5 min-w-0"
style={{ borderColor: "var(--hair)" }}
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5">
<div className="eyebrow">Moods this year</div>
<div className="flex flex-wrap items-center gap-1">
{MOOD_CATALOG.map((mood) => (
<span
key={mood.id}
className="inline-block size-2 shrink-0 rounded-full"
style={{ background: mood.color }}
title={mood.label}
/>
))}
</div>
</div>
{!hasAnyMoods ? (
<p className="muted mb-2 text-[11px]">
No moods logged this year use the arrows to browse other years.
</p>
) : null}
<div ref={containerRef} className="grid w-full min-w-0 gap-px" style={gridStyle}>
{isHorizontal ? (
<>
<div className="h-3" />
{Array.from({ length: DAYS_IN_GRID }, (_, index) => {
const day = index + 1;
const showLabel = day === 1 || day % 5 === 0;
return (
<div
key={`day-head-${day}`}
className="flex items-end justify-center text-[clamp(7px,1.6vw,10px)] leading-none text-muted-foreground tabular-nums"
>
{showLabel ? day : null}
</div>
);
})}
{monthRows.map((cells, month) => (
<Fragment key={MONTH_SHORT[month]}>
<div className="flex items-center pr-0.5 text-[clamp(8px,1.8vw,11px)] leading-none text-muted-foreground">
{MONTH_SHORT[month]}
</div>
{cells.map((cell) => (
<YearCellSquare
key={cell.key}
cell={cell}
selectedDay={selectedDay}
onSelectDay={onSelectDay}
clickableDays={clickableDays}
/>
))}
</Fragment>
))}
</>
) : (
<>
<div className="h-2.5" />
{MONTH_INITIALS.map((label, month) => (
<div
key={`${label}-${month}`}
className="flex items-end justify-center text-[clamp(7px,2.4vw,10px)] leading-none text-muted-foreground"
>
{label}
</div>
))}
{Array.from({ length: DAYS_IN_GRID }, (_, index) => {
const day = index + 1;
return (
<Fragment key={day}>
<div className="flex items-center justify-end pr-px text-[clamp(7px,2.4vw,10px)] leading-none text-muted-foreground tabular-nums">
{day}
</div>
{monthRows.map((month) => (
<YearCellSquare
key={month[index]!.key}
cell={month[index]!}
selectedDay={selectedDay}
onSelectDay={onSelectDay}
clickableDays={clickableDays}
/>
))}
</Fragment>
);
})}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,68 @@
.stress-slider-input {
-webkit-appearance: none;
appearance: none;
height: 10px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--stress-color) 0%,
var(--stress-color) var(--stress-fill),
color-mix(in srgb, var(--hair) 88%, transparent) var(--stress-fill),
color-mix(in srgb, var(--hair) 88%, transparent) 100%
);
outline: none;
cursor: pointer;
transition: background 0.15s ease;
}
.stress-slider-input:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.stress-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--card);
border: 2.5px solid var(--stress-color);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 0 0 4px color-mix(in srgb, var(--stress-color) 18%, transparent);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.stress-slider-input:focus-visible::-webkit-slider-thumb {
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 0 0 4px color-mix(in srgb, var(--stress-color) 28%, transparent);
}
.stress-slider-input::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--card);
border: 2.5px solid var(--stress-color);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 0 0 4px color-mix(in srgb, var(--stress-color) 18%, transparent);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.stress-slider-input:focus-visible::-moz-range-thumb {
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 0 0 4px color-mix(in srgb, var(--stress-color) 28%, transparent);
}
.stress-slider-input::-moz-range-track {
height: 10px;
border-radius: 999px;
background: transparent;
}
@@ -0,0 +1,79 @@
"use client";
import type { CSSProperties } from "react";
import "./stress-slider.css";
type StressBand = {
max: number;
label: string;
emoji: string;
color: string;
};
const STRESS_BANDS: StressBand[] = [
{ max: 3, label: "Low", emoji: "😌", color: "#64b5f6" },
{ max: 5, label: "Mild", emoji: "😐", color: "#90a4ae" },
{ max: 7, label: "Moderate", emoji: "😣", color: "#f4b740" },
{ max: 9, label: "High", emoji: "😰", color: "#ff8c42" },
{ max: 10, label: "Peak", emoji: "🔥", color: "#e57373" },
];
function stressBand(value: number): StressBand {
return STRESS_BANDS.find((band) => value <= band.max) ?? STRESS_BANDS[STRESS_BANDS.length - 1]!;
}
type Props = {
id: string;
value: number;
onChange: (value: number) => void;
disabled?: boolean;
};
export function StressSlider({ id, value, onChange, disabled }: Props) {
const band = stressBand(value);
const fillPercent = `${((value - 1) / 9) * 100}%`;
return (
<div className="stress-slider grid gap-2">
<div className="flex items-center justify-between gap-2">
<span
className="inline-flex items-center gap-1.5 rounded-full border-[0.5px] px-2.5 py-1 text-[12px] font-medium"
style={{
borderColor: `${band.color}66`,
background: `${band.color}18`,
color: "var(--ink)",
}}
>
<span aria-hidden>{band.emoji}</span>
<span>{band.label}</span>
</span>
<span className="text-[13px] font-medium tabular-nums" style={{ color: band.color }}>
{value}/10
</span>
</div>
<input
id={id}
type="range"
min={1}
max={10}
step={1}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
className="stress-slider-input w-full"
style={
{
"--stress-fill": fillPercent,
"--stress-color": band.color,
} as CSSProperties
}
aria-valuetext={`${band.label}, ${value} out of 10`}
/>
<div className="flex justify-between px-0.5 text-[10px] text-muted-foreground">
<span>Calm</span>
<span>Mid</span>
<span>Peak</span>
</div>
</div>
);
}
+17
View File
@@ -8,3 +8,20 @@ export function toDayKey(date: Date): string {
export function recordedDayKey(iso: string): string {
return toDayKey(new Date(iso));
}
export function dayKeyToMonthStart(dayKey: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayKey);
if (!match) return null;
return new Date(Number(match[1]), Number(match[2]) - 1, 1);
}
export function formatDayKeyLabel(dayKey: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayKey);
if (!match) return dayKey;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
}
export function buildEntryDaySet(entries: Array<{ recordedAt: string }>): Set<string> {
return new Set(entries.map((entry) => recordedDayKey(entry.recordedAt)));
}
+108 -1
View File
@@ -1,4 +1,72 @@
import type { ModuleManifest } from "../_core/module";
import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod";
import { getMoodById } from "./mood-catalog";
import { MoodTrackerWidget } from "./components/mood-tracker-widget";
import { listMoodTrackerEntries, listWidgetJournalEntries } from "./server/queries";
const recentEntriesWidgetConfigSchema = z.object({
limit: z.number().int().min(1).max(50).optional(),
});
const moodTrackerWidgetConfigSchema = z.object({
view: z.enum(["year", "month"]),
});
async function RecentEntriesWidget({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
const parsed = recentEntriesWidgetConfigSchema.parse(config);
const entries = await listWidgetJournalEntries(ctx, { limit: parsed.limit ?? 5 });
if (entries.length === 0) {
return <p className="text-sm text-[var(--ink-mute)]">No journal entries yet</p>;
}
return (
<ul className="grid gap-1.5 min-w-0">
{entries.map((entry) => {
const moodLabel = entry.moods
.map((id) => getMoodById(id))
.filter(Boolean)
.map((mood) => mood!.emoji)
.join(" ");
const when = new Date(entry.recordedAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return (
<li key={entry.id} className="min-w-0">
<a
href={`/journal/${entry.id}`}
className="flex items-start justify-between gap-3 rounded-md px-2 py-1.5 hover:bg-[var(--shade)] min-w-0"
>
<div className="min-w-0">
<span className="block text-sm truncate">{entry.title || "Untitled entry"}</span>
<span className="block text-[12px] text-[var(--ink-mute)] mt-0.5">{when}</span>
</div>
<span className="text-base shrink-0">{moodLabel || "—"}</span>
</a>
</li>
);
})}
</ul>
);
}
async function MoodTrackerDashboardWidget({
config,
ctx,
}: {
config: unknown;
ctx: WidgetContext;
}) {
const parsed = moodTrackerWidgetConfigSchema.parse(config);
const entries = await listMoodTrackerEntries(ctx);
return <MoodTrackerWidget entries={entries} view={parsed.view} />;
}
const manifest: ModuleManifest = {
id: "journal",
@@ -18,6 +86,45 @@ const manifest: ModuleManifest = {
},
},
],
dashboardWidgets: [
{
id: "journal.recent",
title: "Recent journal entries",
description: "Latest entry titles with mood emojis.",
category: "Journal",
defaultSize: { w: 4, h: 3 },
minSize: { w: 3, h: 2 },
defaultPriority: 45,
configSchema: recentEntriesWidgetConfigSchema,
defaultConfig: { limit: 5 },
resolveConfigOptions: async () => undefined,
render: (props) => <RecentEntriesWidget {...props} />,
},
{
id: "journal.mood-tracker",
title: "Mood tracker",
description: "Year-in-pixels or month calendar view of moods.",
category: "Journal",
defaultSize: { w: 6, h: 4 },
minSize: { w: 4, h: 3 },
defaultPriority: 46,
configSchema: moodTrackerWidgetConfigSchema,
defaultConfig: { view: "year" },
resolveConfigOptions: async () => ({
view: ["year", "month"],
}),
render: (props) => <MoodTrackerDashboardWidget {...props} />,
},
],
quickAdds: [
{
id: "journal.new-entry",
label: "New journal entry",
icon: "pen-line",
url: "/journal/new",
createKey: "journal.entry",
},
],
};
export default manifest;
+43
View File
@@ -37,3 +37,46 @@ export function averageMoodScore(moodIds: string[]): number | null {
export function validateMoodIds(moodIds: string[]): string[] {
return moodIds.filter((id) => moodById.has(id));
}
function parseHexColor(hex: string): { r: number; g: number; b: number } | null {
const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
if (!match) return null;
return {
r: Number.parseInt(match[1]!, 16),
g: Number.parseInt(match[2]!, 16),
b: Number.parseInt(match[3]!, 16),
};
}
function toHexChannel(value: number): string {
const clamped = Math.max(0, Math.min(255, Math.round(value)));
return clamped.toString(16).padStart(2, "0");
}
export function blendMoodColors(moodIds: string[]): string | null {
const channels = moodIds
.map((id) => moodById.get(id)?.color)
.filter((color): color is string => typeof color === "string")
.map(parseHexColor)
.filter((rgb): rgb is { r: number; g: number; b: number } => rgb !== null);
if (channels.length === 0) return null;
const total = channels.reduce(
(sum, channel) => ({
r: sum.r + channel.r,
g: sum.g + channel.g,
b: sum.b + channel.b,
}),
{ r: 0, g: 0, b: 0 },
);
return `#${toHexChannel(total.r / channels.length)}${toHexChannel(total.g / channels.length)}${toHexChannel(total.b / channels.length)}`;
}
export function moodLabelsForIds(moodIds: string[]): string {
return moodIds
.map((id) => moodById.get(id)?.label)
.filter((label): label is string => typeof label === "string")
.join(", ");
}
+25
View File
@@ -0,0 +1,25 @@
import { recordedDayKey } from "./day-key";
export function buildMoodMapForYear(
entries: Array<{ recordedAt: string; moods: string[] }>,
year: number,
): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
for (const entry of entries) {
const recorded = new Date(entry.recordedAt);
if (recorded.getFullYear() !== year) continue;
const dayKey = recordedDayKey(entry.recordedAt);
const moods = map.get(dayKey) ?? new Set<string>();
for (const moodId of entry.moods) moods.add(moodId);
map.set(dayKey, moods);
}
return map;
}
export function isValidCalendarDate(year: number, month: number, day: number): boolean {
const date = new Date(year, month, day);
return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day;
}
+12
View File
@@ -111,6 +111,18 @@ export async function listJournalEntriesForScope(
return listJournalEntriesForUser(scope.householdId, userId, options);
}
export async function listWidgetJournalEntries(
ctx: { householdId: string; userId: string },
options?: { limit?: number },
) {
const limit = z.number().int().min(1).max(50).optional().parse(options?.limit) ?? 5;
return listJournalEntriesForUser(ctx.householdId, ctx.userId, { limit });
}
export async function listMoodTrackerEntries(ctx: { householdId: string; userId: string }) {
return listJournalEntriesForUser(ctx.householdId, ctx.userId, { limit: 500 });
}
export async function getJournalEntryForScope(scope: ApiAuthContext, id: string) {
const userId = await resolveJournalUserId(scope);
return getJournalEntryForUser(scope.householdId, userId, id);
+20
View File
@@ -0,0 +1,20 @@
export function initialJournalViewDate(
entries: Array<{ recordedAt: string; moods: string[] }>,
): Date {
const anchor = entries.find((entry) => entry.moods.length > 0) ?? entries[0];
if (!anchor) return new Date();
const recorded = new Date(anchor.recordedAt);
return new Date(recorded.getFullYear(), recorded.getMonth(), 1);
}
export function initialJournalViewYear(
entries: Array<{ recordedAt: string; moods: string[] }>,
): number {
const anchor = entries.find((entry) => entry.moods.length > 0) ?? entries[0];
if (!anchor) return new Date().getFullYear();
return new Date(anchor.recordedAt).getFullYear();
}
+23 -6
View File
@@ -1,13 +1,30 @@
import { expect, test } from "@playwright/test";
test("assistant chat smoke", async ({ page }) => {
await page.goto("/assistant");
await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
test("assistant bubble is hidden until opted in", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("button", { name: "Open assistant" })).toHaveCount(0);
});
await page.getByLabel("Message").fill("hello assistant");
test("assistant chat smoke after opt-in", async ({ page }) => {
await page.goto("/settings?s=appearance");
const assistantSwitch = page.getByRole("switch", { name: "AI assistant" });
if (!(await assistantSwitch.isChecked())) {
await assistantSwitch.click();
}
await page.goto("/");
await page.getByRole("button", { name: "Open assistant" }).click();
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
await page.getByLabel("Assistant message").fill("hello assistant");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("You")).toBeVisible();
await expect(page.getByText("hello assistant")).toBeVisible();
await expect(page.getByText("Assistant", { exact: true }).nth(1)).toBeVisible();
const dialog = page.getByRole("dialog", { name: "Assistant" });
await expect(dialog.locator(".animate-spin").first()).toBeVisible({ timeout: 5000 });
await expect(dialog.locator(".animate-spin")).toHaveCount(0, { timeout: 30_000 });
await page.reload();
await page.getByRole("button", { name: "Open assistant" }).click();
await expect(page.getByText("hello assistant")).toBeVisible();
});
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { describeToolActivity } from "../../src/modules/agent/tool-labels";
import { encodeSseEvent, thinkingLabel } from "../../src/modules/agent/server/progress";
import { createMockLlmClient } from "../../src/lib/llm/mock";
import { runAgentChat } from "../../src/modules/agent/server/run";
describe("describeToolActivity", () => {
it("uses item text when adding to a list", () => {
const label = describeToolActivity("add_list_item", JSON.stringify({ text: "milk" }));
assert.match(label, /milk/);
});
it("mentions the API path for call_api", () => {
const label = describeToolActivity(
"call_api",
JSON.stringify({ method: "GET", path: "/api/v1/notes" }),
);
assert.match(label, /\/api\/v1\/notes/);
});
});
describe("agent progress", () => {
it("encodes SSE payloads", () => {
const encoded = encodeSseEvent({ type: "thinking", label: "Planning…", round: 0 });
assert.equal(encoded, 'data: {"type":"thinking","label":"Planning…","round":0}\n\n');
});
it("uses different labels per round", () => {
assert.match(thinkingLabel(0), /request/i);
assert.match(thinkingLabel(1), /found/i);
});
it("emits progress events during tool execution", async () => {
const events: string[] = [];
await runAgentChat({
messages: [{ role: "user", content: "add milk to the shopping list" }],
request: new Request("http://localhost:3000/api/agent/chat"),
llm: createMockLlmClient(),
executeTool: async () =>
JSON.stringify({ status: 201, body: { id: "item-1", text: "milk" } }),
onProgress: (event) => {
if (event.type === "tool") events.push(event.label);
if (event.type === "thinking") events.push(event.label);
},
});
assert.ok(events.some((label) => label.includes("milk")));
assert.ok(events.some((label) => /request|found/i.test(label)));
});
});
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
clearAssistantChat,
loadAssistantChat,
saveAssistantChat,
} from "../../src/modules/agent/assistant-chat-storage";
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
},
configurable: true,
});
describe("assistant chat storage", () => {
it("round-trips messages per user", () => {
storage.clear();
const userId = "user-a";
const messages = [
{ role: "user" as const, content: "hello" },
{ role: "assistant" as const, content: "hi there" },
];
saveAssistantChat(userId, messages);
assert.deepEqual(loadAssistantChat(userId), messages);
});
it("keeps sessions isolated by user id", () => {
storage.clear();
saveAssistantChat("user-a", [{ role: "user", content: "for a" }]);
saveAssistantChat("user-b", [{ role: "user", content: "for b" }]);
assert.deepEqual(loadAssistantChat("user-a"), [{ role: "user", content: "for a" }]);
assert.deepEqual(loadAssistantChat("user-b"), [{ role: "user", content: "for b" }]);
});
it("clears stored chat", () => {
storage.clear();
saveAssistantChat("user-a", [{ role: "user", content: "hello" }]);
clearAssistantChat("user-a");
assert.deepEqual(loadAssistantChat("user-a"), []);
});
});