Compare commits

..
12 Commits
Author SHA1 Message Date
ginnoir a58a91d8b8 chore: release v0.6.0
CI / checks (push) Failing after 3m1s
CI / build (push) Successful in 5m17s
Release Image / build-and-push (push) Successful in 6m32s
2026-07-04 23:26:40 -05:00
ginnoir e5e509081c feat: per-user assistant name and system prompt customization
CI / checks (push) Failing after 2m12s
CI / build (push) Successful in 4m50s
Each user can rename the AI assistant and edit their own system prompt in Settings.
2026-07-04 23:26:17 -05:00
ginnoir a2e5eb111d docs: registry is primary image source, ghcr is backup mirror
CI / checks (push) Failing after 2m34s
CI / build (push) Successful in 4m38s
2026-07-04 23:03:56 -05:00
ginnoir cf50f20714 docs: record v0.5.6 prod deploy and migration repair
CI / checks (push) Failing after 2m13s
CI / build (push) Successful in 4m36s
2026-07-04 22:49:34 -05:00
ginnoir fd338384b7 chore: release v0.5.6
CI / checks (push) Failing after 2m11s
CI / build (push) Successful in 4m41s
Release Image / build-and-push (push) Successful in 6m22s
2026-07-04 22:43:01 -05:00
ginnoir de738274fa fix: add build-time env vars to docker image build
CI / checks (push) Failing after 2m12s
CI / build (push) Successful in 4m40s
2026-07-04 22:42:53 -05:00
ginnoir 9d13d6a430 chore: release v0.5.5
CI / checks (push) Failing after 2m11s
CI / build (push) Successful in 4m40s
Release Image / build-and-push (push) Failing after 4m8s
2026-07-04 22:40:11 -05:00
ginnoir 060a2f19a9 chore: release v0.5.4
CI / checks (push) Failing after 2m12s
CI / build (push) Successful in 4m39s
2026-07-04 22:39:45 -05:00
ginnoir 6183fb62c8 fix: dashboard edit previews, migration journal, and notes comments
CI / checks (push) Failing after 2m8s
CI / build (push) Successful in 4m42s
Register drizzle journal entries for 0022/0023 so prod migrations apply.

Key edit-mode widget previews by index so bangs.stats shows live data.

Add comments to notes detail pages.
2026-07-04 22:39:21 -05:00
ginnoir 9e66c12eb7 docs: add commit hash for p2 batch 28-31 in status 2026-07-04 22:17:49 -05:00
ginnoir e1c2a090fb feat: p2 batch 28-31 reminders lists comments bang stats
Calendar events support multiple reminder offsets with presets and per-user defaults.

Lists index adds inline task entry and list property editing.

