feat(journal): add per-user mood journal module (task 86)

Ship journal entries with mood tracking, insights, and Recharts charts.

Adds v1 API endpoints per ADR 0005 and migration 0020_journal_entries.
This commit is contained in:
ginnoir
2026-07-04 19:41:15 -05:00
parent 8e2ddd6b72
commit 04ae809e07
34 changed files with 1889 additions and 6 deletions
+3 -1
View File
@@ -17,6 +17,8 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **85 — Rich-text notes** (ADR 0004 accepted `67f6752`). TipTap editor in `src/components/rich-text/`; notes create/edit + index/widget/share surfaces; interactive checklists; DOMPurify sanitization; lazy plain-text→HTML migration; mobile overflow CSS; uploads `?scope=notes`. Unit tests: `rich-text.test.ts`. E2E: `tests/e2e/notes.spec.ts` (formatted note + 375px overflow).
- **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`.
- **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.
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
@@ -62,7 +64,7 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
1. ~~Bugs: tasks 8084~~ — done
2. ~~API foundation: task 87 (+ ADR 0006)~~ — done
3. ~~Shared rich-text + notes overhaul: task 85 (+ ADR 0004)~~ — done
4. Journal: task 86 (+ ADR 0005), including journal API endpoints
4. ~~Journal: task 86 (+ ADR 0005), including journal API endpoints~~ — done
5. LLM agent chat: task 88
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 79, 1112, 1519.
+90
View File
@@ -152,6 +152,32 @@ components:
pinned: { type: boolean, default: false }
remindAt: { type: string, format: date-time, nullable: true }
JournalEntry:
type: object
properties:
id: { type: string, format: uuid }
householdId: { type: string, format: uuid }
userId: { type: string, format: uuid }
recordedAt: { type: string, format: date-time }
title: { type: string, nullable: true }
body: { type: string }
moods: { type: array, items: { type: string } }
stress: { type: integer, minimum: 1, maximum: 10, nullable: true }
pillsTaken: { type: boolean, nullable: true }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
JournalEntryInput:
type: object
required: [recordedAt]
properties:
recordedAt: { type: string, format: date-time }
title: { type: string, nullable: true }
body: { type: string, default: "" }
moods: { type: array, items: { type: string }, default: [] }
stress: { type: integer, minimum: 1, maximum: 10, nullable: true }
pillsTaken: { type: boolean, nullable: true }
GardenContainer:
type: object
properties:
@@ -820,10 +846,74 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/journal/entries:
get:
summary: List journal entries for the authenticated user
tags: [Journal]
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/JournalEntry" }
post:
summary: Create journal entry
tags: [Journal]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/JournalEntryInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/JournalEntry" }
/api/v1/journal/entries/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: Get journal entry
tags: [Journal]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/JournalEntry" }
patch:
summary: Update journal entry
tags: [Journal]
requestBody:
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/JournalEntryInput"
description: All fields optional
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/JournalEntry" }
delete:
summary: Delete journal entry
tags: [Journal]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
tags:
- name: Calendars
- name: Events
- name: Lists
- name: Notes
- name: Journal
- name: Garden
- name: Bangs
+5 -5
View File
@@ -33,11 +33,11 @@ P1 personal journaling with mood/stress/pills tracking and stats, without blocki
## Acceptance criteria
- [ ] ADR 0005 accepted.
- [ ] Create entry with only date/time.
- [ ] Index, detail, mood tracker, and insights views work.
- [ ] Journal API endpoints documented and callable with token auth.
- [ ] E2E happy path green.
- [x] ADR 0005 accepted.
- [x] Create entry with only date/time.
- [x] Index, detail, mood tracker, and insights views work.
- [x] Journal API endpoints documented and callable with token auth.
- [x] E2E happy path green (`tests/e2e/journal.spec.ts` — run locally with dev DB).
## Notes
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE "journal_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"recorded_at" timestamp with time zone NOT NULL,
"title" text,
"body" text DEFAULT '' NOT NULL,
"moods" jsonb DEFAULT '[]'::jsonb NOT NULL,
"stress" smallint,
"pills_taken" boolean,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "journal_entries" ADD CONSTRAINT "journal_entries_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "journal_entries" ADD CONSTRAINT "journal_entries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX "journal_entries_user_recorded_idx" ON "journal_entries" USING btree ("user_id","recorded_at");
--> statement-breakpoint
CREATE INDEX "journal_entries_household_idx" ON "journal_entries" USING btree ("household_id");
+7
View File
@@ -141,6 +141,13 @@
"when": 1751664000000,
"tag": "0019_household_api_tokens",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1751750400000,
"tag": "0020_journal_entries",
"breakpoints": true
}
]
}
+1
View File
@@ -113,6 +113,7 @@
"react-dom": "^19.2.5",
"react-grid-layout": "^2.2.3",
"react-resizable": "^3.1.3",
"recharts": "^3.9.2",
"server-only": "^0.0.1",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
+273
View File
@@ -125,6 +125,9 @@ importers:
react-resizable:
specifier: ^3.1.3
version: 3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts:
specifier: ^3.9.2
version: 3.9.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1)
server-only:
specifier: ^0.0.1
version: 0.0.1
@@ -1853,6 +1856,17 @@ packages:
'@types/react':
optional: true
'@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@release-it/conventional-changelog@11.0.1':
resolution: {integrity: sha512-SHAnHfOFhazpDeYuQSqH8Qm8RJa2oREn2ILSod+9s8dqiA18mBgpPyYlpvRaqISCVerSmLzEZghQBKphkrf4IQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
@@ -1881,6 +1895,12 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -2175,6 +2195,33 @@ packages:
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -2830,6 +2877,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -2877,6 +2968,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -3700,6 +3794,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@11.1.11:
resolution: {integrity: sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3719,6 +3816,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
@@ -4770,6 +4871,18 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -4826,6 +4939,22 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
recharts@3.9.2:
resolution: {integrity: sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -4850,6 +4979,9 @@ packages:
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -5436,6 +5568,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
@@ -6973,6 +7108,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.11
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.2.0
optionalDependencies:
react: 19.2.5
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))':
dependencies:
'@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
@@ -7001,6 +7148,10 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -7292,6 +7443,30 @@ snapshots:
'@types/canvas-confetti@1.9.0': {}
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
@@ -7946,6 +8121,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@4.0.1: {}
@@ -7987,6 +8200,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
decode-uri-component@0.2.2: {}
@@ -8968,6 +9183,8 @@ snapshots:
ignore@7.0.5: {}
immer@11.1.11: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -8985,6 +9202,8 @@ snapshots:
hasown: 2.0.3
side-channel: 1.1.0
internmap@2.0.3: {}
ip-address@10.1.0: {}
ip-address@10.2.0: {}
@@ -10070,6 +10289,15 @@ snapshots:
react-is@16.13.1: {}
react-redux@9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
dependencies:
react: 19.2.5
@@ -10124,6 +10352,32 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
recharts@3.9.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.47.0
eventemitter3: 5.0.4
immer: 11.1.11
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-is: 16.13.1
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
reselect: 5.2.0
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.5)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -10180,6 +10434,8 @@ snapshots:
reselect@5.1.1: {}
reselect@5.2.0: {}
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
@@ -10871,6 +11127,23 @@ snapshots:
vary@1.1.2: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
deleteJournalEntryForScope,
updateJournalEntryForScope,
} from "@/modules/journal/server/actions";
import { updateJournalEntryInput } from "@/modules/journal/server/schemas";
import { getJournalEntryForScope } from "@/modules/journal/server/queries";
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope) => {
const entry = await getJournalEntryForScope(scope, id);
return apiJson(entry);
});
}
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = updateJournalEntryInput.parse({ ...(body as object), id });
const entry = await updateJournalEntryForScope(scope, parsed);
return apiJson(entry);
});
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope) => {
await deleteJournalEntryForScope(scope, { id });
return apiJson({ ok: true });
});
}
+20
View File
@@ -0,0 +1,20 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createJournalEntryForScope } from "@/modules/journal/server/actions";
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 apiJson(entries);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = journalEntryInput.parse(body);
const entry = await createJournalEntryForScope(scope, parsed);
return apiJson(entry, 201);
});
}
+10
View File
@@ -0,0 +1,10 @@
import { notFound } from "next/navigation";
import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
import { getJournalEntry } from "@/modules/journal/server/queries";
export default async function JournalEntryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const entry = await getJournalEntry(id).catch(() => null);
if (!entry) notFound();
return <JournalEntryEditor entry={entry} />;
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { DetailBackLink } from "@/components/detail-back-link";
import { InsightsView } from "@/modules/journal/components/insights-view";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalInsightsPage() {
const entries = await listJournalEntries({ limit: 500 });
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 min-w-0">
<DetailBackLink href="/journal" label="Journal" />
<div className="flex items-center justify-between gap-3">
<h2 className="serif text-[22px] tracking-tight">Insights</h2>
<Link href="/journal/mood" className="text-[13px] text-[var(--accent)] hover:underline">
Mood charts
</Link>
</div>
<InsightsView entries={entries} />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { DetailBackLink } from "@/components/detail-back-link";
import { MoodTrackerView } from "@/modules/journal/components/mood-tracker-view";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalMoodPage() {
const entries = await listJournalEntries({ limit: 500 });
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 min-w-0">
<DetailBackLink href="/journal" label="Journal" />
<div className="flex items-center justify-between gap-3">
<h2 className="serif text-[22px] tracking-tight">Mood tracker</h2>
<Link href="/journal/insights" className="text-[13px] text-[var(--accent)] hover:underline">
View insights
</Link>
</div>
<MoodTrackerView entries={entries} />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
export default function NewJournalEntryPage() {
return <JournalEntryEditor />;
}
+9
View File
@@ -0,0 +1,9 @@
import { recordedDayKey } from "@/modules/journal/day-key";
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} />;
}
+2
View File
@@ -1,5 +1,6 @@
import {
Bell,
BookHeart,
Calendar,
Sprout,
CalendarDays,
@@ -45,6 +46,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
list: ListChecks,
"check-square": CheckSquare,
"file-text": FileText,
"book-heart": BookHeart,
note: FileText,
settings: Settings,
history: History,
+2
View File
@@ -5,6 +5,7 @@ import * as calendarSchema from "@/modules/calendar/schema";
import * as listsSchema from "@/modules/lists/schema";
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"]!);
@@ -14,6 +15,7 @@ const schema = {
...listsSchema,
...notesSchema,
...gardenSchema,
...journalSchema,
};
export const db = drizzle(client, { schema });
+2
View File
@@ -5,6 +5,7 @@ import listsManifest from "./lists/manifest";
import notesManifest from "./notes/manifest";
import gardenManifest from "./garden/manifest";
import bangsManifest from "./bangs/manifest";
import journalManifest from "./journal/manifest";
registerModule(coreManifest);
registerModule(calendarManifest);
@@ -12,3 +13,4 @@ registerModule(listsManifest);
registerModule(notesManifest);
registerModule(gardenManifest);
registerModule(bangsManifest);
registerModule(journalManifest);
@@ -0,0 +1,118 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
type Props = {
entryDays: string[];
selectedDay?: string | null;
onSelectDay?: (day: string | null) => void;
};
const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export function EntryCalendar({ entryDays, selectedDay, onSelectDay }: Props) {
const [viewDate, setViewDate] = useState(() => new Date());
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const daySet = useMemo(() => new Set(entryDays), [entryDays]);
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 }> = [];
for (let i = 0; i < startOffset; i += 1) items.push({ key: `pad-${i}`, day: null });
for (let day = 1; day <= daysInMonth; day += 1) {
const key = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
items.push({ key, day });
}
return items;
}, [year, month]);
function shiftMonth(delta: number) {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + delta, 1));
}
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 flex items-center justify-between">
<Button type="button" variant="ghost" size="sm" onClick={() => shiftMonth(-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={() => shiftMonth(1)}>
<ChevronRight className="size-3.5" />
</Button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-[10px] muted mb-1">
{WEEKDAY_LABELS.map((label) => (
<div key={label}>{label}</div>
))}
</div>
<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 isSelected = selectedDay === cell.key;
const content = (
<span
className="relative flex h-8 w-full items-center justify-center rounded-md 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)" }}
/>
) : null}
</span>
);
if (!hasEntry) {
return (
<div key={cell.key} className="min-w-0">
{content}
</div>
);
}
if (onSelectDay) {
return (
<button
key={cell.key}
type="button"
className="min-w-0"
onClick={() => onSelectDay(isSelected ? null : cell.key)}
>
{content}
</button>
);
}
return (
<Link key={cell.key} href={`/journal?day=${cell.key}`} className="min-w-0">
{content}
</Link>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,193 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Save, Trash2 } from "lucide-react";
import { useState, useTransition } from "react";
import { DetailBackLink } from "@/components/detail-back-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { getMoodById } from "../mood-catalog";
import type { JournalEntryDto } from "../server/queries";
import { createJournalEntry, deleteJournalEntry, updateJournalEntry } from "../server/actions";
import { MoodPicker } from "./mood-picker";
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>
),
},
);
export function JournalEntryEditor({ entry }: { entry?: JournalEntryDto }) {
const router = useRouter();
const [recordedAt, setRecordedAt] = useState(toLocalDateTimeValue(entry?.recordedAt ?? null));
const [title, setTitle] = useState(entry?.title ?? "");
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 [pillsTaken, setPillsTaken] = useState(entry?.pillsTaken ?? false);
const [trackPills, setTrackPills] = useState(entry?.pillsTaken != null);
const [isPending, startTransition] = useTransition();
function saveEntry() {
startTransition(async () => {
const payload = {
recordedAt: new Date(recordedAt),
title: title.trim() || null,
body,
moods,
stress: trackStress ? stress : null,
pillsTaken: trackPills ? pillsTaken : null,
};
if (entry) {
await updateJournalEntry({ id: entry.id, ...payload });
router.refresh();
return;
}
const created = await createJournalEntry(payload);
router.push(`/journal/${created.id}`);
});
}
function removeEntry() {
if (!entry) return;
startTransition(async () => {
await deleteJournalEntry({ id: entry.id });
router.push("/journal");
});
}
const moodSummary = moods
.map((id) => getMoodById(id))
.filter(Boolean)
.map((mood) => mood!.emoji)
.join(" ");
return (
<div className="mx-auto grid w-full max-w-4xl gap-4 min-w-0">
<DetailBackLink href="/journal" label="Journal" />
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="serif text-[24px] font-medium tracking-tight">
{entry ? "Edit entry" : "New entry"}
</h2>
{moodSummary ? <p className="muted text-[13px] mt-1">{moodSummary}</p> : null}
</div>
<div className="flex flex-wrap gap-2">
{entry ? (
<Button variant="destructive" size="sm" onClick={removeEntry} disabled={isPending}>
<Trash2 className="size-3.5" />
Delete entry
</Button>
) : null}
<Button size="sm" onClick={saveEntry} disabled={!recordedAt || isPending}>
<Save className="size-3.5" />
Save entry
</Button>
</div>
</header>
<section
className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)] min-w-0"
style={{ borderColor: "var(--hair)" }}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="journal-recorded-at">Date & time</Label>
<Input
id="journal-recorded-at"
type="datetime-local"
value={recordedAt}
onChange={(event) => setRecordedAt(event.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="journal-title">Title (optional)</Label>
<Input
id="journal-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Morning check-in"
/>
</div>
</div>
<div className="space-y-2">
<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="journal-stress">Stress (110)</Label>
<div className="flex items-center gap-2 text-[12px] muted">
<span>Track</span>
<Switch checked={trackStress} onCheckedChange={setTrackStress} />
</div>
</div>
<input
id="journal-stress"
type="range"
min={1}
max={10}
value={stress}
disabled={!trackStress || isPending}
onChange={(event) => setStress(Number(event.target.value))}
className="w-full"
/>
<div className="text-[12px] muted">{trackStress ? `${stress}/10` : "Not tracked"}</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="journal-pills">Pills today</Label>
<div className="flex items-center gap-2 text-[12px] muted">
<span>Track</span>
<Switch checked={trackPills} onCheckedChange={setTrackPills} />
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Switch
id="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="journal-body">Reflection</Label>
<RichTextEditor
id="journal-body"
aria-label="Reflection"
value={body}
onChange={setBody}
disabled={isPending}
/>
</div>
</section>
</div>
);
}
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);
}
@@ -0,0 +1,82 @@
"use client";
import { recordedDayKey } from "../day-key";
import { getMoodById } from "../mood-catalog";
import {
averageStress,
computeDayStreak,
pillsCorrelation,
stressMoodCorrelation,
topMoodId,
weekOverWeekTrend,
} from "../server/analytics";
import type { JournalEntryDto } from "../server/queries";
type Props = {
entries: JournalEntryDto[];
};
export function InsightsView({ entries }: Props) {
const entryDays = entries.map((entry) => recordedDayKey(entry.recordedAt));
const streak = computeDayStreak(entryDays);
const topMood = topMoodId(entries);
const topMoodDef = topMood ? getMoodById(topMood) : null;
const avgStress = averageStress(entries);
const trend = weekOverWeekTrend(entries);
const pills = pillsCorrelation(entries);
const stressMood = stressMoodCorrelation(entries);
return (
<div className="grid gap-4 sm:grid-cols-2 min-w-0">
<InsightCard label="Day streak" value={String(streak)} />
<InsightCard
label="Top mood"
value={topMoodDef ? `${topMoodDef.emoji} ${topMoodDef.label}` : "—"}
/>
<InsightCard
label="Avg stress"
value={avgStress !== null ? `${avgStress.toFixed(1)}/10` : "—"}
/>
<InsightCard
label="Week trend"
value={
trend.delta === null ? "—" : `${trend.delta > 0 ? "+" : ""}${trend.delta.toFixed(1)} mood`
}
/>
<InsightCard
label="Mood w/ pills"
value={pills.avgMoodWithPills !== null ? pills.avgMoodWithPills.toFixed(1) : "—"}
hint={`${pills.withPillsCount} entries`}
/>
<InsightCard
label="Mood w/o pills"
value={pills.avgMoodWithoutPills !== null ? pills.avgMoodWithoutPills.toFixed(1) : "—"}
hint={`${pills.withoutPillsCount} entries`}
/>
<div
className="sm:col-span-2 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4"
style={{ borderColor: "var(--hair)" }}
>
<h3 className="serif text-[16px] mb-2">Stress mood snapshot</h3>
<p className="text-[13px] muted">
{stressMood.points.length > 0
? `Across ${stressMood.points.length} entries with both stress and moods: avg stress ${stressMood.avgStress?.toFixed(1)}, avg mood ${stressMood.avgMood?.toFixed(1)}.`
: "Track stress and moods on the same entries to populate this summary."}
</p>
</div>
</div>
);
}
function InsightCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
<div
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 min-w-0"
style={{ borderColor: "var(--hair)" }}
>
<div className="eyebrow mb-1">{label}</div>
<div className="serif text-[22px]">{value}</div>
{hint ? <div className="muted text-[11px] mt-1">{hint}</div> : null}
</div>
);
}
@@ -0,0 +1,120 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
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 type { JournalEntryDto } from "../server/queries";
import { EntryCalendar } from "./entry-calendar";
type Props = {
entries: JournalEntryDto[];
entryDays: string[];
};
export function JournalIndex({ entries, entryDays }: Props) {
const [selectedDay, setSelectedDay] = useState<string | null>(null);
const visibleEntries = useMemo(() => {
const filtered = selectedDay
? entries.filter((entry) => recordedDayKey(entry.recordedAt) === selectedDay)
: entries;
return filtered.slice(0, selectedDay ? 50 : 10);
}, [entries, selectedDay]);
return (
<div className="mx-auto grid w-full max-w-5xl gap-5 min-w-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="serif text-[22px] tracking-tight">Journal</h2>
<p className="muted text-[13px] mt-1">
Private mood and reflection log only you can see this.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href="/journal/mood" className={buttonVariants({ variant: "outline", size: "sm" })}>
<LineChart className="size-3.5" />
Mood tracker
</Link>
<Link
href="/journal/insights"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Sparkles className="size-3.5" />
Insights
</Link>
<Link href="/journal/new" className={buttonVariants({ size: "sm" })}>
<Plus className="size-3.5" />
New entry
</Link>
</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="eyebrow">
{selectedDay ? `Entries on ${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} />)
)}
{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>
</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,46 @@
"use client";
import { MOOD_CATALOG } from "../mood-catalog";
type Props = {
value: string[];
onChange: (next: string[]) => void;
disabled?: boolean;
};
export function MoodPicker({ value, onChange, disabled }: Props) {
function toggle(id: string) {
if (disabled) return;
if (value.includes(id)) {
onChange(value.filter((moodId) => moodId !== id));
return;
}
onChange([...value, id]);
}
return (
<div className="flex flex-wrap gap-2">
{MOOD_CATALOG.map((mood) => {
const selected = value.includes(mood.id);
return (
<button
key={mood.id}
type="button"
disabled={disabled}
aria-pressed={selected}
onClick={() => toggle(mood.id)}
className="inline-flex items-center gap-1.5 rounded-full border-[0.5px] px-3 py-1.5 text-[13px] transition-colors disabled:opacity-50"
style={{
borderColor: selected ? mood.color : "var(--hair)",
background: selected ? `${mood.color}22` : "transparent",
color: "var(--ink)",
}}
>
<span>{mood.emoji}</span>
<span>{mood.label}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { entryMoodScore } from "../server/analytics";
import type { JournalEntryDto } from "../server/queries";
type Props = {
entries: JournalEntryDto[];
};
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 stressPoints = entries
.filter((entry) => entry.stress !== null && entryMoodScore(entry) !== null)
.map((entry) => ({
stress: entry.stress as number,
moodScore: entryMoodScore(entry) as number,
}));
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>
<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>
</div>
)}
</section>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export function toDayKey(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function recordedDayKey(iso: string): string {
return toDayKey(new Date(iso));
}
+23
View File
@@ -0,0 +1,23 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "journal",
name: "Journal",
nav: { href: "/journal", label: "Journal", icon: "book-heart" },
entities: [
{
type: "journal.entry",
label: { singular: "Journal entry", plural: "Journal entries" },
share: { canShare: false },
resolveUrl: (id) => `/journal/${id}`,
renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined;
if (entry.action === "create") return `Created journal entry${title ? ` "${title}"` : ""}`;
if (entry.action === "delete") return `Deleted journal entry${title ? ` "${title}"` : ""}`;
return `Updated journal entry${title ? ` "${title}"` : ""}`;
},
},
],
};
export default manifest;
+39
View File
@@ -0,0 +1,39 @@
export type MoodDefinition = {
id: string;
label: string;
emoji: string;
color: string;
score: number;
};
export const MOOD_CATALOG: MoodDefinition[] = [
{ id: "happy", label: "Happy", emoji: "😊", color: "#f4b740", score: 5 },
{ id: "excited", label: "Excited", emoji: "🤩", color: "#ff8c42", score: 5 },
{ id: "grateful", label: "Grateful", emoji: "🙏", color: "#7cb342", score: 4 },
{ id: "calm", label: "Calm", emoji: "😌", color: "#64b5f6", score: 4 },
{ id: "neutral", label: "Neutral", emoji: "😐", color: "#90a4ae", score: 3 },
{ id: "tired", label: "Tired", emoji: "😴", color: "#8d6e63", score: 2 },
{ id: "anxious", label: "Anxious", emoji: "😰", color: "#ab47bc", score: 2 },
{ id: "stressed", label: "Stressed", emoji: "😣", color: "#e57373", score: 1 },
{ id: "sad", label: "Sad", emoji: "😢", color: "#5c6bc0", score: 1 },
{ id: "angry", label: "Angry", emoji: "😠", color: "#d84315", score: 2 },
];
const moodById = new Map(MOOD_CATALOG.map((mood) => [mood.id, mood]));
export function getMoodById(id: string): MoodDefinition | undefined {
return moodById.get(id);
}
export function averageMoodScore(moodIds: string[]): number | null {
if (moodIds.length === 0) return null;
const scores = moodIds
.map((id) => moodById.get(id)?.score)
.filter((score): score is number => typeof score === "number");
if (scores.length === 0) return null;
return scores.reduce((sum, score) => sum + score, 0) / scores.length;
}
export function validateMoodIds(moodIds: string[]): string[] {
return moodIds.filter((id) => moodById.has(id));
}
+38
View File
@@ -0,0 +1,38 @@
import {
boolean,
index,
jsonb,
pgTable,
smallint,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const journalEntries = pgTable(
"journal_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(),
title: text("title"),
body: text("body").notNull().default(""),
moods: jsonb("moods").$type<string[]>().notNull().default([]),
stress: smallint("stress"),
pillsTaken: boolean("pills_taken"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("journal_entries_user_recorded_idx").on(t.userId, t.recordedAt),
index("journal_entries_household_idx").on(t.householdId),
],
);
export type JournalEntry = typeof journalEntries.$inferSelect;
+155
View File
@@ -0,0 +1,155 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { ApiAuthContext } from "@/lib/api-auth";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivityForScope } from "@/modules/_core/activity";
import { validateMoodIds } from "../mood-catalog";
import { journalEntries } from "../schema";
import { journalEntryInput, updateJournalEntryInput } from "./schemas";
import { getJournalEntryForUser, type JournalEntryDto } from "./queries";
import { resolveJournalUserId } from "./scope";
function toScope(ctx: ApiAuthContext) {
return { householdId: ctx.householdId, userId: ctx.userId };
}
export async function createJournalEntryForScope(
scope: ApiAuthContext,
input: z.input<typeof journalEntryInput>,
): Promise<JournalEntryDto> {
const parsed = journalEntryInput.parse(input);
const userId = await resolveJournalUserId(scope);
const moods = validateMoodIds(parsed.moods);
const [row] = await db
.insert(journalEntries)
.values({
householdId: scope.householdId,
userId,
recordedAt: parsed.recordedAt,
title: parsed.title ?? null,
body: parsed.body,
moods,
stress: parsed.stress ?? null,
pillsTaken: parsed.pillsTaken ?? null,
})
.returning();
if (!row) throw new Error("Journal entry was not created");
await logActivityForScope(toScope(scope), {
entityType: "journal.entry",
entityId: row.id,
action: "create",
payload: { title: row.title },
});
return getJournalEntryForUser(scope.householdId, userId, row.id);
}
export async function createJournalEntry(input: z.input<typeof journalEntryInput>) {
const { household, user } = await getCurrentSession();
const entry = await createJournalEntryForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
revalidateJournalPaths();
return entry;
}
export async function updateJournalEntryForScope(
scope: ApiAuthContext,
input: z.input<typeof updateJournalEntryInput>,
): Promise<JournalEntryDto> {
const parsed = updateJournalEntryInput.parse(input);
const userId = await resolveJournalUserId(scope);
await assertCanAccessEntry(parsed.id, scope.householdId, userId);
const [row] = await db
.update(journalEntries)
.set({
recordedAt: parsed.recordedAt,
title: parsed.title === undefined ? undefined : parsed.title,
body: parsed.body,
moods: parsed.moods === undefined ? undefined : validateMoodIds(parsed.moods),
stress: parsed.stress === undefined ? undefined : parsed.stress,
pillsTaken: parsed.pillsTaken === undefined ? undefined : parsed.pillsTaken,
updatedAt: new Date(),
})
.where(
and(
eq(journalEntries.id, parsed.id),
eq(journalEntries.householdId, scope.householdId),
eq(journalEntries.userId, userId),
),
)
.returning();
if (!row) throw new Error("Journal entry was not updated");
await logActivityForScope(toScope(scope), {
entityType: "journal.entry",
entityId: row.id,
action: "update",
payload: { title: row.title },
});
return getJournalEntryForUser(scope.householdId, userId, row.id);
}
export async function updateJournalEntry(input: z.input<typeof updateJournalEntryInput>) {
const { household, user } = await getCurrentSession();
const entry = await updateJournalEntryForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
revalidateJournalPaths(entry.id);
return entry;
}
export async function deleteJournalEntryForScope(scope: ApiAuthContext, input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const userId = await resolveJournalUserId(scope);
const entry = await getJournalEntryForUser(scope.householdId, userId, parsed.id);
await logActivityForScope(toScope(scope), {
entityType: "journal.entry",
entityId: parsed.id,
action: "delete",
payload: { title: entry.title },
});
await db
.delete(journalEntries)
.where(
and(
eq(journalEntries.id, parsed.id),
eq(journalEntries.householdId, scope.householdId),
eq(journalEntries.userId, userId),
),
);
}
export async function deleteJournalEntry(input: { id: string }) {
const { household, user } = await getCurrentSession();
await deleteJournalEntryForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
revalidateJournalPaths(input.id);
}
async function assertCanAccessEntry(entryId: string, householdId: string, userId: string) {
await getJournalEntryForUser(householdId, userId, entryId);
}
function revalidateJournalPaths(entryId?: string) {
revalidatePath("/journal");
revalidatePath("/journal/mood");
revalidatePath("/journal/insights");
if (entryId) revalidatePath(`/journal/${entryId}`);
}
+153
View File
@@ -0,0 +1,153 @@
import { averageMoodScore, getMoodById, MOOD_CATALOG } from "../mood-catalog";
import { toDayKey } from "../day-key";
export type JournalEntryPoint = {
id: string;
recordedAt: string;
moods: string[];
stress: number | null;
pillsTaken: boolean | null;
};
export function entryMoodScore(entry: Pick<JournalEntryPoint, "moods">): number | null {
return averageMoodScore(entry.moods);
}
export function computeDayStreak(entryDays: string[]): number {
if (entryDays.length === 0) return 0;
const days = new Set(entryDays);
const todayKey = toDayKey(new Date());
let cursor = days.has(todayKey) ? new Date() : addDays(new Date(), -1);
let streak = 0;
while (days.has(toDayKey(cursor))) {
streak += 1;
cursor = addDays(cursor, -1);
}
return streak;
}
export function topMoodId(entries: Pick<JournalEntryPoint, "moods">[]): string | null {
const counts = new Map<string, number>();
for (const entry of entries) {
for (const moodId of entry.moods) {
counts.set(moodId, (counts.get(moodId) ?? 0) + 1);
}
}
let best: string | null = null;
let bestCount = 0;
for (const [id, count] of counts) {
if (count > bestCount) {
best = id;
bestCount = count;
}
}
return best;
}
export function averageStress(entries: Pick<JournalEntryPoint, "stress">[]): number | null {
const values = entries.map((e) => e.stress).filter((v): v is number => typeof v === "number");
if (values.length === 0) return null;
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
export function pillsCorrelation(entries: JournalEntryPoint[]) {
const withPills = entries.filter((e) => e.pillsTaken === true);
const withoutPills = entries.filter((e) => e.pillsTaken === false);
const avgWith = averageEntryMoodScores(withPills);
const avgWithout = averageEntryMoodScores(withoutPills);
return {
withPillsCount: withPills.length,
withoutPillsCount: withoutPills.length,
avgMoodWithPills: avgWith,
avgMoodWithoutPills: avgWithout,
};
}
export function stressMoodCorrelation(entries: JournalEntryPoint[]) {
const points = entries
.map((entry) => {
const moodScore = entryMoodScore(entry);
if (moodScore === null || entry.stress === null) return null;
return { stress: entry.stress, moodScore };
})
.filter((point): point is { stress: number; moodScore: number } => point !== null);
if (points.length === 0) {
return { points: [], avgStress: null, avgMood: null };
}
const avgStress = points.reduce((sum, p) => sum + p.stress, 0) / points.length;
const avgMood = points.reduce((sum, p) => sum + p.moodScore, 0) / points.length;
return { points, avgStress, avgMood };
}
export function weekOverWeekTrend(
entries: JournalEntryPoint[],
now = new Date(),
): { current: number | null; previous: number | null; delta: number | null } {
const currentStart = startOfWeekMonday(now);
const currentEnd = addDays(currentStart, 7);
const previousStart = addDays(currentStart, -7);
const currentScores = entries
.filter((e) => inRange(new Date(e.recordedAt), currentStart, currentEnd))
.map(entryMoodScore)
.filter((v): v is number => v !== null);
const previousScores = entries
.filter((e) => inRange(new Date(e.recordedAt), previousStart, currentStart))
.map(entryMoodScore)
.filter((v): v is number => v !== null);
const current =
currentScores.length > 0
? currentScores.reduce((sum, v) => sum + v, 0) / currentScores.length
: null;
const previous =
previousScores.length > 0
? previousScores.reduce((sum, v) => sum + v, 0) / previousScores.length
: null;
const delta = current !== null && previous !== null ? current - previous : null;
return { current, previous, delta };
}
export function moodCatalogForClient() {
return MOOD_CATALOG.map(({ id, label, emoji, color }) => ({ id, label, emoji, color }));
}
export function moodLabel(id: string): string {
return getMoodById(id)?.label ?? id;
}
function averageEntryMoodScores(entries: JournalEntryPoint[]): number | null {
const scores = entries.map(entryMoodScore).filter((v): v is number => v !== null);
if (scores.length === 0) return null;
return scores.reduce((sum, v) => sum + v, 0) / scores.length;
}
function addDays(date: Date, delta: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + delta);
return next;
}
function startOfWeekMonday(date: Date): Date {
const next = new Date(date);
const day = next.getDay();
const diffToMonday = (day + 6) % 7;
next.setHours(0, 0, 0, 0);
next.setDate(next.getDate() - diffToMonday);
return next;
}
function inRange(date: Date, start: Date, end: Date): boolean {
return date >= start && date < end;
}
+133
View File
@@ -0,0 +1,133 @@
"use server";
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import { z } from "zod";
import type { ApiAuthContext } from "@/lib/api-auth";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { validateMoodIds } from "../mood-catalog";
import { journalEntries } from "../schema";
import { resolveJournalUserId } from "./scope";
export type JournalEntryDto = {
id: string;
householdId: string;
userId: string;
recordedAt: string;
title: string | null;
body: string;
moods: string[];
stress: number | null;
pillsTaken: boolean | null;
createdAt: string;
updatedAt: string;
};
export async function listJournalEntriesForUser(
householdId: string,
userId: string,
options?: { limit?: number; from?: Date; to?: Date },
): Promise<JournalEntryDto[]> {
const conditions = [
eq(journalEntries.householdId, householdId),
eq(journalEntries.userId, userId),
];
if (options?.from) conditions.push(gte(journalEntries.recordedAt, options.from));
if (options?.to) conditions.push(lte(journalEntries.recordedAt, options.to));
const rows = await db
.select()
.from(journalEntries)
.where(and(...conditions))
.orderBy(desc(journalEntries.recordedAt))
.limit(options?.limit ?? 500);
return rows.map(toDto);
}
export async function listJournalEntries(options?: { limit?: number }) {
const { household, user } = await getCurrentSession();
return listJournalEntriesForUser(household.id, user.id, options);
}
export async function getJournalEntryForUser(
householdId: string,
userId: string,
id: string,
): Promise<JournalEntryDto> {
const parsed = z.string().uuid().parse(id);
const [row] = await db
.select()
.from(journalEntries)
.where(
and(
eq(journalEntries.id, parsed),
eq(journalEntries.householdId, householdId),
eq(journalEntries.userId, userId),
),
)
.limit(1);
if (!row) throw new Error("Journal entry not found");
return toDto(row);
}
export async function getJournalEntry(id: string) {
const { household, user } = await getCurrentSession();
return getJournalEntryForUser(household.id, user.id, id);
}
export async function listJournalEntryDays(userId: string, year: number, month: number) {
const start = new Date(year, month - 1, 1);
const end = new Date(year, month, 1);
const rows = await db
.select({
day: sql<string>`to_char(${journalEntries.recordedAt} at time zone 'UTC', 'YYYY-MM-DD')`,
})
.from(journalEntries)
.where(
and(
eq(journalEntries.userId, userId),
gte(journalEntries.recordedAt, start),
lte(journalEntries.recordedAt, end),
),
);
return [...new Set(rows.map((row) => row.day))];
}
export async function listJournalEntryDaysForSession(year: number, month: number) {
const { user } = await getCurrentSession();
return listJournalEntryDays(user.id, year, month);
}
export async function listJournalEntriesForScope(
scope: ApiAuthContext,
options?: { limit?: number },
) {
const userId = await resolveJournalUserId(scope);
return listJournalEntriesForUser(scope.householdId, userId, options);
}
export async function getJournalEntryForScope(scope: ApiAuthContext, id: string) {
const userId = await resolveJournalUserId(scope);
return getJournalEntryForUser(scope.householdId, userId, id);
}
function toDto(row: typeof journalEntries.$inferSelect): JournalEntryDto {
return {
id: row.id,
householdId: row.householdId,
userId: row.userId,
recordedAt: row.recordedAt.toISOString(),
title: row.title,
body: row.body,
moods: validateMoodIds(row.moods ?? []),
stress: row.stress,
pillsTaken: row.pillsTaken,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
const moodIdsSchema = z.array(z.string().min(1)).max(12).default([]);
export const journalEntryInput = z.object({
recordedAt: z.coerce.date(),
title: z.string().trim().max(200).nullable().optional(),
body: z.string().max(20000).default(""),
moods: moodIdsSchema,
stress: z.number().int().min(1).max(10).nullable().optional(),
pillsTaken: z.boolean().nullable().optional(),
});
export const updateJournalEntryInput = journalEntryInput.partial().extend({
id: z.string().uuid(),
});
+19
View File
@@ -0,0 +1,19 @@
import { and, eq } from "drizzle-orm";
import type { ApiAuthContext } from "@/lib/api-auth";
import { db } from "@/lib/db";
import { householdMembers } from "@/modules/_core/schema";
export async function resolveJournalUserId(scope: ApiAuthContext): Promise<string> {
if (scope.userId) return scope.userId;
const [member] = await db
.select({ userId: householdMembers.userId })
.from(householdMembers)
.where(
and(eq(householdMembers.householdId, scope.householdId), eq(householdMembers.role, "owner")),
)
.limit(1);
if (!member) throw new Error("No household owner found");
return member.userId;
}
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test";
test("journal happy path", async ({ page }) => {
const suffix = Date.now().toString();
const title = `E2E Journal ${suffix}`;
await page.goto("/journal");
await expect(page.getByRole("heading", { name: "Journal" })).toBeVisible();
await page.getByRole("link", { name: "New entry" }).click();
await expect(page.getByRole("heading", { name: "New entry" })).toBeVisible();
await page.getByLabel("Title (optional)").fill(title);
await page.getByRole("button", { name: "Happy" }).click();
await page.getByRole("button", { name: "Save entry" }).click();
await expect(page).toHaveURL(/\/journal\/[0-9a-f-]+$/);
await expect(page.getByRole("heading", { name: "Edit entry" })).toBeVisible();
await page.goto("/journal");
await expect(page.getByRole("link", { name: new RegExp(title) })).toBeVisible();
await page.getByRole("link", { name: "Mood tracker" }).click();
await expect(page.getByRole("heading", { name: "Mood tracker" })).toBeVisible();
await expect(page.getByText("Mood over time")).toBeVisible();
});
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
computeDayStreak,
entryMoodScore,
pillsCorrelation,
topMoodId,
} from "../../src/modules/journal/server/analytics";
import { toDayKey } from "../../src/modules/journal/day-key";
describe("journal analytics", () => {
it("scores multi-select moods as an average", () => {
const score = entryMoodScore({ moods: ["happy", "stressed"] });
assert.equal(score, 3);
});
it("computes consecutive day streak", () => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const streak = computeDayStreak([toDayKey(today), toDayKey(yesterday)]);
assert.equal(streak, 2);
});
it("finds the most common mood id", () => {
const top = topMoodId([
{ moods: ["happy"], recordedAt: "", stress: null, pillsTaken: null, id: "1" },
{ moods: ["happy", "calm"], recordedAt: "", stress: null, pillsTaken: null, id: "2" },
{ moods: ["sad"], recordedAt: "", stress: null, pillsTaken: null, id: "3" },
]);
assert.equal(top, "happy");
});
it("compares mood averages with and without pills", () => {
const result = pillsCorrelation([
{
id: "1",
recordedAt: "2026-01-01T10:00:00.000Z",
moods: ["happy"],
stress: null,
pillsTaken: true,
},
{
id: "2",
recordedAt: "2026-01-02T10:00:00.000Z",
moods: ["sad"],
stress: null,
pillsTaken: false,
},
]);
assert.equal(result.withPillsCount, 1);
assert.equal(result.withoutPillsCount, 1);
assert.equal(result.avgMoodWithPills, 5);
assert.equal(result.avgMoodWithoutPills, 1);
});
});