diff --git a/STATUS.md b/STATUS.md
index 1793018..4077262 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -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 80–84~~ — 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 7–9, 11–12, 15–19.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index bec1348..60e2548 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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
diff --git a/docs/tasks/86-journal-module.md b/docs/tasks/86-journal-module.md
index b6ae913..81c533e 100644
--- a/docs/tasks/86-journal-module.md
+++ b/docs/tasks/86-journal-module.md
@@ -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
diff --git a/drizzle/0020_journal_entries.sql b/drizzle/0020_journal_entries.sql
new file mode 100644
index 0000000..314beb2
--- /dev/null
+++ b/drizzle/0020_journal_entries.sql
@@ -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");
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 04e01b3..5b9ff23 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -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
}
]
}
\ No newline at end of file
diff --git a/package.json b/package.json
index 56f2e5a..ab5e42f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b1a3aa0..d07c2c7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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:
diff --git a/src/app/api/v1/journal/entries/[id]/route.ts b/src/app/api/v1/journal/entries/[id]/route.ts
new file mode 100644
index 0000000..567ef64
--- /dev/null
+++ b/src/app/api/v1/journal/entries/[id]/route.ts
@@ -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 });
+ });
+}
diff --git a/src/app/api/v1/journal/entries/route.ts b/src/app/api/v1/journal/entries/route.ts
new file mode 100644
index 0000000..ab5ff82
--- /dev/null
+++ b/src/app/api/v1/journal/entries/route.ts
@@ -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);
+ });
+}
diff --git a/src/app/journal/[id]/page.tsx b/src/app/journal/[id]/page.tsx
new file mode 100644
index 0000000..11ea70d
--- /dev/null
+++ b/src/app/journal/[id]/page.tsx
@@ -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 ;
+}
diff --git a/src/app/journal/insights/page.tsx b/src/app/journal/insights/page.tsx
new file mode 100644
index 0000000..3a1fbc1
--- /dev/null
+++ b/src/app/journal/insights/page.tsx
@@ -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 (
+
+
+
+
Insights
+
+ Mood charts
+
+
+
+
+ );
+}
diff --git a/src/app/journal/mood/page.tsx b/src/app/journal/mood/page.tsx
new file mode 100644
index 0000000..e91a610
--- /dev/null
+++ b/src/app/journal/mood/page.tsx
@@ -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 (
+
+
+
+
Mood tracker
+
+ View insights
+
+
+
+
+ );
+}
diff --git a/src/app/journal/new/page.tsx b/src/app/journal/new/page.tsx
new file mode 100644
index 0000000..408d1a0
--- /dev/null
+++ b/src/app/journal/new/page.tsx
@@ -0,0 +1,5 @@
+import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
+
+export default function NewJournalEntryPage() {
+ return ;
+}
diff --git a/src/app/journal/page.tsx b/src/app/journal/page.tsx
new file mode 100644
index 0000000..aed0817
--- /dev/null
+++ b/src/app/journal/page.tsx
@@ -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 ;
+}
diff --git a/src/components/nav-icon.tsx b/src/components/nav-icon.tsx
index 35bd1bd..8d7c7e5 100644
--- a/src/components/nav-icon.tsx
+++ b/src/components/nav-icon.tsx
@@ -1,5 +1,6 @@
import {
Bell,
+ BookHeart,
Calendar,
Sprout,
CalendarDays,
@@ -45,6 +46,7 @@ const ICONS: Record> = {
list: ListChecks,
"check-square": CheckSquare,
"file-text": FileText,
+ "book-heart": BookHeart,
note: FileText,
settings: Settings,
history: History,
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 67b87a1..0fc4684 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -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 });
diff --git a/src/modules/index.ts b/src/modules/index.ts
index 303cccc..293ac8d 100644
--- a/src/modules/index.ts
+++ b/src/modules/index.ts
@@ -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);
diff --git a/src/modules/journal/components/entry-calendar.tsx b/src/modules/journal/components/entry-calendar.tsx
new file mode 100644
index 0000000..ab1a377
--- /dev/null
+++ b/src/modules/journal/components/entry-calendar.tsx
@@ -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 (
+
+
+
shiftMonth(-1)}>
+
+
+
+ {viewDate.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
+
+
shiftMonth(1)}>
+
+
+
+
+
+ {WEEKDAY_LABELS.map((label) => (
+
{label}
+ ))}
+
+
+
+ {cells.map((cell) => {
+ if (cell.day === null) return
;
+ const hasEntry = daySet.has(cell.key);
+ const isSelected = selectedDay === cell.key;
+
+ const content = (
+
+ {cell.day}
+ {hasEntry ? (
+
+ ) : null}
+
+ );
+
+ if (!hasEntry) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ if (onSelectDay) {
+ return (
+
onSelectDay(isSelected ? null : cell.key)}
+ >
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/modules/journal/components/entry-editor.tsx b/src/modules/journal/components/entry-editor.tsx
new file mode 100644
index 0000000..96f2438
--- /dev/null
+++ b/src/modules/journal/components/entry-editor.tsx
@@ -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: () => (
+
+ Loading editor…
+
+ ),
+ },
+);
+
+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(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 (
+
+
+
+
+
+
+
+
+ Moods
+
+
+
+
+
+
+
Stress (1–10)
+
+ Track
+
+
+
+
setStress(Number(event.target.value))}
+ className="w-full"
+ />
+
{trackStress ? `${stress}/10` : "Not tracked"}
+
+
+
+
+
Pills today
+
+ Track
+
+
+
+
+
+ {pillsTaken ? "Taken" : "Not taken"}
+
+
+
+
+
+ Reflection
+
+
+
+
+ );
+}
+
+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);
+}
diff --git a/src/modules/journal/components/insights-view.tsx b/src/modules/journal/components/insights-view.tsx
new file mode 100644
index 0000000..d1710e6
--- /dev/null
+++ b/src/modules/journal/components/insights-view.tsx
@@ -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 (
+
+
+
+
+
0 ? "+" : ""}${trend.delta.toFixed(1)} mood`
+ }
+ />
+
+
+
+
Stress ↔ mood snapshot
+
+ {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."}
+
+
+
+ );
+}
+
+function InsightCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
+ return (
+
+
{label}
+
{value}
+ {hint ?
{hint}
: null}
+
+ );
+}
diff --git a/src/modules/journal/components/journal-index.tsx b/src/modules/journal/components/journal-index.tsx
new file mode 100644
index 0000000..fc786e6
--- /dev/null
+++ b/src/modules/journal/components/journal-index.tsx
@@ -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(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 (
+
+
+
+
Journal
+
+ Private mood and reflection log — only you can see this.
+
+
+
+
+
+ Mood tracker
+
+
+
+ Insights
+
+
+
+ New entry
+
+
+
+
+
+
+
+ {selectedDay ? `Entries on ${selectedDay}` : "Recent entries"}
+
+ {visibleEntries.length === 0 ? (
+
+ No entries yet.
+
+ ) : (
+ visibleEntries.map((entry) =>
)
+ )}
+ {entries.length > 10 && !selectedDay ? (
+
Showing latest 10 of {entries.length} entries.
+ ) : null}
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+ {entry.title || "Untitled entry"}
+
+
{when}
+
+
{moodLabel || "—"}
+
+ {entry.body ? (
+ {richTextToPlainText(entry.body, 140)}
+ ) : null}
+
+ );
+}
diff --git a/src/modules/journal/components/mood-picker.tsx b/src/modules/journal/components/mood-picker.tsx
new file mode 100644
index 0000000..b500c14
--- /dev/null
+++ b/src/modules/journal/components/mood-picker.tsx
@@ -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 (
+
+ {MOOD_CATALOG.map((mood) => {
+ const selected = value.includes(mood.id);
+ return (
+ 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)",
+ }}
+ >
+ {mood.emoji}
+ {mood.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/modules/journal/components/mood-tracker-view.tsx b/src/modules/journal/components/mood-tracker-view.tsx
new file mode 100644
index 0000000..13dc9bf
--- /dev/null
+++ b/src/modules/journal/components/mood-tracker-view.tsx
@@ -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 (
+
+
+ Mood over time
+ {moodSeries.length === 0 ? (
+ Log moods on entries to see this chart.
+ ) : (
+
+
+
+
+
+ new Date(value).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ })
+ }
+ tick={{ fontSize: 11 }}
+ />
+
+
+ new Date(Number(value)).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ })
+ }
+ />
+
+
+
+
+ )}
+
+
+
+ Stress vs mood
+ {stressPoints.length === 0 ? (
+ Track stress and moods to see correlation.
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/modules/journal/day-key.ts b/src/modules/journal/day-key.ts
new file mode 100644
index 0000000..dd546b6
--- /dev/null
+++ b/src/modules/journal/day-key.ts
@@ -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));
+}
diff --git a/src/modules/journal/manifest.tsx b/src/modules/journal/manifest.tsx
new file mode 100644
index 0000000..b11a571
--- /dev/null
+++ b/src/modules/journal/manifest.tsx
@@ -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;
diff --git a/src/modules/journal/mood-catalog.ts b/src/modules/journal/mood-catalog.ts
new file mode 100644
index 0000000..7ee9638
--- /dev/null
+++ b/src/modules/journal/mood-catalog.ts
@@ -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));
+}
diff --git a/src/modules/journal/schema.ts b/src/modules/journal/schema.ts
new file mode 100644
index 0000000..4a70bd5
--- /dev/null
+++ b/src/modules/journal/schema.ts
@@ -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().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;
diff --git a/src/modules/journal/server/actions.ts b/src/modules/journal/server/actions.ts
new file mode 100644
index 0000000..3ca9676
--- /dev/null
+++ b/src/modules/journal/server/actions.ts
@@ -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,
+): Promise {
+ 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) {
+ 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,
+): Promise {
+ 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) {
+ 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}`);
+}
diff --git a/src/modules/journal/server/analytics.ts b/src/modules/journal/server/analytics.ts
new file mode 100644
index 0000000..8e6aafe
--- /dev/null
+++ b/src/modules/journal/server/analytics.ts
@@ -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): 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[]): string | null {
+ const counts = new Map();
+ 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[]): 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;
+}
diff --git a/src/modules/journal/server/queries.ts b/src/modules/journal/server/queries.ts
new file mode 100644
index 0000000..cdee2a6
--- /dev/null
+++ b/src/modules/journal/server/queries.ts
@@ -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 {
+ 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 {
+ 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`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(),
+ };
+}
diff --git a/src/modules/journal/server/schemas.ts b/src/modules/journal/server/schemas.ts
new file mode 100644
index 0000000..6386f83
--- /dev/null
+++ b/src/modules/journal/server/schemas.ts
@@ -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(),
+});
diff --git a/src/modules/journal/server/scope.ts b/src/modules/journal/server/scope.ts
new file mode 100644
index 0000000..bd44703
--- /dev/null
+++ b/src/modules/journal/server/scope.ts
@@ -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 {
+ 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;
+}
diff --git a/tests/e2e/journal.spec.ts b/tests/e2e/journal.spec.ts
new file mode 100644
index 0000000..96eae84
--- /dev/null
+++ b/tests/e2e/journal.spec.ts
@@ -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();
+});
diff --git a/tests/unit/journal-analytics.test.ts b/tests/unit/journal-analytics.test.ts
new file mode 100644
index 0000000..d3c9668
--- /dev/null
+++ b/tests/unit/journal-analytics.test.ts
@@ -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);
+ });
+});