Generic entity comments on list detail. New bangs.stats dashboard widget.

Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
2026-07-04 22:17:35 -05:00
ginnoir 7714b1187c docs: record journal polish commit in status
CI / checks (push) Failing after 2m7s
CI / build (push) Successful in 4m35s
2026-07-04 22:04:10 -05:00
60 changed files with 1828 additions and 170 deletions
+3
View File
@@ -1,5 +1,8 @@
name: Release Image name: Release Image
# Primary release pipeline — pushes to registry.ginnoir.com (prod pulls this).
# GitHub Actions .github/workflows/release.yml mirrors to GHCR as backup only.
on: on:
push: push:
tags: tags:
+4 -1
View File
@@ -1,4 +1,7 @@
name: Release name: Release (GHCR mirror)
# Temporary backup mirror only. Production pulls from registry.ginnoir.com via
# .gitea/workflows/release.yml — do not deploy from ghcr.io directly.
on: on:
push: push:
+1 -1
View File
@@ -67,7 +67,7 @@ src/
### Core primitives every module gets ### Core primitives every module gets
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity. - **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. - **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu. - **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic. - **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic.
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy. - **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
+84
View File
@@ -1,5 +1,89 @@
# Changelog # Changelog
## [0.6.0](https://github.com/ginnoir/famapp/compare/v0.5.6...v0.6.0) (2026-07-05)
### Features
- per-user assistant name and system prompt customization ([e5e5090](https://github.com/ginnoir/famapp/commit/e5e509081c611b8f84e2e529f82bfa5bd7f9da52))
### Documentation
- record v0.5.6 prod deploy and migration repair ([cf50f20](https://github.com/ginnoir/famapp/commit/cf50f2071442948b8d8493b464f74bc05f3900b2))
- registry is primary image source, ghcr is backup mirror ([a2e5eb1](https://github.com/ginnoir/famapp/commit/a2e5eb111d44d7781bc3852a6d4346cb10387b52))
## [0.5.6](https://github.com/ginnoir/famapp/compare/v0.5.5...v0.5.6) (2026-07-05)
### Bug Fixes
- add build-time env vars to docker image build ([de73827](https://github.com/ginnoir/famapp/commit/de738274face84f05a029718cf011c0ef455371d))
## [0.5.5](https://github.com/ginnoir/famapp/compare/v0.5.4...v0.5.5) (2026-07-05)
### Features
- **agent:** add llm assistant chat with api tools (task 88) ([4a924a4](https://github.com/ginnoir/famapp/commit/4a924a41072a43484c009757a1644cd42309fc0e))
- api v1 garden bangs routes and openapi ([e8d13be](https://github.com/ginnoir/famapp/commit/e8d13bede81b5a778118420292e7f35e4e54788e))
- api v1 routes for calendar lists and notes ([d4304b0](https://github.com/ginnoir/famapp/commit/d4304b005cf86d6d3c14497afbb6e0973a2a8d54))
- bang edit and delete ([753f653](https://github.com/ginnoir/famapp/commit/753f653b89f244b263023dbe744e30c5eaf2893f))
- household api token auth foundation ([ea5d1d0](https://github.com/ginnoir/famapp/commit/ea5d1d050ca5e0aa8a532330448e2f2cdbda0c39))
- journal dashboard widgets, agent polish, and edit-mode live previews ([a09747c](https://github.com/ginnoir/famapp/commit/a09747c3142e089199bfd3839631c9d8573175f0))
- **journal:** add per-user mood journal module (task 86) ([04ae809](https://github.com/ginnoir/famapp/commit/04ae809e0710c3930b0056fbb1f869fc70223131))
- **notes:** rich-text editor with tiptap (task 85) ([a4be5d5](https://github.com/ginnoir/famapp/commit/a4be5d5061d41cb9b4ba37fc89adfb68c00466f7))
- p2 batch 28-31 reminders lists comments bang stats ([e1c2a09](https://github.com/ginnoir/famapp/commit/e1c2a090fb379dcd5d453a658a74a5085b473281)), closes [#28](https://github.com/ginnoir/famapp/issues/28) [#29](https://github.com/ginnoir/famapp/issues/29) [#30](https://github.com/ginnoir/famapp/issues/30)
- shared back navigation on detail pages ([d090200](https://github.com/ginnoir/famapp/commit/d090200ec8950d43fe1e1f71bf1248cf71759199))
### Bug Fixes
- dashboard edit mode renders live widgets ([7eeb2f1](https://github.com/ginnoir/famapp/commit/7eeb2f15bcab4cfbb33ba1138449b74d01ff5953))
- dashboard edit previews, migration journal, and notes comments ([6183fb6](https://github.com/ginnoir/famapp/commit/6183fb62c88a51ff967da428edfbf1b8b6d70d84))
- garden container plant count ([700ee29](https://github.com/ginnoir/famapp/commit/700ee29f83dd8e6b3580168d5944672c3441a8da))
- quick add opens create ui ([68a573c](https://github.com/ginnoir/famapp/commit/68a573c4d656f4a2c25d5b15d4f7ff1849a76bdf))
### Documentation
- accept adr 0004 rich-text editor decisions ([67f6752](https://github.com/ginnoir/famapp/commit/67f67525ff7732868839e0e615078522b57e9ddf))
- accept adr 0005 journal module decisions ([8e2ddd6](https://github.com/ginnoir/famapp/commit/8e2ddd6b7216381e480a657980e5619d488d01c9))
- accept adr 0006 api and agent architecture ([2c0c315](https://github.com/ginnoir/famapp/commit/2c0c31540ed276582984e6dd45f37d7672e60b63))
- add backlog triage implementation plan ([310ba48](https://github.com/ginnoir/famapp/commit/310ba48e335409e108e8b4387acee234db784adb))
- add commit hash for p2 batch 28-31 in status ([9e66c12](https://github.com/ginnoir/famapp/commit/9e66c12eb7afc4dcb6e0203f0e32a5ce8d0200f1))
- add post-v0.1 backlog triage design ([72e868d](https://github.com/ginnoir/famapp/commit/72e868dda1f96dd132d3e1b6bfaa54ed1c5883ef))
- complete Phase 9 backlog triage (issues map, briefs, ADRs) ([76f6854](https://github.com/ginnoir/famapp/commit/76f68548b2609aa8e672dba563acf90a30ba09ed)), closes [1-#37](https://github.com/ginnoir/1-/issues/37)
- mark task 82 done in status ([1a03763](https://github.com/ginnoir/famapp/commit/1a03763e1bf992766dfd7891e3eb815534e92038))
- mark task 83 done in status ([1a1c080](https://github.com/ginnoir/famapp/commit/1a1c080d18e2c22838949dfed8e77a54fab1dcb5))
- mark task 84 done, bugs batch complete ([5e711cf](https://github.com/ginnoir/famapp/commit/5e711cfe0d7bd779114af19fe111375abe269953))
- mark task 87 done in status ([fb67692](https://github.com/ginnoir/famapp/commit/fb67692a08973f9e6e14f7c2ec336cad5bdaf777))
- record journal polish commit in status ([7714b11](https://github.com/ginnoir/famapp/commit/7714b1187c76d1d324573bc8819568751f991b09))
* fix: dashboard edit previews, migration journal, and notes comments (6183fb6)
* docs: add commit hash for p2 batch 28-31 in status (9e66c12)
* feat: p2 batch 28-31 reminders lists comments bang stats (e1c2a09)
* docs: record journal polish commit in status (7714b11)
* feat: journal dashboard widgets, agent polish, and edit-mode live previews (a09747c)
* feat(agent): add llm assistant chat with api tools (task 88) (4a924a4)
* feat(journal): add per-user mood journal module (task 86) (04ae809)
* docs: accept adr 0005 journal module decisions (8e2ddd6)
* feat(notes): rich-text editor with tiptap (task 85) (a4be5d5)
* docs: accept adr 0004 rich-text editor decisions (67f6752)
* docs: mark task 87 done in status (fb67692)
* feat: api v1 garden bangs routes and openapi (e8d13be)
* feat: api v1 routes for calendar lists and notes (d4304b0)
* feat: household api token auth foundation (ea5d1d0)
* docs: accept adr 0006 api and agent architecture (2c0c315)
* docs: mark task 84 done, bugs batch complete (5e711cf)
* feat: shared back navigation on detail pages (d090200)
* docs: mark task 83 done in status (1a1c080)
* feat: bang edit and delete (753f653)
* docs: mark task 82 done in status (1a03763)
* fix: garden container plant count (700ee29)
* fix: dashboard edit mode renders live widgets (7eeb2f1)
* test: cover all quick-add create dialogs in e2e (02b6ec6)
* fix: quick add opens create ui (68a573c)
* docs: complete Phase 9 backlog triage (issues map, briefs, ADRs) (76f6854)
* chore: ignore .worktrees for agent isolation (5dfc8b8)
* docs: add backlog triage implementation plan (310ba48)
* docs: add post-v0.1 backlog triage design (72e868d)
* chore: vendor react-best-practices agent skill for cursor agents (3d825fb)
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04) ## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -68,7 +68,7 @@ src/
### Core primitives every module gets ### Core primitives every module gets
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity. - **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. - **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu. - **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic. - **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic.
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy. - **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
+3
View File
@@ -14,6 +14,9 @@ WORKDIR /app
# CI=true prevents pnpm from prompting for TTY confirmation when removing modules dir # CI=true prevents pnpm from prompting for TTY confirmation when removing modules dir
ENV CI=true ENV CI=true
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL=postgres://build:build@localhost:5432/build
ENV AUTH_SECRET=build-time-placeholder
ENV NEXT_PUBLIC_APP_URL=http://localhost:3000
COPY . . COPY . .
RUN pnpm install --offline --frozen-lockfile RUN pnpm install --offline --frozen-lockfile
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \ RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
+1 -1
View File
@@ -4,7 +4,7 @@ Self-hosted family coordination web app. Shared calendar, lists, notes, and gard
[![CI](https://github.com/ginnoir/famapp/actions/workflows/ci.yml/badge.svg)](https://github.com/ginnoir/famapp/actions/workflows/ci.yml) [![CI](https://github.com/ginnoir/famapp/actions/workflows/ci.yml/badge.svg)](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
[![Latest release](https://img.shields.io/github/v/release/ginnoir/famapp?sort=semver)](https://github.com/ginnoir/famapp/releases) [![Latest release](https://img.shields.io/github/v/release/ginnoir/famapp?sort=semver)](https://github.com/ginnoir/famapp/releases)
[![Docker image](https://img.shields.io/badge/image-ghcr.io%2Fginnoir%2Ffamapp-blue?logo=docker)](https://github.com/ginnoir/famapp/pkgs/container/famapp) [![Docker image](https://img.shields.io/badge/image-registry.ginnoir.com%2Fginnoir%2Ffamapp-blue?logo=docker)](https://gitea.ginnoir.com/ginnoir/famapp)
[![Node ≥ 22](https://img.shields.io/badge/node-%E2%89%A522-brightgreen?logo=node.js)](https://nodejs.org) [![Node ≥ 22](https://img.shields.io/badge/node-%E2%89%A522-brightgreen?logo=node.js)](https://nodejs.org)
--- ---
+12 -2
View File
@@ -21,7 +21,17 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **88 — LLM agent chat** (ADR 0006). Opt-in floating chat bubble; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls; `POST /api/agent/chat`. Per-user `assistant_enabled` (default off). Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`. Migration `0021`. - **88 — LLM agent chat** (ADR 0006). Opt-in floating chat bubble; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls; `POST /api/agent/chat`. Per-user `assistant_enabled` (default off). Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`. Migration `0021`.
- **Journal + dashboard polish** (follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open. - **Journal + dashboard polish** (commit `a09747c`, follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open.
- **89 — Calendar reminders overhaul** (Gitea #28, commit `e1c2a09`). Multiple reminders per event via `syncCalendarEventReminders`; `ReminderPicker` with presets + custom offsets; migration `0022_multiple_reminders.sql` drops one-per-entity unique index, adds `offset_minutes` + `users.default_event_reminder_offsets`. Settings → Notifications default reminders editor. Create/edit/quick-add calendar flows updated; drag-reschedule resyncs existing offsets.
- **90 — Lists index inline add + edit** (Gitea #29, commit `e1c2a09`). `/lists` cards: inline task add row, pencil edit for name/type via `updateListProperties`.
- **91 — Comments on lists and tasks** (Gitea #30, commit `e1c2a09`). Generic `comments` table + `_core/comments.ts`; reusable `EntityComments` on list detail (list + per-item compact threads). Migration `0023_comments.sql`.
- **92 — Bang stats dashboard widget** (Gitea #31, commit `e1c2a09`). `bangs.stats` widget with monthly/yearly counts, average days between bangs, recent month breakdown.
- **P2 batch follow-ups** (release **v0.5.6**, prod deployed 2026-07-05). Migration journal entries for `0022`/`0023`; dashboard edit previews keyed by placement index (`bangs.stats` live in edit mode); notes comments; Dockerfile build-time env vars (fixes release CI). **Prod:** pulled `ghcr.io/ginnoir/famapp:latest` → tagged `registry.ginnoir.com/ginnoir/famapp:latest`, recreated `famapp` container. Prod DB had 21/24 migration rows (0022/0023 applied out of order); applied missing `0019``0021` schema + journal rows — now 24 migrations, `comments` + `default_event_reminder_offsets` confirmed.
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green. - **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. - **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.
@@ -73,7 +83,7 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 79, 1112, 1519. P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 79, 1112, 1519.
**How to resume:** Phase 9 P1 batch complete. Next work is P2/P3 Gitea backlog or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`). **How to resume:** P2 batch #28#31 complete (released + deployed v0.5.6 on prod). Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
## Development login/testing notes ## Development login/testing notes
+5 -1
View File
@@ -15,12 +15,16 @@ pnpm release:patch # or :minor / :major
# Requires GITHUB_TOKEN in .env # Requires GITHUB_TOKEN in .env
``` ```
Gitea Actions (`release.yml`) then builds and pushes the Docker image to the self-hosted registry: Gitea Actions (`.gitea/workflows/release.yml`) builds and pushes the Docker image to the **self-hosted registry** (production source of truth):
- `registry.ginnoir.com/ginnoir/famapp:v0.x.y` - `registry.ginnoir.com/ginnoir/famapp:v0.x.y`
- `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias) - `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias)
- `registry.ginnoir.com/ginnoir/famapp:latest` - `registry.ginnoir.com/ginnoir/famapp:latest`
GitHub Actions (`.github/workflows/release.yml`) mirrors the same tag to **GHCR** as a temporary backup only — prod must not pull from `ghcr.io`.
Wait for the Gitea Actions run to finish (or confirm the new digest on the registry) before redeploying.
## Deploying a release ## Deploying a release
On the home server, in `/srv/famapp/deploy/`: On the home server, in `/srv/famapp/deploy/`:
@@ -0,0 +1,39 @@
# 89 — Calendar reminders overhaul
## Goal
Replace the single "Remind me 30 min before" checkbox with multiple reminders, presets, and custom offsets.
## What we decided
- Store multiple reminder rows per calendar event in the generic `reminders` table (drop one-per-entity unique constraint).
- Offsets are minutes before event start; presets + custom value/unit in UI.
- Per-user default offsets live on `users.default_event_reminder_offsets` (jsonb, default `[30]`).
- Notes/garden keep single-reminder behavior via delete-then-insert in `scheduleReminder`.
## Depends on
- 41 (reminders engine)
## Scope
- Migration: drop `reminders_entity_unique`, add `offset_minutes`, add user default column.
- `_core/reminders.ts`: `syncRemindersForEntity`, refactor `scheduleReminder`.
- Shared `ReminderPicker` component + offset helpers.
- Calendar create/edit + quick-add dialogs; sync reminders on create/update/delete.
- Optional: settings field for default reminder offsets.
## Out of scope
- Recurring reminders, snooze.
## Acceptance criteria
- [x] Users can add/remove multiple reminders on an event
- [x] Preset list and custom offset both work
- [x] Reminders fire via existing notification bus
- [x] Per-user default reminder preferences (stored + applied on new events)
## Notes
- Gitea: [ginnoir/famapp#28](https://gitea.ginnoir.com/ginnoir/famapp/issues/28)
+30
View File
@@ -0,0 +1,30 @@
# 90 — Lists index: inline task add + list property edit
## Goal
From `/lists`, add tasks and edit list properties without opening the full list page.
## What we decided
- Inline add input at the bottom of each list card (Enter to submit, same as detail page).
- Inline edit for list name and type via a small edit control on the card header.
- Reuse existing `addItem`, `renameList`, and extended `updateList` server actions.
## Depends on
- 11 (lists module)
## Scope
- Extend `listUpdateInput` to allow `type` changes.
- Update `lists-index.tsx` with add-item row and list property editor.
## Acceptance criteria
- [x] Add an item to a list from the index
- [x] Edit list properties from the index
- [x] Changes persist and match detail-page behavior
## Notes
- Gitea: [ginnoir/famapp#29](https://gitea.ginnoir.com/ginnoir/famapp/issues/29)
+39
View File
@@ -0,0 +1,39 @@
# 91 — Comments on lists and tasks
## Goal
Household members can comment on lists and individual list items using a reusable comment component.
## What we decided
- Generic `comments` table in `_core` keyed by `(entity_type, entity_id)`.
- Reusable `EntityComments` client component in `src/components/comments/`.
- Wired on list detail: list-level thread + per-item expandable comments.
- Entity-generic design so notes/other modules can adopt later without schema changes.
## Depends on
- 11 (lists module)
## Scope
- Migration: `comments` table.
- `_core/comments.ts`: list, add, delete (author-only).
- `EntityComments` UI.
- Lists detail page integration for `lists.list` and `lists.item`.
## Out of scope
- Comments on notes/calendar (future adoption of same component).
- Rich-text comments (plain text only).
## Acceptance criteria
- [x] Comment on a list
- [x] Comment on a task/item
- [x] Household members see each other's comments
- [x] Component is entity-generic (documented in brief)
## Notes
- Gitea: [ginnoir/famapp#30](https://gitea.ginnoir.com/ginnoir/famapp/issues/30)
+31
View File
@@ -0,0 +1,31 @@
# 92 — Bang stats dashboard widget
## Goal
A second bangs dashboard widget showing monthly/yearly counts and average days between bangs.
## What we decided
- Widget id `bangs.stats`, separate from `bangs.counter`.
- Aggregates computed server-side from `bang_events.occurred_on`.
- Works alongside edit/delete from task 83.
## Depends on
- Bangs module, 83 (edit/delete)
## Scope
- `getBangAggregates` query.
- `BangStatsWidget` component.
- Manifest registration.
## Acceptance criteria
- [x] Widget registered and placeable on dashboards
- [x] Shows monthly and yearly counts
- [x] Shows average days between bangs
## Notes
- Gitea: [ginnoir/famapp#31](https://gitea.ginnoir.com/ginnoir/famapp/issues/31)
+7
View File
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS "reminders_entity_unique";
--> statement-breakpoint
ALTER TABLE "reminders" ADD COLUMN "offset_minutes" integer;
--> statement-breakpoint
CREATE INDEX "reminders_entity_idx" ON "reminders" USING btree ("entity_type","entity_id");
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "default_event_reminder_offsets" jsonb DEFAULT '[30]'::jsonb NOT NULL;
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"entity_type" text NOT NULL,
"entity_id" uuid NOT NULL,
"author_id" uuid NOT NULL,
"body" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "comments_entity_idx" ON "comments" USING btree ("entity_type","entity_id","created_at");
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "assistant_name" text DEFAULT 'Assistant' NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "assistant_system_prompt" text;
+21
View File
@@ -155,6 +155,27 @@
"when": 1751754000000, "when": 1751754000000,
"tag": "0021_user_assistant_enabled", "tag": "0021_user_assistant_enabled",
"breakpoints": true "breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1780392000000,
"tag": "0022_multiple_reminders",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1780393000000,
"tag": "0023_comments",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1780394000000,
"tag": "0024_user_assistant_customization",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "famapp", "name": "famapp",
"version": "0.5.3", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.3", "packageManager": "pnpm@10.33.3",
+7 -3
View File
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { apiError, apiJson } from "@/lib/api-handler"; import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth"; import { resolveApiAuth } from "@/lib/api-auth";
import { getAssistantEnabled } from "@/lib/assistant-preference"; import { getAssistantPreferences, resolveAssistantSystemPrompt } from "@/lib/assistant-preference";
import { isLlmConfigured } from "@/lib/llm"; import { isLlmConfigured } from "@/lib/llm";
import { encodeSseEvent } from "@/modules/agent/server/progress"; import { encodeSseEvent } from "@/modules/agent/server/progress";
import { runAgentChat } from "@/modules/agent/server/run"; import { runAgentChat } from "@/modules/agent/server/run";
@@ -25,11 +25,13 @@ export async function POST(request: Request) {
return apiError("Unauthorized", 401); return apiError("Unauthorized", 401);
} }
const assistantEnabled = await getAssistantEnabled(auth.userId); const assistant = await getAssistantPreferences(auth.userId);
if (!assistantEnabled) { if (!assistant.enabled) {
return apiError("Assistant not enabled", 403); return apiError("Assistant not enabled", 403);
} }
const systemPrompt = resolveAssistantSystemPrompt(assistant.systemPrompt);
let body: unknown; let body: unknown;
try { try {
body = await request.json(); body = await request.json();
@@ -54,6 +56,7 @@ export async function POST(request: Request) {
const result = await runAgentChat({ const result = await runAgentChat({
messages: parsed.data.messages, messages: parsed.data.messages,
request, request,
systemPrompt,
onProgress: send, onProgress: send,
}); });
@@ -87,6 +90,7 @@ export async function POST(request: Request) {
const result = await runAgentChat({ const result = await runAgentChat({
messages: parsed.data.messages, messages: parsed.data.messages,
request, request,
systemPrompt,
}); });
return apiJson({ return apiJson({
+8 -2
View File
@@ -1,5 +1,9 @@
import { CalendarShell } from "@/modules/calendar/components/calendar-shell"; import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
import { listCalendars, listEvents } from "@/modules/calendar/server/queries"; import {
getDefaultEventReminderOffsets,
listCalendars,
listEvents,
} from "@/modules/calendar/server/queries";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import type { CalView } from "@/modules/_core/themes"; import type { CalView } from "@/modules/_core/themes";
@@ -10,10 +14,11 @@ export default async function CalendarPage() {
const to = new Date(now); const to = new Date(now);
to.setMonth(to.getMonth() + 10); to.setMonth(to.getMonth() + 10);
const [{ user }, calendars, events] = await Promise.all([ const [{ user }, calendars, events, defaultReminderOffsets] = await Promise.all([
getCurrentSession(), getCurrentSession(),
listCalendars(), listCalendars(),
listEvents({ from, to, calendarIds: "all" }), listEvents({ from, to, calendarIds: "all" }),
getDefaultEventReminderOffsets(),
]); ]);
return ( return (
@@ -21,6 +26,7 @@ export default async function CalendarPage() {
calendars={calendars} calendars={calendars}
events={events} events={events}
defaultView={user.themeCalView as CalView} defaultView={user.themeCalView as CalView}
defaultReminderOffsets={defaultReminderOffsets}
/> />
); );
} }
+4 -4
View File
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout, widgetContentKey } from "@/lib/dashboard"; import { parseDashboardLayout, widgetContentIndexKey } from "@/lib/dashboard";
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft"; import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server"; import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core"; import { getWidget, getWidgetMetas } from "@/modules/_core";
@@ -58,10 +58,10 @@ export default async function DashboardPage({
if (isEditing) { if (isEditing) {
const ctx = { userId: user.id, householdId: household.id }; const ctx = { userId: user.id, householdId: household.id };
const widgetContents = Object.fromEntries( const widgetContents = Object.fromEntries(
layout.widgets.map((placement) => [ layout.widgets.map((placement, index) => [
widgetContentKey(placement), widgetContentIndexKey(index),
<DashboardWidgetContent <DashboardWidgetContent
key={widgetContentKey(placement)} key={widgetContentIndexKey(index)}
placement={placement} placement={placement}
ctx={ctx} ctx={ctx}
/>, />,
+9 -1
View File
@@ -18,6 +18,7 @@ import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster"; import { AppToaster } from "@/components/app-toaster";
import { AssistantBubble } from "@/modules/agent/components/assistant-bubble"; import { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
import { isLlmConfigured } from "@/lib/llm"; import { isLlmConfigured } from "@/lib/llm";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes"; import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes"; import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -102,6 +103,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let userDashboards: DashboardMeta[] = []; let userDashboards: DashboardMeta[] = [];
let signedIn = false; let signedIn = false;
let assistantEnabled = false; let assistantEnabled = false;
let assistantName = DEFAULT_ASSISTANT_NAME;
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
@@ -114,6 +116,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
themeDensity: users.themeDensity, themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle, themeNavStyle: users.themeNavStyle,
assistantEnabled: users.assistantEnabled, assistantEnabled: users.assistantEnabled,
assistantName: users.assistantName,
}) })
.from(users) .from(users)
.where(eq(users.id, session.user.id)) .where(eq(users.id, session.user.id))
@@ -125,6 +128,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
density = row.themeDensity as Density; density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle; navStyle = row.themeNavStyle as NavStyle;
assistantEnabled = row.assistantEnabled; assistantEnabled = row.assistantEnabled;
assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME;
} }
userDashboards = await db userDashboards = await db
.select({ .select({
@@ -178,7 +182,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<InstallPrompt /> <InstallPrompt />
<PwaRegister /> <PwaRegister />
{signedIn && assistantEnabled && session?.user?.id ? ( {signedIn && assistantEnabled && session?.user?.id ? (
<AssistantBubble configured={isLlmConfigured()} userId={session.user.id} /> <AssistantBubble
configured={isLlmConfigured()}
userId={session.user.id}
assistantName={assistantName}
/>
) : null} ) : null}
<AppToaster position="bottom-right" /> <AppToaster position="bottom-right" />
</QuickAddProvider> </QuickAddProvider>
+3 -2
View File
@@ -1,11 +1,12 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getCurrentSession } from "@/lib/session";
import { ListDetail } from "@/modules/lists/components/list-detail"; import { ListDetail } from "@/modules/lists/components/list-detail";
import { getList } from "@/modules/lists/server/queries"; import { getList } from "@/modules/lists/server/queries";
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) { export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const list = await getList(id).catch(() => null); const [{ user }, list] = await Promise.all([getCurrentSession(), getList(id).catch(() => null)]);
if (!list) notFound(); if (!list) notFound();
return <ListDetail initialList={list} />; return <ListDetail initialList={list} currentUserId={user.id} />;
} }
+3 -2
View File
@@ -1,11 +1,12 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getCurrentSession } from "@/lib/session";
import { NoteEditor } from "@/modules/notes/components/note-editor"; import { NoteEditor } from "@/modules/notes/components/note-editor";
import { getNote } from "@/modules/notes/server/queries"; import { getNote } from "@/modules/notes/server/queries";
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) { export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const note = await getNote(id).catch(() => null); const [{ user }, note] = await Promise.all([getCurrentSession(), getNote(id).catch(() => null)]);
if (!note) notFound(); if (!note) notFound();
return <NoteEditor note={note} />; return <NoteEditor note={note} currentUserId={user.id} />;
} }
+47 -2
View File
@@ -2,13 +2,58 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import {
DEFAULT_ASSISTANT_NAME,
MAX_ASSISTANT_NAME_LENGTH,
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
} from "@/lib/assistant-config";
import { users } from "@/modules/_core/schema"; import { users } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
const assistantNameSchema = z
.string()
.trim()
.min(1, "Name is required")
.max(MAX_ASSISTANT_NAME_LENGTH);
const assistantSystemPromptSchema = z
.string()
.trim()
.min(1, "Prompt cannot be empty")
.max(MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH);
function revalidateAssistantSurfaces() {
revalidatePath("/settings");
revalidatePath("/", "layout");
}
export async function setAssistantEnabled(enabled: boolean): Promise<void> { export async function setAssistantEnabled(enabled: boolean): Promise<void> {
const { user } = await getCurrentSession(); const { user } = await getCurrentSession();
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id)); await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
revalidatePath("/settings"); revalidateAssistantSurfaces();
revalidatePath("/", "layout"); }
export async function setAssistantName(name: string): Promise<void> {
const parsed = assistantNameSchema.parse(name);
const { user } = await getCurrentSession();
await db.update(users).set({ assistantName: parsed }).where(eq(users.id, user.id));
revalidateAssistantSurfaces();
}
export async function setAssistantSystemPrompt(prompt: string | null): Promise<void> {
const { user } = await getCurrentSession();
const normalized = prompt === null ? null : assistantSystemPromptSchema.parse(prompt);
await db.update(users).set({ assistantSystemPrompt: normalized }).where(eq(users.id, user.id));
revalidateAssistantSurfaces();
}
export async function resetAssistantName(): Promise<void> {
await setAssistantName(DEFAULT_ASSISTANT_NAME);
}
export async function resetAssistantSystemPrompt(): Promise<void> {
await setAssistantSystemPrompt(null);
} }
+31 -3
View File
@@ -13,7 +13,9 @@ import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions"; import { revokeShareLinkAction } from "./actions";
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token"; import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
import { ApiTokenSettings } from "@/components/api-token-settings"; import { ApiTokenSettings } from "@/components/api-token-settings";
import { AssistantOptIn } from "@/components/assistant-opt-in"; import { AssistantSettings } from "@/components/assistant-settings";
import { AGENT_SYSTEM_PROMPT } from "@/modules/agent/tools";
import { DefaultEventRemindersSetting } from "@/components/default-event-reminders-setting";
import { listCalendars } from "@/modules/calendar/server/queries"; import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries"; import { listLists } from "@/modules/lists/server/queries";
import Link from "next/link"; import Link from "next/link";
@@ -61,7 +63,12 @@ export default async function SettingsPage({
{section === "household" && <HouseholdSection household={household} user={user} />} {section === "household" && <HouseholdSection household={household} user={user} />}
{section === "sharing" && <SharingSection />} {section === "sharing" && <SharingSection />}
{section === "notifications" && ( {section === "notifications" && (
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} /> <NotificationsSection
user={user}
defaultEventReminderOffsets={user.defaultEventReminderOffsets ?? [30]}
vapidKey={vapidKey}
ntfyConfigured={ntfyConfigured}
/>
)} )}
{section === "calendars" && <CalendarsAndListsSection />} {section === "calendars" && <CalendarsAndListsSection />}
{section === "appearance" && <AppearanceSection user={user} />} {section === "appearance" && <AppearanceSection user={user} />}
@@ -185,10 +192,12 @@ async function SharingSection() {
function NotificationsSection({ function NotificationsSection({
user, user,
defaultEventReminderOffsets,
vapidKey, vapidKey,
ntfyConfigured, ntfyConfigured,
}: { }: {
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean }; user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
defaultEventReminderOffsets: number[];
vapidKey: string; vapidKey: string;
ntfyConfigured: boolean; ntfyConfigured: boolean;
}) { }) {
@@ -216,6 +225,18 @@ function NotificationsSection({
/> />
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Default event reminders</CardTitle>
</CardHeader>
<CardContent>
<DefaultEventRemindersSetting
key={defaultEventReminderOffsets.join(",")}
initialOffsets={defaultEventReminderOffsets}
/>
</CardContent>
</Card>
</> </>
); );
} }
@@ -302,6 +323,8 @@ function AppearanceSection({
themeCalView: string; themeCalView: string;
themeNavStyle: string; themeNavStyle: string;
assistantEnabled: boolean; assistantEnabled: boolean;
assistantName: string;
assistantSystemPrompt: string | null;
}; };
}) { }) {
return ( return (
@@ -335,7 +358,12 @@ function AppearanceSection({
<MessageCircle className="size-4 text-[var(--ink-mute)]" /> <MessageCircle className="size-4 text-[var(--ink-mute)]" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AssistantOptIn enabled={user.assistantEnabled} /> <AssistantSettings
enabled={user.assistantEnabled}
name={user.assistantName}
systemPrompt={user.assistantSystemPrompt}
defaultSystemPrompt={AGENT_SYSTEM_PROMPT}
/>
</CardContent> </CardContent>
</Card> </Card>
</> </>
+21
View File
@@ -0,0 +1,21 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
import { users } from "@/modules/_core/schema";
const offsetsSchema = z.array(z.number().int().min(0)).max(20);
export async function setDefaultEventReminderOffsets(offsets: number[]) {
const { user } = await getCurrentSession();
const parsed = normalizeReminderOffsets(offsetsSchema.parse(offsets));
await db.update(users).set({ defaultEventReminderOffsets: parsed }).where(eq(users.id, user.id));
revalidatePath("/calendar");
revalidatePath("/settings");
}
-35
View File
@@ -1,35 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { setAssistantEnabled } from "@/app/settings/assistant-actions";
import { Switch } from "@/components/ui/switch";
export function AssistantOptIn({ enabled }: { enabled: boolean }) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
function toggle(next: boolean) {
startTransition(async () => {
await setAssistantEnabled(next);
router.refresh();
});
}
return (
<label className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">AI assistant</div>
<p className="muted text-[12px] mt-0.5">
Off by default. Turn on to show a chat bubble in the bottom-right corner.
</p>
</div>
<Switch
checked={enabled}
disabled={isPending}
onCheckedChange={toggle}
aria-label="AI assistant"
/>
</label>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import {
resetAssistantName,
resetAssistantSystemPrompt,
setAssistantEnabled,
setAssistantName,
setAssistantSystemPrompt,
} from "@/app/settings/assistant-actions";
import { DEFAULT_ASSISTANT_NAME, MAX_ASSISTANT_NAME_LENGTH } from "@/lib/assistant-config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
type Props = {
enabled: boolean;
name: string;
systemPrompt: string | null;
defaultSystemPrompt: string;
};
export function AssistantSettings({ enabled, name, systemPrompt, defaultSystemPrompt }: Props) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const effectivePrompt = systemPrompt ?? defaultSystemPrompt;
const [savedName, setSavedName] = useState(name);
const [draftName, setDraftName] = useState(name);
const [savedPrompt, setSavedPrompt] = useState(systemPrompt);
const [draftPrompt, setDraftPrompt] = useState(effectivePrompt);
const nameDirty = draftName.trim() !== savedName;
const promptDirty =
savedPrompt === null
? draftPrompt.trim() !== defaultSystemPrompt.trim()
: draftPrompt.trim() !== savedPrompt.trim();
const usingDefaultPrompt = savedPrompt === null;
function toggle(next: boolean) {
startTransition(async () => {
await setAssistantEnabled(next);
router.refresh();
});
}
function saveName() {
const next = draftName.trim();
if (!next) return;
startTransition(async () => {
await setAssistantName(next);
setSavedName(next);
setDraftName(next);
router.refresh();
});
}
function savePrompt() {
const next = draftPrompt.trim();
if (!next) return;
startTransition(async () => {
const isDefault = next === defaultSystemPrompt.trim();
await setAssistantSystemPrompt(isDefault ? null : next);
setSavedPrompt(isDefault ? null : next);
setDraftPrompt(next);
router.refresh();
});
}
function handleResetName() {
startTransition(async () => {
await resetAssistantName();
setSavedName(DEFAULT_ASSISTANT_NAME);
setDraftName(DEFAULT_ASSISTANT_NAME);
router.refresh();
});
}
function handleResetPrompt() {
startTransition(async () => {
await resetAssistantSystemPrompt();
setSavedPrompt(null);
setDraftPrompt(defaultSystemPrompt);
router.refresh();
});
}
return (
<div className="flex flex-col gap-5">
<label className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">AI assistant</div>
<p className="muted text-[12px] mt-0.5">
Off by default. Turn on to show a chat bubble in the bottom-right corner.
</p>
</div>
<Switch
checked={enabled}
disabled={isPending}
onCheckedChange={toggle}
aria-label="AI assistant"
/>
</label>
<div className="flex flex-col gap-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<label htmlFor="assistant-name" className="text-sm font-medium">
Assistant name
</label>
<p className="muted text-[12px] mt-0.5">Shown in the chat bubble and message labels.</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || savedName === DEFAULT_ASSISTANT_NAME}
onClick={handleResetName}
>
Reset
</Button>
</div>
<Input
id="assistant-name"
value={draftName}
maxLength={MAX_ASSISTANT_NAME_LENGTH}
disabled={isPending}
onChange={(event) => setDraftName(event.target.value)}
aria-label="Assistant name"
/>
<Button
type="button"
size="sm"
disabled={!nameDirty || isPending || !draftName.trim()}
onClick={saveName}
>
{isPending ? "Saving…" : "Save name"}
</Button>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<label htmlFor="assistant-system-prompt" className="text-sm font-medium">
System prompt
</label>
<p className="muted text-[12px] mt-0.5">
Instructions sent to the model before each conversation. Customize tone, priorities,
or household context.
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || usingDefaultPrompt}
onClick={handleResetPrompt}
>
Reset
</Button>
</div>
<textarea
id="assistant-system-prompt"
value={draftPrompt}
rows={10}
disabled={isPending}
onChange={(event) => setDraftPrompt(event.target.value)}
aria-label="Assistant system prompt"
className="input min-h-[180px] resize-y font-mono text-[12px] leading-relaxed"
/>
<Button
type="button"
size="sm"
disabled={!promptDirty || isPending || !draftPrompt.trim()}
onClick={savePrompt}
>
{isPending ? "Saving…" : "Save prompt"}
</Button>
</div>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { MessageSquare, Trash2 } from "lucide-react";
import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { addComment, deleteComment, listComments, type CommentDto } from "@/modules/_core/comments";
type Props = {
entityType: string;
entityId: string;
currentUserId: string;
compact?: boolean;
};
export function EntityComments({ entityType, entityId, currentUserId, compact = false }: Props) {
const [open, setOpen] = useState(!compact);
const [comments, setComments] = useState<CommentDto[]>([]);
const [draft, setDraft] = useState("");
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!open) return;
listComments(entityType, entityId)
.then(setComments)
.catch(() => setComments([]));
}, [entityType, entityId, open]);
function submitComment() {
const body = draft.trim();
if (!body) return;
startTransition(async () => {
const created = await addComment({ entityType, entityId, body });
setComments((current) => [...current, created]);
setDraft("");
});
}
function removeComment(id: string) {
startTransition(async () => {
await deleteComment({ id });
setComments((current) => current.filter((comment) => comment.id !== id));
});
}
return (
<div className={compact ? "mt-1" : "mt-3 border-t border-[var(--hair)] pt-3"}>
{compact ? (
<button
type="button"
className="inline-flex items-center gap-1 text-[12px] text-[var(--ink-mute)] hover:text-[var(--ink)]"
onClick={() => setOpen((value) => !value)}
>
<MessageSquare className="size-3.5" />
{open ? "Hide comments" : "Comments"}
{comments.length > 0 ? ` (${comments.length})` : ""}
</button>
) : (
<div className="eyebrow mb-2">Comments</div>
)}
{open && (
<div className="mt-2 space-y-2">
{comments.length === 0 ? (
<p className="text-[12px] text-[var(--ink-mute)]">No comments yet.</p>
) : (
<ul className="space-y-2">
{comments.map((comment) => (
<li
key={comment.id}
className="rounded-md border border-[var(--hair)] px-2.5 py-2 text-[13px]"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[11px] text-[var(--ink-mute)]">
{comment.authorName ?? "Someone"} · {formatCommentTime(comment.createdAt)}
</div>
<p className="mt-0.5 whitespace-pre-wrap break-words">{comment.body}</p>
</div>
{comment.authorId === currentUserId ? (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Delete comment"
disabled={isPending}
onClick={() => removeComment(comment.id)}
>
<Trash2 className="size-3.5" />
</Button>
) : null}
</div>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Add a comment…"
disabled={isPending}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitComment();
}
}}
/>
<Button
type="button"
size="sm"
disabled={!draft.trim() || isPending}
onClick={submitComment}
>
Post
</Button>
</div>
</div>
)}
</div>
);
}
function formatCommentTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
+24 -4
View File
@@ -9,7 +9,7 @@ import { GridLayout } from "react-grid-layout";
import type { Layout } from "react-grid-layout"; import type { Layout } from "react-grid-layout";
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react"; import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard"; import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
import { computePresetLayoutFromMetas, widgetContentKey } from "@/lib/dashboard"; import { computePresetLayoutFromMetas, widgetContentIndexKey } from "@/lib/dashboard";
import type { SerializedWidgetMeta } from "@/modules/_core/registry"; import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import { import {
saveDashboardLayout, saveDashboardLayout,
@@ -113,10 +113,18 @@ export function DashboardEditor({
function addWidget(widgetId: string, config: unknown) { function addWidget(widgetId: string, config: unknown) {
const meta = widgetMetas.find((m) => m.id === widgetId); const meta = widgetMetas.find((m) => m.id === widgetId);
if (!meta) return; if (!meta) return;
const resolvedConfig = resolveWidgetConfig(meta, config);
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0); const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
const next = [ const next = [
...placements, ...placements,
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h }, {
widgetId,
config: resolvedConfig,
x: 0,
y: maxY,
w: meta.defaultSize.w,
h: meta.defaultSize.h,
},
]; ];
setPlacements(next); setPlacements(next);
setIsDirty(true); setIsDirty(true);
@@ -125,7 +133,9 @@ export function DashboardEditor({
} }
function updateConfig(index: number, config: unknown) { function updateConfig(index: number, config: unknown) {
const next = placements.map((p, i) => (i === index ? { ...p, config } : p)); const meta = widgetMetas.find((m) => m.id === placements[index]?.widgetId);
const resolvedConfig = meta ? resolveWidgetConfig(meta, config) : config;
const next = placements.map((p, i) => (i === index ? { ...p, config: resolvedConfig } : p));
setPlacements(next); setPlacements(next);
setIsDirty(true); setIsDirty(true);
setConfiguringIndex(null); setConfiguringIndex(null);
@@ -237,7 +247,7 @@ export function DashboardEditor({
> >
{placements.map((placement, i) => { {placements.map((placement, i) => {
const meta = widgetMetas.find((m) => m.id === placement.widgetId); const meta = widgetMetas.find((m) => m.id === placement.widgetId);
const content = widgetContents[widgetContentKey(placement)]; const content = widgetContents[widgetContentIndexKey(i)];
return ( return (
<div <div
key={placementKey(placement, i)} key={placementKey(placement, i)}
@@ -308,3 +318,13 @@ export function DashboardEditor({
</div> </div>
); );
} }
function resolveWidgetConfig(meta: SerializedWidgetMeta, config: unknown): unknown {
if (config != null && typeof config === "object" && !Array.isArray(config)) {
return {
...(meta.defaultConfig as Record<string, unknown>),
...(config as Record<string, unknown>),
};
}
return meta.defaultConfig;
}
@@ -0,0 +1,34 @@
"use client";
import { useState, useTransition } from "react";
import { ReminderPicker } from "@/components/reminder-picker";
import { Button } from "@/components/ui/button";
import { setDefaultEventReminderOffsets } from "@/app/settings/reminder-actions";
export function DefaultEventRemindersSetting({ initialOffsets }: { initialOffsets: number[] }) {
const [saved, setSaved] = useState(initialOffsets);
const [offsets, setOffsets] = useState(initialOffsets);
const [isPending, startTransition] = useTransition();
const dirty =
offsets.length !== saved.length || offsets.some((value, index) => value !== saved[index]);
function handleSave() {
startTransition(async () => {
await setDefaultEventReminderOffsets(offsets);
setSaved(offsets);
});
}
return (
<div className="space-y-3">
<p className="muted text-[13px]">
Applied when you create a new calendar event. You can still customize reminders per event.
</p>
<ReminderPicker offsets={offsets} onChange={setOffsets} disabled={isPending} />
<Button type="button" size="sm" disabled={!dirty || isPending} onClick={handleSave}>
{isPending ? "Saving…" : "Save defaults"}
</Button>
</div>
);
}
@@ -2,6 +2,7 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { ReminderPicker } from "@/components/reminder-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -22,7 +23,11 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { createEvent } from "@/modules/calendar/server/actions"; import { createEvent } from "@/modules/calendar/server/actions";
import { richTextToPlainText } from "@/components/rich-text"; import { richTextToPlainText } from "@/components/rich-text";
import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries"; import {
getDefaultEventReminderOffsets,
listCalendars,
type CalendarDto,
} from "@/modules/calendar/server/queries";
const RichTextEditor = dynamic( const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor), () => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
@@ -59,7 +64,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000))); const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [remind, setRemind] = useState(true); const [reminderOffsets, setReminderOffsets] = useState<number[]>([30]);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
useEffect(() => { useEffect(() => {
@@ -67,6 +72,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
setCalendars(rows); setCalendars(rows);
setCalendarId(rows[0]?.id ?? ""); setCalendarId(rows[0]?.id ?? "");
}); });
getDefaultEventReminderOffsets().then(setReminderOffsets);
}, []); }, []);
const calendarItems = useMemo( const calendarItems = useMemo(
@@ -86,7 +92,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
allDay: false, allDay: false,
location: location || null, location: location || null,
notes: richTextToPlainText(notes) ? notes : null, notes: richTextToPlainText(notes) ? notes : null,
remindMinutesBefore: remind ? 30 : null, reminderOffsets,
}); });
onDone(); onDone();
}); });
@@ -171,15 +177,11 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
placeholder="Add details…" placeholder="Add details…"
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm"> <ReminderPicker
<input offsets={reminderOffsets}
type="checkbox" onChange={setReminderOffsets}
className="size-4 cursor-pointer" disabled={isPending}
checked={remind}
onChange={(e) => setRemind(e.target.checked)}
/> />
Remind me 30 min before
</label>
</form> </form>
<DialogFooter> <DialogFooter>
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}> <Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { Plus, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
REMINDER_PRESETS,
customOffsetToMinutes,
formatReminderOffset,
normalizeReminderOffsets,
type ReminderOffsetUnit,
} from "@/lib/reminder-offsets";
type Props = {
offsets: number[];
onChange: (offsets: number[]) => void;
disabled?: boolean;
};
export function ReminderPicker({ offsets, onChange, disabled }: Props) {
const [customValue, setCustomValue] = useState("1");
const [customUnit, setCustomUnit] = useState<ReminderOffsetUnit>("hours");
const normalized = useMemo(() => normalizeReminderOffsets(offsets), [offsets]);
function addOffset(minutes: number) {
onChange(normalizeReminderOffsets([...normalized, minutes]));
}
function removeOffset(minutes: number) {
onChange(normalized.filter((o) => o !== minutes));
}
function addCustom() {
const parsed = Number.parseInt(customValue, 10);
if (!Number.isFinite(parsed) || parsed < 0) return;
addOffset(customOffsetToMinutes(parsed, customUnit));
}
const availablePresets = REMINDER_PRESETS.filter((p) => !normalized.includes(p.offsetMinutes));
return (
<div className="space-y-3">
<Label>Reminders</Label>
{normalized.length === 0 ? (
<p className="text-[13px] text-[var(--ink-mute)]">No reminders set.</p>
) : (
<ul className="space-y-1.5">
{normalized.map((offset) => (
<li
key={offset}
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hair)] px-2.5 py-1.5 text-[13px]"
>
<span>{formatReminderOffset(offset)}</span>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Remove ${formatReminderOffset(offset)}`}
disabled={disabled}
onClick={() => removeOffset(offset)}
>
<X className="size-3.5" />
</Button>
</li>
))}
</ul>
)}
{availablePresets.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{availablePresets.map((preset) => (
<Button
key={preset.offsetMinutes}
type="button"
size="sm"
variant="outline"
disabled={disabled}
onClick={() => addOffset(preset.offsetMinutes)}
>
<Plus className="size-3" />
{preset.label}
</Button>
))}
</div>
)}
<div className="grid gap-2 sm:grid-cols-[80px_120px_auto] sm:items-end">
<div className="space-y-1">
<Label htmlFor="reminder-custom-value">Custom</Label>
<Input
id="reminder-custom-value"
type="number"
min={0}
value={customValue}
disabled={disabled}
onChange={(e) => setCustomValue(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="reminder-custom-unit">Unit</Label>
<Select
value={customUnit}
onValueChange={(value) => setCustomUnit(value as ReminderOffsetUnit)}
disabled={disabled}
>
<SelectTrigger id="reminder-custom-unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="weeks">Weeks</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="button" variant="secondary" disabled={disabled} onClick={addCustom}>
Add custom
</Button>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { AGENT_SYSTEM_PROMPT } from "@/modules/agent/tools";
export const DEFAULT_ASSISTANT_NAME = "Assistant";
export const MAX_ASSISTANT_NAME_LENGTH = 40;
export const MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH = 8000;
export function resolveAssistantSystemPrompt(customPrompt: string | null | undefined): string {
const trimmed = customPrompt?.trim();
return trimmed ? trimmed : AGENT_SYSTEM_PROMPT;
}
+30 -3
View File
@@ -1,13 +1,40 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
import { users } from "@/modules/_core/schema"; import { users } from "@/modules/_core/schema";
export async function getAssistantEnabled(userId: string): Promise<boolean> { export {
DEFAULT_ASSISTANT_NAME,
MAX_ASSISTANT_NAME_LENGTH,
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
resolveAssistantSystemPrompt,
} from "@/lib/assistant-config";
export type AssistantPreferences = {
enabled: boolean;
name: string;
systemPrompt: string | null;
};
export async function getAssistantPreferences(userId: string): Promise<AssistantPreferences> {
const [row] = await db const [row] = await db
.select({ assistantEnabled: users.assistantEnabled }) .select({
assistantEnabled: users.assistantEnabled,
assistantName: users.assistantName,
assistantSystemPrompt: users.assistantSystemPrompt,
})
.from(users) .from(users)
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.limit(1); .limit(1);
return row?.assistantEnabled ?? false; return {
enabled: row?.assistantEnabled ?? false,
name: row?.assistantName?.trim() || DEFAULT_ASSISTANT_NAME,
systemPrompt: row?.assistantSystemPrompt ?? null,
};
}
export async function getAssistantEnabled(userId: string): Promise<boolean> {
const prefs = await getAssistantPreferences(userId);
return prefs.enabled;
} }
+6 -1
View File
@@ -11,7 +11,12 @@ export type WidgetPlacement = {
}; };
export function widgetContentKey(placement: Pick<WidgetPlacement, "widgetId" | "config">): string { export function widgetContentKey(placement: Pick<WidgetPlacement, "widgetId" | "config">): string {
return `${placement.widgetId}::${JSON.stringify(placement.config)}`; return `${placement.widgetId}::${JSON.stringify(placement.config ?? null)}`;
}
/** Stable key for edit-mode widget preview map (index-aligned with layout.widgets). */
export function widgetContentIndexKey(index: number): string {
return String(index);
} }
export type DashboardLayout = { export type DashboardLayout = {
+62
View File
@@ -0,0 +1,62 @@
export type ReminderOffsetUnit = "minutes" | "hours" | "days" | "weeks";
export type ReminderPreset = {
label: string;
offsetMinutes: number;
};
export const REMINDER_PRESETS: ReminderPreset[] = [
{ label: "At event time", offsetMinutes: 0 },
{ label: "5 minutes before", offsetMinutes: 5 },
{ label: "15 minutes before", offsetMinutes: 15 },
{ label: "30 minutes before", offsetMinutes: 30 },
{ label: "1 hour before", offsetMinutes: 60 },
{ label: "2 hours before", offsetMinutes: 120 },
{ label: "1 day before", offsetMinutes: 1440 },
{ label: "2 days before", offsetMinutes: 2880 },
{ label: "1 week before", offsetMinutes: 10080 },
];
export function formatReminderOffset(offsetMinutes: number): string {
const preset = REMINDER_PRESETS.find((p) => p.offsetMinutes === offsetMinutes);
if (preset) return preset.label;
if (offsetMinutes === 0) return "At event time";
if (offsetMinutes % 10080 === 0) {
const weeks = offsetMinutes / 10080;
return weeks === 1 ? "1 week before" : `${weeks} weeks before`;
}
if (offsetMinutes % 1440 === 0) {
const days = offsetMinutes / 1440;
return days === 1 ? "1 day before" : `${days} days before`;
}
if (offsetMinutes % 60 === 0) {
const hours = offsetMinutes / 60;
return hours === 1 ? "1 hour before" : `${hours} hours before`;
}
return offsetMinutes === 1 ? "1 minute before" : `${offsetMinutes} minutes before`;
}
export function customOffsetToMinutes(value: number, unit: ReminderOffsetUnit): number {
const safe = Math.max(0, Math.floor(value));
switch (unit) {
case "minutes":
return safe;
case "hours":
return safe * 60;
case "days":
return safe * 1440;
case "weeks":
return safe * 10080;
}
}
export function normalizeReminderOffsets(offsets: number[]): number[] {
return [...new Set(offsets.filter((n) => Number.isFinite(n) && n >= 0))].toSorted(
(a, b) => b - a,
);
}
export function fireAtForEventStart(startAt: Date, offsetMinutes: number): Date {
return new Date(startAt.getTime() - offsetMinutes * 60_000);
}
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { and, asc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { comments, users } from "./schema";
export type CommentDto = {
id: string;
entityType: string;
entityId: string;
body: string;
authorId: string;
authorName: string | null;
createdAt: string;
};
const commentInput = z.object({
entityType: z.string().trim().min(1).max(80),
entityId: z.string().uuid(),
body: z.string().trim().min(1).max(2000),
});
export async function listComments(entityType: string, entityId: string): Promise<CommentDto[]> {
const { household } = await getCurrentSession();
const parsedEntityId = z.string().uuid().parse(entityId);
const rows = await db
.select({
id: comments.id,
entityType: comments.entityType,
entityId: comments.entityId,
body: comments.body,
authorId: comments.authorId,
authorName: users.name,
createdAt: comments.createdAt,
})
.from(comments)
.innerJoin(users, eq(comments.authorId, users.id))
.where(
and(
eq(comments.householdId, household.id),
eq(comments.entityType, entityType),
eq(comments.entityId, parsedEntityId),
),
)
.orderBy(asc(comments.createdAt));
return rows.map((row) => ({
id: row.id,
entityType: row.entityType,
entityId: row.entityId,
body: row.body,
authorId: row.authorId,
authorName: row.authorName,
createdAt: row.createdAt.toISOString(),
}));
}
export async function addComment(input: z.input<typeof commentInput>): Promise<CommentDto> {
const parsed = commentInput.parse(input);
const { user, household } = await getCurrentSession();
const [row] = await db
.insert(comments)
.values({
householdId: household.id,
entityType: parsed.entityType,
entityId: parsed.entityId,
authorId: user.id,
body: parsed.body,
})
.returning();
if (!row) throw new Error("Comment was not created");
revalidatePath("/lists");
revalidatePath("/notes");
return {
id: row.id,
entityType: row.entityType,
entityId: row.entityId,
body: row.body,
authorId: row.authorId,
authorName: user.name,
createdAt: row.createdAt.toISOString(),
};
}
export async function deleteComment(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { user, household } = await getCurrentSession();
const [existing] = await db
.select({ id: comments.id, authorId: comments.authorId })
.from(comments)
.where(and(eq(comments.id, parsed.id), eq(comments.householdId, household.id)))
.limit(1);
if (!existing) throw new Error("Comment not found");
if (existing.authorId !== user.id) throw new Error("Forbidden");
await db.delete(comments).where(eq(comments.id, parsed.id));
revalidatePath("/lists");
revalidatePath("/notes");
}
+10 -1
View File
@@ -24,4 +24,13 @@ export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share"; export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
export { sendPush, sendPushToEndpoint } from "./push"; export { sendPush, sendPushToEndpoint } from "./push";
export { notify } from "./notify"; export { notify } from "./notify";
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders"; export {
scheduleReminder,
cancelReminder,
listReminders,
listReminderOffsets,
syncRemindersForEntity,
syncCalendarEventReminders,
startReminderWorker,
} from "./reminders";
export { listComments, addComment, deleteComment, type CommentDto } from "./comments";
+110 -12
View File
@@ -1,11 +1,19 @@
import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm"; import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { fireAtForEventStart } from "@/lib/reminder-offsets";
import { reminders } from "./schema"; import { reminders } from "./schema";
import { notify } from "./notify"; import { notify } from "./notify";
const REMINDER_LOCK_KEY = 7_777_777; const REMINDER_LOCK_KEY = 7_777_777;
type ReminderInput = {
fireAt: Date;
offsetMinutes?: number | null;
title?: string;
body?: string;
};
export async function scheduleReminder(input: { export async function scheduleReminder(input: {
householdId: string; householdId: string;
entityType: string; entityType: string;
@@ -15,29 +23,91 @@ export async function scheduleReminder(input: {
channel?: string; channel?: string;
title?: string; title?: string;
body?: string; body?: string;
offsetMinutes?: number | null;
}) { }) {
await db await db
.insert(reminders) .delete(reminders)
.values({ .where(
and(
eq(reminders.entityType, input.entityType),
eq(reminders.entityId, input.entityId),
isNull(reminders.firedAt),
),
);
await db.insert(reminders).values({
householdId: input.householdId, householdId: input.householdId,
entityType: input.entityType, entityType: input.entityType,
entityId: input.entityId, entityId: input.entityId,
fireAt: input.fireAt, fireAt: input.fireAt,
offsetMinutes: input.offsetMinutes ?? null,
channel: input.channel ?? "auto", channel: input.channel ?? "auto",
title: input.title ?? null, title: input.title ?? null,
body: input.body ?? null, body: input.body ?? null,
createdBy: input.createdBy, createdBy: input.createdBy,
firedAt: null, firedAt: null,
}) });
.onConflictDoUpdate({ }
target: [reminders.entityType, reminders.entityId],
set: { export async function syncRemindersForEntity(input: {
fireAt: input.fireAt, householdId: string;
title: input.title ?? null, entityType: string;
body: input.body ?? null, entityId: string;
firedAt: null, createdBy: string;
channel?: string;
reminders: ReminderInput[];
}) {
await db
.delete(reminders)
.where(
and(
eq(reminders.entityType, input.entityType),
eq(reminders.entityId, input.entityId),
isNull(reminders.firedAt),
),
);
const now = new Date();
const rows = input.reminders.filter((r) => r.fireAt > now);
if (rows.length === 0) return;
await db.insert(reminders).values(
rows.map((row) => ({
householdId: input.householdId,
entityType: input.entityType,
entityId: input.entityId,
fireAt: row.fireAt,
offsetMinutes: row.offsetMinutes ?? null,
channel: input.channel ?? "auto",
title: row.title ?? null,
body: row.body ?? null,
createdBy: input.createdBy, createdBy: input.createdBy,
}, firedAt: null,
})),
);
}
export async function syncCalendarEventReminders(input: {
householdId: string;
eventId: string;
eventTitle: string;
startAt: Date;
createdBy: string;
offsetMinutes: number[];
}) {
const remindersToSchedule = input.offsetMinutes.map((offset) => ({
fireAt: fireAtForEventStart(input.startAt, offset),
offsetMinutes: offset,
title: input.eventTitle,
body: formatReminderOffsetBody(offset, input.eventTitle),
}));
await syncRemindersForEntity({
householdId: input.householdId,
entityType: "calendar.event",
entityId: input.eventId,
createdBy: input.createdBy,
reminders: remindersToSchedule,
}); });
} }
@@ -54,6 +124,29 @@ export async function listReminders(entityType: string, entityId: string) {
.where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId))); .where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId)));
} }
export async function listReminderOffsets(entityType: string, entityId: string): Promise<number[]> {
const rows = await listReminders(entityType, entityId);
return rows
.filter((row) => row.offsetMinutes != null && row.firedAt == null)
.map((row) => row.offsetMinutes!)
.toSorted((a, b) => b - a);
}
function formatReminderOffsetBody(offsetMinutes: number, title: string): string {
if (offsetMinutes === 0) return `${title} is starting now`;
if (offsetMinutes % 1440 === 0) {
const days = offsetMinutes / 1440;
return days === 1 ? `${title} starts in 1 day` : `${title} starts in ${days} days`;
}
if (offsetMinutes % 60 === 0) {
const hours = offsetMinutes / 60;
return hours === 1 ? `${title} starts in 1 hour` : `${title} starts in ${hours} hours`;
}
return offsetMinutes === 1
? `${title} starts in 1 minute`
: `${title} starts in ${offsetMinutes} minutes`;
}
async function tickReminders() { async function tickReminders() {
let dueReminders: (typeof reminders.$inferSelect)[] = []; let dueReminders: (typeof reminders.$inferSelect)[] = [];
@@ -94,7 +187,12 @@ async function tickReminders() {
await notify(reminder.createdBy, { await notify(reminder.createdBy, {
title: reminder.title ?? "Reminder", title: reminder.title ?? "Reminder",
body: reminder.body ?? "You have a reminder", body: reminder.body ?? "You have a reminder",
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/", url:
reminder.entityType === "notes.note"
? `/notes/${reminder.entityId}`
: reminder.entityType === "calendar.event"
? "/calendar"
: "/",
channels: ["push", "inapp"], channels: ["push", "inapp"],
}); });
} catch (err) { } catch (err) {
+27 -1
View File
@@ -36,6 +36,12 @@ export const users = pgTable("users", {
notifInApp: boolean("notif_inapp").notNull().default(true), notifInApp: boolean("notif_inapp").notNull().default(true),
notifNtfy: boolean("notif_ntfy").notNull().default(false), notifNtfy: boolean("notif_ntfy").notNull().default(false),
assistantEnabled: boolean("assistant_enabled").notNull().default(false), assistantEnabled: boolean("assistant_enabled").notNull().default(false),
assistantName: text("assistant_name").notNull().default("Assistant"),
assistantSystemPrompt: text("assistant_system_prompt"),
defaultEventReminderOffsets: jsonb("default_event_reminder_offsets")
.notNull()
.$type<number[]>()
.default([30]),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}); });
@@ -170,12 +176,13 @@ export const reminders = pgTable(
channel: text("channel").notNull().default("auto"), channel: text("channel").notNull().default("auto"),
title: text("title"), title: text("title"),
body: text("body"), body: text("body"),
offsetMinutes: integer("offset_minutes"),
firedAt: timestamp("fired_at", { withTimezone: true }), firedAt: timestamp("fired_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, },
(t) => [ (t) => [
uniqueIndex("reminders_entity_unique").on(t.entityType, t.entityId), index("reminders_entity_idx").on(t.entityType, t.entityId),
index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt), index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt),
], ],
); );
@@ -212,6 +219,25 @@ export const notifications = pgTable(
(t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)], (t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)],
); );
export const comments = pgTable(
"comments",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
entityId: uuid("entity_id").notNull(),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
body: text("body").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("comments_entity_idx").on(t.entityType, t.entityId, t.createdAt)],
);
export const householdApiTokens = pgTable( export const householdApiTokens = pgTable(
"household_api_tokens", "household_api_tokens",
{ {
@@ -7,9 +7,10 @@ import { AssistantPanel } from "./assistant-panel";
type Props = { type Props = {
configured: boolean; configured: boolean;
userId: string; userId: string;
assistantName: string;
}; };
export function AssistantBubble({ configured, userId }: Props) { export function AssistantBubble({ configured, userId, assistantName }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@@ -18,31 +19,36 @@ export function AssistantBubble({ configured, userId }: Props) {
<div <div
className="assistant-bubble-panel" className="assistant-bubble-panel"
role="dialog" role="dialog"
aria-label="Assistant" aria-label={assistantName}
aria-modal="false" aria-modal="false"
> >
<div className="assistant-bubble-header"> <div className="assistant-bubble-header">
<div> <div>
<div className="serif text-[15px] font-medium tracking-tight">Assistant</div> <div className="serif text-[15px] font-medium tracking-tight">{assistantName}</div>
<div className="muted text-[11px]">Household helper</div> <div className="muted text-[11px]">Household helper</div>
</div> </div>
<button <button
type="button" type="button"
className="assistant-bubble-close" className="assistant-bubble-close"
aria-label="Close assistant" aria-label={`Close ${assistantName}`}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>
</div> </div>
<AssistantPanel key={userId} configured={configured} userId={userId} /> <AssistantPanel
key={userId}
configured={configured}
userId={userId}
assistantName={assistantName}
/>
</div> </div>
) : null} ) : null}
<button <button
type="button" type="button"
className="assistant-bubble-trigger" className="assistant-bubble-trigger"
aria-label={open ? "Close assistant" : "Open assistant"} aria-label={open ? `Close ${assistantName}` : `Open ${assistantName}`}
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((current) => !current)} onClick={() => setOpen((current) => !current)}
> >
@@ -15,9 +15,10 @@ import {
type Props = { type Props = {
configured: boolean; configured: boolean;
userId: string; userId: string;
assistantName: string;
}; };
export function AssistantPanel({ configured, userId }: Props) { export function AssistantPanel({ configured, userId, assistantName }: Props) {
const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId)); const [messages, setMessages] = useState<AssistantChatMessage[]>(() => loadAssistantChat(userId));
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -144,7 +145,7 @@ export function AssistantPanel({ configured, userId }: Props) {
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined} style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
> >
<div className="eyebrow mb-0.5 text-[10px]"> <div className="eyebrow mb-0.5 text-[10px]">
{message.role === "user" ? "You" : "Assistant"} {message.role === "user" ? "You" : assistantName}
</div> </div>
{message.content} {message.content}
</div> </div>
@@ -157,7 +158,7 @@ export function AssistantPanel({ configured, userId }: Props) {
aria-live="polite" aria-live="polite"
aria-busy="true" aria-busy="true"
> >
<div className="eyebrow mb-1 text-[10px]">Assistant</div> <div className="eyebrow mb-1 text-[10px]">{assistantName}</div>
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground"> <div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" /> <Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{activityLabel ?? "Working…"}</span> <span>{activityLabel ?? "Working…"}</span>
@@ -180,9 +181,9 @@ export function AssistantPanel({ configured, userId }: Props) {
<Input <Input
value={input} value={input}
onChange={(event) => setInput(event.target.value)} onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…" placeholder={`Ask ${assistantName}`}
disabled={isPending} disabled={isPending}
aria-label="Assistant message" aria-label={`Message for ${assistantName}`}
className="h-9" className="h-9"
/> />
<Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send"> <Button type="submit" size="sm" disabled={isPending || !input.trim()} aria-label="Send">
+3 -1
View File
@@ -26,6 +26,7 @@ export type AgentProgressHandler = (event: AgentProgressEvent) => void;
export async function runAgentChat(options: { export async function runAgentChat(options: {
messages: ClientChatMessage[]; messages: ClientChatMessage[];
request: Request; request: Request;
systemPrompt?: string;
llm?: LlmClient; llm?: LlmClient;
executeTool?: ToolExecutor; executeTool?: ToolExecutor;
onProgress?: AgentProgressHandler; onProgress?: AgentProgressHandler;
@@ -33,9 +34,10 @@ export async function runAgentChat(options: {
const llm = options.llm ?? createLlmClient(); const llm = options.llm ?? createLlmClient();
const executeTool = options.executeTool ?? createApiToolExecutor(options.request); const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const onProgress = options.onProgress; const onProgress = options.onProgress;
const systemPrompt = options.systemPrompt ?? AGENT_SYSTEM_PROMPT;
const transcript: ChatMessage[] = [ const transcript: ChatMessage[] = [
{ role: "system", content: AGENT_SYSTEM_PROMPT }, { role: "system", content: systemPrompt },
...options.messages.map( ...options.messages.map(
(message): ChatMessage => ({ (message): ChatMessage => ({
role: message.role, role: message.role,
@@ -0,0 +1,55 @@
import type { BangAggregatesDto } from "../server/queries";
type Props = {
aggregates: BangAggregatesDto;
};
export function BangStatsWidget({ aggregates }: Props) {
const recentMonths = aggregates.monthlyCounts.slice(-6);
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<StatBlock label="This month" value={String(aggregates.thisMonth)} />
<StatBlock label="This year" value={String(aggregates.thisYear)} />
<StatBlock
label="Avg gap"
value={aggregates.averageDaysBetween == null ? "—" : `${aggregates.averageDaysBetween}d`}
/>
</div>
{recentMonths.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium text-[var(--ink-mute)]">Recent months</p>
<div className="space-y-1.5">
{recentMonths.map((row) => (
<div key={row.month} className="flex items-center justify-between text-sm">
<span>{formatMonth(row.month)}</span>
<span className="tabular-nums font-medium">{row.count}</span>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-[var(--ink-mute)]">No bangs recorded yet.</p>
)}
</div>
);
}
function StatBlock({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-[var(--hair)] px-3 py-2">
<div className="text-[11px] uppercase tracking-wide text-[var(--ink-mute)]">{label}</div>
<div className="text-2xl font-semibold tabular-nums">{value}</div>
</div>
);
}
function formatMonth(monthKey: string): string {
const [year, month] = monthKey.split("-").map(Number);
return new Date(year!, month! - 1, 1).toLocaleDateString(undefined, {
month: "short",
year: "numeric",
});
}
+23 -1
View File
@@ -1,18 +1,27 @@
import { z } from "zod"; import { z } from "zod";
import type { ModuleManifest, WidgetContext } from "../_core/module"; import type { ModuleManifest, WidgetContext } from "../_core/module";
import { getBangStats } from "./server/queries"; import { getBangAggregates, getBangStats } from "./server/queries";
import { BangWidget } from "./components/bang-widget"; import { BangWidget } from "./components/bang-widget";
import { BangStatsWidget } from "./components/bang-stats-widget";
const bangWidgetConfigSchema = z.object({ const bangWidgetConfigSchema = z.object({
maxRecentBangs: z.number().int().min(1).max(20).default(5), maxRecentBangs: z.number().int().min(1).max(20).default(5),
}); });
const bangStatsConfigSchema = z.object({});
async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) { async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
const parsed = bangWidgetConfigSchema.parse(config); const parsed = bangWidgetConfigSchema.parse(config);
const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs); const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs);
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />; return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
} }
async function BangStatsWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
bangStatsConfigSchema.parse(config);
const aggregates = await getBangAggregates(ctx.householdId);
return <BangStatsWidget aggregates={aggregates} />;
}
const bangsManifest: ModuleManifest = { const bangsManifest: ModuleManifest = {
id: "bangs", id: "bangs",
name: "Bangs", name: "Bangs",
@@ -44,6 +53,19 @@ const bangsManifest: ModuleManifest = {
defaultConfig: { maxRecentBangs: 5 }, defaultConfig: { maxRecentBangs: 5 },
render: (props) => <BangWidgetServer {...props} />, render: (props) => <BangWidgetServer {...props} />,
}, },
{
id: "bangs.stats",
title: "Bang Stats",
description: "Monthly and yearly bang counts plus average days between bangs.",
category: "Fun",
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 2 },
defaultPriority: 55,
configSchema: bangStatsConfigSchema,
defaultConfig: {},
resolveConfigOptions: async () => undefined,
render: (props) => <BangStatsWidgetServer {...props} />,
},
], ],
quickAdds: [ quickAdds: [
{ {
+66 -1
View File
@@ -1,4 +1,4 @@
import { count, desc, eq } from "drizzle-orm"; import { asc, count, desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema"; import { users } from "@/modules/_core/schema";
import { bangEvents } from "../schema"; import { bangEvents } from "../schema";
@@ -14,6 +14,13 @@ export type RecentBangDto = {
recordedByName: string | null; recordedByName: string | null;
}; };
export type BangAggregatesDto = {
thisMonth: number;
thisYear: number;
averageDaysBetween: number | null;
monthlyCounts: { month: string; count: number }[];
};
export async function getBangStatsForScope( export async function getBangStatsForScope(
householdId: string, householdId: string,
limit: number, limit: number,
@@ -48,3 +55,61 @@ export async function getBangStatsForScope(
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> { export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
return getBangStatsForScope(householdId, limit); return getBangStatsForScope(householdId, limit);
} }
export async function getBangAggregates(householdId: string): Promise<BangAggregatesDto> {
const now = new Date();
const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const yearPrefix = `${now.getFullYear()}-`;
const [thisMonthRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(bangEvents)
.where(
sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${monthPrefix}%`}`,
);
const [thisYearRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(bangEvents)
.where(
sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${yearPrefix}%`}`,
);
const monthlyRows = await db
.select({
month: sql<string>`substring(${bangEvents.occurredOn}, 1, 7)`,
count: sql<number>`count(*)::int`,
})
.from(bangEvents)
.where(eq(bangEvents.householdId, householdId))
.groupBy(sql`substring(${bangEvents.occurredOn}, 1, 7)`)
.orderBy(asc(sql`substring(${bangEvents.occurredOn}, 1, 7)`));
const dateRows = await db
.select({ occurredOn: bangEvents.occurredOn })
.from(bangEvents)
.where(eq(bangEvents.householdId, householdId))
.orderBy(asc(bangEvents.occurredOn));
let averageDaysBetween: number | null = null;
if (dateRows.length >= 2) {
const dates = dateRows.map((row) => parseBangDate(row.occurredOn).getTime());
let totalGapDays = 0;
for (let i = 1; i < dates.length; i++) {
totalGapDays += (dates[i]! - dates[i - 1]!) / (1000 * 60 * 60 * 24);
}
averageDaysBetween = Math.round((totalGapDays / (dates.length - 1)) * 10) / 10;
}
return {
thisMonth: thisMonthRow?.count ?? 0,
thisYear: thisYearRow?.count ?? 0,
averageDaysBetween,
monthlyCounts: monthlyRows.map((row) => ({ month: row.month, count: row.count })),
};
}
function parseBangDate(isoDate: string): Date {
const [year, month, day] = isoDate.split("-").map(Number);
return new Date(year!, month! - 1, day!);
}
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ShareButton } from "@/components/share-button"; import { ShareButton } from "@/components/share-button";
import { ReminderPicker } from "@/components/reminder-picker";
import type { CalView } from "@/modules/_core/themes"; import type { CalView } from "@/modules/_core/themes";
import { import {
Select, Select,
@@ -25,6 +26,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { CalendarDto, CalendarEventDto } from "../server/queries"; import type { CalendarDto, CalendarEventDto } from "../server/queries";
import { getEventReminderOffsets } from "../server/queries";
import { import {
createCalendar, createCalendar,
createEvent, createEvent,
@@ -57,7 +59,7 @@ type EventDraft = {
allDay: boolean; allDay: boolean;
location: string; location: string;
notes: string; notes: string;
remindMinutesBefore: number | null; reminderOffsets: number[];
}; };
type MobileView = "day" | "week" | "agenda"; type MobileView = "day" | "week" | "agenda";
@@ -99,10 +101,12 @@ export function CalendarShell({
calendars, calendars,
events, events,
defaultView = "month", defaultView = "month",
defaultReminderOffsets = [30],
}: { }: {
calendars: CalendarDto[]; calendars: CalendarDto[];
events: CalendarEventDto[]; events: CalendarEventDto[];
defaultView?: CalView; defaultView?: CalView;
defaultReminderOffsets?: number[];
}) { }) {
const [calendarRows, setCalendarRows] = useState(calendars); const [calendarRows, setCalendarRows] = useState(calendars);
const [eventRows, setEventRows] = useState(events); const [eventRows, setEventRows] = useState(events);
@@ -160,13 +164,14 @@ export function CalendarShell({
allDay, allDay,
location: "", location: "",
notes: "", notes: "",
remindMinutesBefore: 30, reminderOffsets: [...defaultReminderOffsets],
}); });
} }
function openExistingEvent({ event }: EventClickArg) { function openExistingEvent({ event }: EventClickArg) {
const row = eventRows.find((item) => item.id === event.id); const row = eventRows.find((item) => item.id === event.id);
if (!row) return; if (!row) return;
const eventId = row.id;
setSelectedEvent({ setSelectedEvent({
id: row.id, id: row.id,
calendarId: row.calendarId, calendarId: row.calendarId,
@@ -176,7 +181,12 @@ export function CalendarShell({
allDay: row.allDay, allDay: row.allDay,
location: row.location ?? "", location: row.location ?? "",
notes: row.notes ?? "", notes: row.notes ?? "",
remindMinutesBefore: null, reminderOffsets: [],
});
void getEventReminderOffsets(eventId).then((offsets) => {
setSelectedEvent((current) =>
current?.id === eventId ? { ...current, reminderOffsets: offsets } : current,
);
}); });
} }
@@ -199,7 +209,11 @@ export function CalendarShell({
startTransition(async () => { startTransition(async () => {
if (eventId) { if (eventId) {
await updateEvent({ id: eventId, ...payload }); await updateEvent({
id: eventId,
...payload,
reminderOffsets: selectedEvent.reminderOffsets,
});
setLastCalendarId(payload.calendarId); setLastCalendarId(payload.calendarId);
setEventRows((current) => setEventRows((current) =>
current.map((event) => current.map((event) =>
@@ -220,7 +234,7 @@ export function CalendarShell({
} else { } else {
const created = await createEvent({ const created = await createEvent({
...payload, ...payload,
remindMinutesBefore: selectedEvent.remindMinutesBefore, reminderOffsets: selectedEvent.reminderOffsets,
}); });
setLastCalendarId(payload.calendarId); setLastCalendarId(payload.calendarId);
setEventRows((current) => [...current, created]); setEventRows((current) => [...current, created]);
@@ -538,22 +552,11 @@ export function CalendarShell({
placeholder="Add details…" placeholder="Add details…"
/> />
</div> </div>
{!selectedEvent.id && ( <ReminderPicker
<label className="flex items-center gap-2 text-sm"> offsets={selectedEvent.reminderOffsets}
<input onChange={(reminderOffsets) => setSelectedEvent({ ...selectedEvent, reminderOffsets })}
type="checkbox" disabled={isPending}
className="size-4 cursor-pointer"
checked={selectedEvent.remindMinutesBefore !== null}
onChange={(e) =>
setSelectedEvent({
...selectedEvent,
remindMinutesBefore: e.target.checked ? 30 : null,
})
}
/> />
Remind me 30 min before
</label>
)}
<div className="flex items-center justify-between gap-2 pt-2"> <div className="flex items-center justify-between gap-2 pt-2">
<div className="flex gap-2"> <div className="flex gap-2">
{selectedEvent.id && ( {selectedEvent.id && (
+63 -9
View File
@@ -7,7 +7,12 @@ import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { logActivityForScope } from "@/modules/_core/activity"; import { logActivityForScope } from "@/modules/_core/activity";
import { householdMembers } from "@/modules/_core/schema"; import { householdMembers } from "@/modules/_core/schema";
import { scheduleReminder, cancelReminder } from "@/modules/_core/reminders"; import {
cancelReminder,
syncCalendarEventReminders,
listReminderOffsets,
} from "@/modules/_core/reminders";
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
import { calendarEvents, calendars } from "../schema"; import { calendarEvents, calendars } from "../schema";
import { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries"; import { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries";
import { calendarInput, calendarUpdateInput, eventInput, eventUpdateInput } from "./schemas"; import { calendarInput, calendarUpdateInput, eventInput, eventUpdateInput } from "./schemas";
@@ -193,18 +198,17 @@ export async function createEventForScope(
if (!event) throw new Error("Event was not created"); if (!event) throw new Error("Event was not created");
if (parsed.remindMinutesBefore != null && scope.userId) { const reminderOffsets = resolveReminderOffsets(parsed);
const fireAt = new Date(parsed.startAt.getTime() - parsed.remindMinutesBefore * 60_000); if (reminderOffsets.length > 0 && scope.userId) {
if (fireAt > new Date()) { await syncCalendarEventReminders({
await scheduleReminder({
householdId: scope.householdId, householdId: scope.householdId,
entityType: "calendar.event", eventId: event.id,
entityId: event.id, eventTitle: event.title,
fireAt, startAt: parsed.startAt,
createdBy: scope.userId, createdBy: scope.userId,
offsetMinutes: reminderOffsets,
}); });
} }
}
await logActivityForScope(scope, { await logActivityForScope(scope, {
entityType: "calendar.event", entityType: "calendar.event",
@@ -256,6 +260,43 @@ export async function updateEventForScope(
}) })
.where(eq(calendarEvents.id, parsed.id)); .where(eq(calendarEvents.id, parsed.id));
if (scope.userId) {
const [updated] = await db
.select({ title: calendarEvents.title, startAt: calendarEvents.startAt })
.from(calendarEvents)
.where(eq(calendarEvents.id, parsed.id))
.limit(1);
if (updated) {
const startAt = parsed.startAt ?? updated.startAt;
let offsets: number[];
if (parsed.reminderOffsets !== undefined || parsed.remindMinutesBefore !== undefined) {
offsets = resolveReminderOffsets(parsed);
} else if (parsed.startAt !== undefined) {
offsets = await listReminderOffsets("calendar.event", parsed.id);
} else {
offsets = [];
}
if (
offsets.length === 0 &&
(parsed.reminderOffsets !== undefined || parsed.remindMinutesBefore !== undefined)
) {
await cancelReminder("calendar.event", parsed.id);
} else if (offsets.length > 0) {
await syncCalendarEventReminders({
householdId: scope.householdId,
eventId: parsed.id,
eventTitle: updated.title,
startAt,
createdBy: scope.userId,
offsetMinutes: offsets,
});
}
}
}
await logActivityForScope(scope, { await logActivityForScope(scope, {
entityType: "calendar.event", entityType: "calendar.event",
entityId: parsed.id, entityId: parsed.id,
@@ -304,3 +345,16 @@ async function assertOwnsCalendar(userId: string, calendarId: string) {
if (!calendar) throw new Error("Forbidden"); if (!calendar) throw new Error("Forbidden");
} }
function resolveReminderOffsets(input: {
reminderOffsets?: number[];
remindMinutesBefore?: number | null;
}): number[] {
if (input.reminderOffsets !== undefined) {
return normalizeReminderOffsets(input.reminderOffsets);
}
if (input.remindMinutesBefore != null) {
return normalizeReminderOffsets([input.remindMinutesBefore]);
}
return [];
}
+15
View File
@@ -4,6 +4,8 @@ import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
import { listReminderOffsets } from "@/modules/_core/reminders";
import { householdMembers } from "@/modules/_core/schema"; import { householdMembers } from "@/modules/_core/schema";
import { calendarEvents, calendars } from "../schema"; import { calendarEvents, calendars } from "../schema";
@@ -258,6 +260,19 @@ export async function searchEvents(query: string, householdId: string) {
})); }));
} }
export async function getDefaultEventReminderOffsets(): Promise<number[]> {
const { user } = await getCurrentSession();
return normalizeReminderOffsets(user.defaultEventReminderOffsets ?? [30]);
}
export async function getEventReminderOffsets(eventId: string): Promise<number[]> {
const parsed = z.string().uuid().parse(eventId);
const { user, household } = await getCurrentSession();
const event = await getEventForScope({ householdId: household.id, userId: user.id }, parsed);
if (!event) throw new Error("Event not found");
return listReminderOffsets("calendar.event", parsed);
}
function toEventDto(row: { function toEventDto(row: {
id: string; id: string;
calendarId: string; calendarId: string;
+2
View File
@@ -20,6 +20,8 @@ export const eventBaseInput = z.object({
allDay: z.boolean().default(false), allDay: z.boolean().default(false),
location: z.string().trim().max(300).nullable().optional(), location: z.string().trim().max(300).nullable().optional(),
notes: z.string().trim().max(3000).nullable().optional(), notes: z.string().trim().max(3000).nullable().optional(),
reminderOffsets: z.array(z.number().int().min(0)).optional(),
/** @deprecated use reminderOffsets */
remindMinutesBefore: z.number().int().min(0).nullable().optional(), remindMinutesBefore: z.number().int().min(0).nullable().optional(),
}); });
+22 -1
View File
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { Archive, Plus, Trash2 } from "lucide-react"; import { Archive, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { DetailBackLink } from "@/components/detail-back-link"; import { DetailBackLink } from "@/components/detail-back-link";
import { EntityComments } from "@/components/comments/entity-comments";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ShareButton } from "@/components/share-button"; import { ShareButton } from "@/components/share-button";
@@ -18,7 +19,13 @@ import {
updateItem, updateItem,
} from "../server/actions"; } from "../server/actions";
export function ListDetail({ initialList }: { initialList: ListDetailDto }) { export function ListDetail({
initialList,
currentUserId,
}: {
initialList: ListDetailDto;
currentUserId: string;
}) {
const router = useRouter(); const router = useRouter();
const [list, setList] = useState(initialList); const [list, setList] = useState(initialList);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@@ -156,6 +163,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
<ListItemRow <ListItemRow
key={item.id} key={item.id}
item={item} item={item}
currentUserId={currentUserId}
onToggle={(done) => setItemDone(item, done)} onToggle={(done) => setItemDone(item, done)}
onEdit={(text) => editItemText(item, text)} onEdit={(text) => editItemText(item, text)}
onCommit={() => commitItemText(item)} onCommit={() => commitItemText(item)}
@@ -170,6 +178,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
<ListItemRow <ListItemRow
key={item.id} key={item.id}
item={item} item={item}
currentUserId={currentUserId}
onToggle={(done) => setItemDone(item, done)} onToggle={(done) => setItemDone(item, done)}
onEdit={(text) => editItemText(item, text)} onEdit={(text) => editItemText(item, text)}
onCommit={() => commitItemText(item)} onCommit={() => commitItemText(item)}
@@ -179,18 +188,22 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
</> </>
)} )}
</div> </div>
<EntityComments entityType="lists.list" entityId={list.id} currentUserId={currentUserId} />
</div> </div>
); );
} }
function ListItemRow({ function ListItemRow({
item, item,
currentUserId,
onToggle, onToggle,
onEdit, onEdit,
onCommit, onCommit,
onRemove, onRemove,
}: { }: {
item: ListItemDto; item: ListItemDto;
currentUserId: string;
onToggle: (done: boolean) => void; onToggle: (done: boolean) => void;
onEdit: (text: string) => void; onEdit: (text: string) => void;
onCommit: () => void; onCommit: () => void;
@@ -333,6 +346,14 @@ function ListItemRow({
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</div> </div>
<div className="px-[14px] pb-2">
<EntityComments
entityType="lists.item"
entityId={item.id}
currentUserId={currentUserId}
compact
/>
</div>
</div> </div>
); );
} }
+128 -6
View File
@@ -1,13 +1,13 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { ChevronDown, ChevronRight, ExternalLink, Plus } from "lucide-react"; import { ChevronDown, ChevronRight, ExternalLink, Pencil, Plus } from "lucide-react";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { ListIndexItem, ListWithItemsDto } from "../server/queries"; import type { ListIndexItem, ListWithItemsDto } from "../server/queries";
import { createList, toggleItem } from "../server/actions"; import { addItem, createList, toggleItem, updateListProperties } from "../server/actions";
export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) { export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
const [listRows, setListRows] = useState(lists); const [listRows, setListRows] = useState(lists);
@@ -61,6 +61,44 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
}); });
} }
function handleAddItem(listId: string, text: string) {
setListRows((current) =>
current.map((list) =>
list.id !== listId
? list
: {
...list,
openCount: list.openCount + 1,
items: [
...list.items,
{
id: `temp-${Date.now()}`,
text,
done: false,
position: list.items.length,
},
],
},
),
);
startTransition(async () => {
await addItem({ listId, text });
const { listListsWithItems } = await import("../server/queries");
const refreshed = await listListsWithItems();
setListRows(refreshed);
});
}
function handleUpdateList(listId: string, patch: { name?: string; type?: string }) {
setListRows((current) =>
current.map((list) => (list.id === listId ? { ...list, ...patch } : list)),
);
startTransition(async () => {
await updateListProperties({ id: listId, ...patch });
});
}
return ( return (
<div className="mx-auto grid w-full max-w-5xl gap-6"> <div className="mx-auto grid w-full max-w-5xl gap-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
@@ -101,7 +139,13 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
<div className="eyebrow">{groupType}</div> <div className="eyebrow">{groupType}</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groupLists.map((list) => ( {groupLists.map((list) => (
<ListCard key={list.id} list={list} onToggle={handleToggle} /> <ListCard
key={list.id}
list={list}
onToggle={handleToggle}
onAddItem={handleAddItem}
onUpdateList={handleUpdateList}
/>
))} ))}
</div> </div>
</section> </section>
@@ -114,11 +158,34 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
function ListCard({ function ListCard({
list, list,
onToggle, onToggle,
onAddItem,
onUpdateList,
}: { }: {
list: ListWithItemsDto; list: ListWithItemsDto;
onToggle: (listId: string, item: ListIndexItem, done: boolean) => void; onToggle: (listId: string, item: ListIndexItem, done: boolean) => void;
onAddItem: (listId: string, text: string) => void;
onUpdateList: (listId: string, patch: { name?: string; type?: string }) => void;
}) { }) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [editing, setEditing] = useState(false);
const [draftName, setDraftName] = useState(list.name);
const [draftType, setDraftType] = useState(list.type);
const [itemDraft, setItemDraft] = useState("");
function saveListProperties() {
const name = draftName.trim();
const type = draftType.trim();
if (!name || !type) return;
onUpdateList(list.id, { name, type });
setEditing(false);
}
function submitItem() {
const text = itemDraft.trim();
if (!text) return;
onAddItem(list.id, text);
setItemDraft("");
}
return ( return (
<div <div
@@ -135,23 +202,61 @@ function ListCard({
> >
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />} {expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button> </button>
<div className="min-w-0"> <div className="min-w-0 flex-1">
{editing ? (
<div className="grid gap-2">
<Input value={draftName} onChange={(e) => setDraftName(e.target.value)} />
<Input value={draftType} onChange={(e) => setDraftType(e.target.value)} />
<div className="flex gap-2">
<Button type="button" size="sm" onClick={saveListProperties}>
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setDraftName(list.name);
setDraftType(list.type);
setEditing(false);
}}
>
Cancel
</Button>
</div>
</div>
) : (
<>
<h3 className="serif text-[15px] truncate text-[var(--ink)] m-0 font-medium"> <h3 className="serif text-[15px] truncate text-[var(--ink)] m-0 font-medium">
{list.name} {list.name}
</h3> </h3>
<div className="meta"> <div className="meta">
{list.openCount} open · {list.doneCount} done {list.type} · {list.openCount} open · {list.doneCount} done
</div> </div>
</>
)}
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1">
{!editing && (
<button
type="button"
onClick={() => setEditing(true)}
className="text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
aria-label={`Edit ${list.name}`}
>
<Pencil className="size-4" />
</button>
)}
<Link <Link
href={`/lists/${list.id}`} href={`/lists/${list.id}`}
className="shrink-0 text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors" className="text-[var(--ink-mute)] hover:text-[var(--ink)] transition-colors"
aria-label={`Open ${list.name}`} aria-label={`Open ${list.name}`}
> >
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Link> </Link>
</div> </div>
</div>
{expanded && ( {expanded && (
<div> <div>
@@ -204,6 +309,23 @@ function ListCard({
)} )}
</div> </div>
)} )}
<div className="flex gap-2 px-[14px] py-3 border-t border-[var(--hair)]">
<Input
value={itemDraft}
onChange={(e) => setItemDraft(e.target.value)}
placeholder="Add a task…"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitItem();
}
}}
/>
<Button type="button" size="sm" disabled={!itemDraft.trim()} onClick={submitItem}>
Add
</Button>
</div>
</div> </div>
)} )}
</div> </div>
+20 -2
View File
@@ -59,16 +59,20 @@ export async function updateListForScope(
.update(lists) .update(lists)
.set({ .set({
name: parsed.name, name: parsed.name,
type: parsed.type,
archived: parsed.archived, archived: parsed.archived,
}) })
.where(eq(lists.id, parsed.id)); .where(eq(lists.id, parsed.id));
if (parsed.name) { if (parsed.name || parsed.type) {
await logActivityForScope(toScope(scope), { await logActivityForScope(toScope(scope), {
entityType: "lists.list", entityType: "lists.list",
entityId: parsed.id, entityId: parsed.id,
action: "update", action: "update",
payload: { name: parsed.name }, payload: {
...(parsed.name ? { name: parsed.name } : {}),
...(parsed.type ? { type: parsed.type } : {}),
},
}); });
} }
if (parsed.archived === true) { if (parsed.archived === true) {
@@ -93,6 +97,20 @@ export async function renameList(input: { id: string; name: string }) {
revalidatePath(`/lists/${parsed.id}`); revalidatePath(`/lists/${parsed.id}`);
} }
export async function updateListProperties(input: { id: string; name?: string; type?: string }) {
const parsed = z
.object({
id: z.string().uuid(),
name: listInput.shape.name.optional(),
type: listInput.shape.type.optional(),
})
.parse(input);
const { household, user } = await getCurrentSession();
await updateListForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
revalidatePath("/lists");
revalidatePath(`/lists/${parsed.id}`);
}
export async function archiveList(input: { id: string }) { export async function archiveList(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input); const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household, user } = await getCurrentSession(); const { household, user } = await getCurrentSession();
+1
View File
@@ -7,6 +7,7 @@ export const listInput = z.object({
export const listUpdateInput = z.object({ export const listUpdateInput = z.object({
name: listInput.shape.name.optional(), name: listInput.shape.name.optional(),
type: listInput.shape.type.optional(),
archived: z.boolean().optional(), archived: z.boolean().optional(),
}); });
+10 -1
View File
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Pin, PinOff, Save, Trash2 } from "lucide-react"; import { Pin, PinOff, Save, Trash2 } from "lucide-react";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { EntityComments } from "@/components/comments/entity-comments";
import { RichTextContent } from "@/components/rich-text"; import { RichTextContent } from "@/components/rich-text";
import { DetailBackLink } from "@/components/detail-back-link"; import { DetailBackLink } from "@/components/detail-back-link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -25,7 +26,7 @@ const RichTextEditor = dynamic(
}, },
); );
export function NoteEditor({ note }: { note?: NoteDto }) { export function NoteEditor({ note, currentUserId }: { note?: NoteDto; currentUserId?: string }) {
const router = useRouter(); const router = useRouter();
const [currentNote, setCurrentNote] = useState(note); const [currentNote, setCurrentNote] = useState(note);
const [title, setTitle] = useState(note?.title ?? ""); const [title, setTitle] = useState(note?.title ?? "");
@@ -147,6 +148,14 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
<RichTextContent html={body} /> <RichTextContent html={body} />
</aside> </aside>
</div> </div>
{currentNote && currentUserId ? (
<EntityComments
entityType="notes.note"
entityId={currentNote.id}
currentUserId={currentUserId}
/>
) : null}
</div> </div>
); );
} }
+1 -1
View File
@@ -16,7 +16,7 @@ test("assistant chat smoke after opt-in", async ({ page }) => {
await page.getByRole("button", { name: "Open assistant" }).click(); await page.getByRole("button", { name: "Open assistant" }).click();
await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Assistant" })).toBeVisible();
await page.getByLabel("Assistant message").fill("hello assistant"); await page.getByLabel("Message for Assistant").fill("hello assistant");
await page.getByRole("button", { name: "Send" }).click(); await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("hello assistant")).toBeVisible(); await expect(page.getByText("hello assistant")).toBeVisible();
+12
View File
@@ -71,4 +71,16 @@ describe("runAgentChat", () => {
assert.equal(result.toolCalls[0]?.name, "add_list_item"); assert.equal(result.toolCalls[0]?.name, "add_list_item");
assert.equal(result.toolCalls[0]?.status, 201); assert.equal(result.toolCalls[0]?.status, 201);
}); });
it("accepts a custom system prompt override", async () => {
const result = await runAgentChat({
messages: [{ role: "user", content: "hello" }],
request: new Request("http://localhost:3000/api/agent/chat"),
systemPrompt: "You are a pirate.",
llm: createMockLlmClient(),
});
assert.equal(result.message.role, "assistant");
assert.ok(result.message.content.length > 0);
});
}); });
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
DEFAULT_ASSISTANT_NAME,
resolveAssistantSystemPrompt,
} from "../../src/lib/assistant-config";
import { AGENT_SYSTEM_PROMPT } from "../../src/modules/agent/tools";
describe("resolveAssistantSystemPrompt", () => {
it("returns the default prompt when custom is null", () => {
assert.equal(resolveAssistantSystemPrompt(null), AGENT_SYSTEM_PROMPT);
});
it("returns the default prompt when custom is blank", () => {
assert.equal(resolveAssistantSystemPrompt(" "), AGENT_SYSTEM_PROMPT);
});
it("returns trimmed custom prompt when set", () => {
assert.equal(resolveAssistantSystemPrompt(" Be extra cheerful. "), "Be extra cheerful.");
});
});
describe("DEFAULT_ASSISTANT_NAME", () => {
it("is Assistant", () => {
assert.equal(DEFAULT_ASSISTANT_NAME, "Assistant");
});
});