feat: journal dashboard widgets, agent polish, and edit-mode live previews
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "assistant_enabled" boolean DEFAULT false NOT NULL;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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()} />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (1–10)</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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 "add milk to the shopping list" or "what's on the calendar this
|
||||
week?"
|
||||
</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 "add milk to the shopping list" or "what's on the calendar this
|
||||
week?"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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"), []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user