From 04ae809e0710c3930b0056fbb1f869fc70223131 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Sat, 4 Jul 2026 19:41:15 -0500 Subject: [PATCH] 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. --- STATUS.md | 4 +- docs/api/openapi.yaml | 90 ++++++ docs/tasks/86-journal-module.md | 10 +- drizzle/0020_journal_entries.sql | 29 ++ drizzle/meta/_journal.json | 7 + package.json | 1 + pnpm-lock.yaml | 273 ++++++++++++++++++ src/app/api/v1/journal/entries/[id]/route.ts | 33 +++ src/app/api/v1/journal/entries/route.ts | 20 ++ src/app/journal/[id]/page.tsx | 10 + src/app/journal/insights/page.tsx | 21 ++ src/app/journal/mood/page.tsx | 21 ++ src/app/journal/new/page.tsx | 5 + src/app/journal/page.tsx | 9 + src/components/nav-icon.tsx | 2 + src/lib/db.ts | 2 + src/modules/index.ts | 2 + .../journal/components/entry-calendar.tsx | 118 ++++++++ .../journal/components/entry-editor.tsx | 193 +++++++++++++ .../journal/components/insights-view.tsx | 82 ++++++ .../journal/components/journal-index.tsx | 120 ++++++++ .../journal/components/mood-picker.tsx | 46 +++ .../journal/components/mood-tracker-view.tsx | 130 +++++++++ src/modules/journal/day-key.ts | 10 + src/modules/journal/manifest.tsx | 23 ++ src/modules/journal/mood-catalog.ts | 39 +++ src/modules/journal/schema.ts | 38 +++ src/modules/journal/server/actions.ts | 155 ++++++++++ src/modules/journal/server/analytics.ts | 153 ++++++++++ src/modules/journal/server/queries.ts | 133 +++++++++ src/modules/journal/server/schemas.ts | 16 + src/modules/journal/server/scope.ts | 19 ++ tests/e2e/journal.spec.ts | 25 ++ tests/unit/journal-analytics.test.ts | 56 ++++ 34 files changed, 1889 insertions(+), 6 deletions(-) create mode 100644 drizzle/0020_journal_entries.sql create mode 100644 src/app/api/v1/journal/entries/[id]/route.ts create mode 100644 src/app/api/v1/journal/entries/route.ts create mode 100644 src/app/journal/[id]/page.tsx create mode 100644 src/app/journal/insights/page.tsx create mode 100644 src/app/journal/mood/page.tsx create mode 100644 src/app/journal/new/page.tsx create mode 100644 src/app/journal/page.tsx create mode 100644 src/modules/journal/components/entry-calendar.tsx create mode 100644 src/modules/journal/components/entry-editor.tsx create mode 100644 src/modules/journal/components/insights-view.tsx create mode 100644 src/modules/journal/components/journal-index.tsx create mode 100644 src/modules/journal/components/mood-picker.tsx create mode 100644 src/modules/journal/components/mood-tracker-view.tsx create mode 100644 src/modules/journal/day-key.ts create mode 100644 src/modules/journal/manifest.tsx create mode 100644 src/modules/journal/mood-catalog.ts create mode 100644 src/modules/journal/schema.ts create mode 100644 src/modules/journal/server/actions.ts create mode 100644 src/modules/journal/server/analytics.ts create mode 100644 src/modules/journal/server/queries.ts create mode 100644 src/modules/journal/server/schemas.ts create mode 100644 src/modules/journal/server/scope.ts create mode 100644 tests/e2e/journal.spec.ts create mode 100644 tests/unit/journal-analytics.test.ts 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 ( +
+
+ +
+ {viewDate.toLocaleDateString(undefined, { month: "long", year: "numeric" })} +
+ +
+ +
+ {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 ( + + ); + } + + 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 ( +
+ +
+
+

+ {entry ? "Edit entry" : "New entry"} +

+ {moodSummary ?

{moodSummary}

: null} +
+
+ {entry ? ( + + ) : null} + +
+
+ +
+
+
+ + setRecordedAt(event.target.value)} + required + /> +
+
+ + setTitle(event.target.value)} + placeholder="Morning check-in" + /> +
+
+ +
+ + +
+ +
+
+
+ +
+ Track + +
+
+ setStress(Number(event.target.value))} + className="w-full" + /> +
{trackStress ? `${stress}/10` : "Not tracked"}
+
+ +
+
+ +
+ Track + +
+
+
+ + {pillsTaken ? "Taken" : "Not taken"} +
+
+
+ +
+ + +
+
+
+ ); +} + +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 ( + + ); + })} +
+ ); +} 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); + }); +});