Compare commits

...
Author SHA1 Message Date
ginnoir 8921cb0444 chore: release v0.3.0
Release / build-and-push (push) Has been cancelled
2026-06-01 18:43:13 -05:00
ginnoir e3d2b5a364 chore: sync version to v0.2.0 and load .env for release scripts 2026-06-01 18:41:32 -05:00
ginnoir ed310d042f docs: add github_token to .env.example 2026-06-01 18:34:57 -05:00
ginnoir 30af9f63bf chore: add release workflow (commitlint, husky, lint-staged, release-it) 2026-06-01 18:33:37 -05:00
ginnoir e9bb2a5444 feat: expand theme system to 10 full-skin palettes with light and dark modes 2026-06-01 18:25:58 -05:00
ginnoir a95f10fcde fix: scope rail nav-label hiding to sidebar and fall back fab-only to sidebar on desktop 2026-06-01 18:00:49 -05:00
ginnoir b255aaeac1 fix: refresh server components after nav style change to fix broken layout 2026-06-01 17:53:21 -05:00
ginnoir 19308be768 feat: add dev startup script, fix push notifications, and scope test sends to device
- pnpm dev:local/dev:reset: orchestrate DB container, migrations, seed, and Next.js dev server in one command; Caddy snippet + docs for HTTPS via dev.ginnoir.com
- Fix dev login on HTTPS: set both authjs.session-token and __Secure-authjs.session-token so Auth.js finds the session regardless of cookie name resolution
- Suppress hydration mismatch on <html> caused by pre-paint script changing data-nav before React hydrates
- VAPID startup warning if keys not configured; remove dead NEXT_PUBLIC_VAPID_PUBLIC_KEY var
- PushOptIn: hydrate subscription state on mount; reuse existing subscription on iOS to avoid redundant prompts
- sendPushToEndpoint: new function to send to a single device endpoint
- sendTestNotification: scoped to the calling device's endpoint (ownership-verified) instead of all user subscriptions
2026-06-01 17:45:20 -05:00
ginnoirandClaude Opus 4.7 9612a54e52 Apply paper-and-ink design system across all surfaces
Release / build-and-push (push) Has been cancelled
Replaces the generic shadcn/ui gray theme + horizontal top-bar shell with
the paper-and-ink language from the Claude Design handoff bundle: warm
off-white paper, near-black ink, Source Serif 4 + Inter, hairline borders,
muted ink accents (clay/indigo/sage/plum/ochre) used functionally for
calendars and share scopes.

Theme switcher expanded from 2 dimensions (theme × mode) to 7: palette ×
mode × fontPair × density × dashLayout × calView × navStyle. All exposed
in Settings → Appearance and persisted on the users row. Pre-paint script
applies all four data-* attributes from localStorage so reload doesn't
flash.

App shell restructured to a CSS-grid driven by data-nav on <html>: sidebar
on desktop, bottom-nav + FAB under 760px. Four desktop nav modes wired
(sidebar/rail/top/fab-only). Topbar gets a search-→-CommandPalette button,
notification bell, "+ New" quick-add, avatar.

Dashboard, calendar, lists, notes, settings, login, public share viewer,
and quick-add sheet all reskinned. Dashboard editor gains a Preset menu
(classic/split/glance) that fills the layout from the registered widgets.
FullCalendar wrapped in .fc-skin and inherits all paper-and-ink tokens via
CSS variable overrides. Public share viewer (/s/<token>) rebuilt around
ShareFrame: expiration banner, brand strip, eyebrow chip, 38px serif
title, mini-day + mini-map cards, share-rows.

Schema: drops users.theme; adds theme_palette, theme_font_pair,
theme_density, theme_dash_layout, theme_cal_view, theme_nav_style with
defaults that match the design (clay / serif-sans / regular / classic /
month / rail-desktop). Migration 0014_paper_ink_theme.

Middleware sets x-pathname so the AppShell server component can render
bare for /s/* and /login without a route-group refactor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:03:21 -05:00
ginnoirandClaude Sonnet 4.6 e130cda6c5 Remove signIn callback — enrollment now happens in getCurrentSession
Release / build-and-push (push) Has been cancelled
Auth.js v5 calls signIn before the new user's row is fully committed, so
inserting into household_members immediately caused a FK violation for any
new user (e.g. cam on first sign-in). Enrollment is now handled lazily in
getCurrentSession's auto-enroll fallback, which runs after the session is
established and the user row is guaranteed to exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:23:34 -05:00
ginnoirandClaude Sonnet 4.6 2129f9718f Fix revalidatePath during render in getDefaultDashboardSlug
On a new user's first load, getDefaultDashboardSlug fell through to
createDashboard("Home"), which calls revalidatePath("/"). That is illegal
in a render context (page component → server action → revalidatePath).

Inline the DB insert directly in the fallback and skip revalidatePath —
the redirect that immediately follows is a fresh request, so no cache
invalidation is needed here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:58:32 -05:00
ginnoirandClaude Sonnet 4.6 484f747f66 Auto-enroll user into household if membership missing on session load
If a user has a valid session but no household_members row (e.g. they
first signed in before the household was seeded), getCurrentSession now
auto-enrolls them instead of throwing. Assigns owner if first member,
member otherwise, and creates their default calendars and lists.

Removes the permanently-stuck state that occurred on first deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:12:26 -05:00
ginnoirandClaude Sonnet 4.6 c66bf68373 Seed household on container startup via seed.mjs
Without a household row the signIn callback has nothing to attach the
first user to, causing "No household membership" on first load. Add
scripts/seed.mjs (plain ESM, only needs postgres which is already in the
image) and run it from the entrypoint after migrations. Idempotent — skips
if a household already exists. Default calendars/lists are created by the
signIn callback when the first user authenticates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:49:20 -05:00
ginnoirandClaude Sonnet 4.6 1e076bd2f3 Fix migrations: commit drizzle/meta/ so journal is present in Docker build
drizzle/meta/ was gitignored, so GitHub Actions had no _journal.json to
copy into the image. The migrator requires this file to know which SQL
files to apply. Unblock by committing the meta directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 19:50:07 -05:00
ginnoirandClaude Sonnet 4.6 64b8180d9a Fix migrate.mjs: explicitly copy full drizzle-orm and postgres into image
The standalone tracer only includes subpaths the Next.js app imports.
drizzle-orm/postgres-js/migrator is only used by migrate.mjs (not app
code) so it is excluded from the trace. Copy the full packages from the
builder stage to ensure all subpaths are available at startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 19:22:18 -05:00
ginnoirandClaude Sonnet 4.6 39fcd54f0c Fix migrate.mjs: externalize drizzle-orm and postgres from Next.js bundle
Next.js bundles all non-externalized server packages into server chunks.
drizzle-orm and postgres were bundled, so they were absent from the
standalone node_modules/ and unavailable to migrate.mjs at startup.
Adding them to serverExternalPackages keeps them as separate modules and
tells the standalone tracer to include them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 19:15:00 -05:00
ginnoirandClaude Sonnet 4.6 524efac05f Fix Docker build: include drizzle/ migrations in build context
drizzle/ was excluded in .dockerignore but the Dockerfile copies it into
the runner stage (needed by migrate.mjs at container startup). Remove the
exclusion so migration SQL files are present in the builder stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:50:48 -05:00
ginnoirandClaude Sonnet 4.6 bb3a770cf3 Fix Docker build: move entrypoint out of excluded deploy/ dir
.dockerignore excludes deploy/ entirely. The entrypoint script lived
there, so the COPY in Stage 3 failed with "not found". Move it to the
project root (alongside Dockerfile) where the build context includes it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:46:36 -05:00
ginnoirandClaude Sonnet 4.6 01945fbd0a Gitignore generated public/sw.js
next.config.ts regenerates public/sw.js on every build with a new
timestamp; tracking it produces noise on every build. Add to .gitignore
and untrack — it is always regenerated by `pnpm dev` / `pnpm build`
and by the Docker build stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:34:40 -05:00
ginnoirandClaude Sonnet 4.6 1185f5ab50 Production-ready wiring: VAPID prop fix, port mappings, AUTH_URL
- PushOptIn now accepts vapidKey as a prop from its server-component
  parent (settings page reads VAPID_PUBLIC_KEY at runtime) — eliminates
  the NEXT_PUBLIC_* build-time dependency so pre-built GHCR images work
  without a build arg.
- deploy/compose.yaml: famapp exposes 3010:3000, authentik-server exposes
  9200:9000 so the existing Caddy stack can proxy by IP, matching every
  other service in the homelab.  NEXT_PUBLIC_APP_URL replaced by AUTH_URL
  (correct next-auth v5 var).
- deploy/Caddyfile.snippet: updated to 192.168.1.69:3010 / :9200.
- .env.production.example: AUTH_URL, ntfy pre-wired to ntfy.ginnoir.com,
  VAPID_SUBJECT prefilled with real email.
- typecheck and pnpm build both pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:32:08 -05:00
ginnoir c73338e256 Code-side
src/lib/dev-login-config.ts — startup assertion: throws if NODE_ENV=production + ENABLE_DEV_LOGIN=true, scoped to runtime (skipped during next build).
Container

scripts/migrate.mjs — runs Drizzle migrations against DATABASE_URL.
deploy/docker-entrypoint.sh — runs migrations then exec node server.js. Skip with RUN_MIGRATIONS=false.
Dockerfile — copies drizzle/, scripts/migrate.mjs, entrypoint into runner stage; ENTRYPOINT now points at the script.
Compose

deploy/compose.yaml — famapp now image: ${FAMAPP_IMAGE:-ghcr.io/ginnoir/famapp:latest} (build still works locally as fallback). Authentik pinned via AUTHENTIK_IMAGE_TAG (default 2024.12.3). New RUN_MIGRATIONS env passed through.
.env.production.example — documents FAMAPP_IMAGE, AUTHENTIK_IMAGE_TAG, RUN_MIGRATIONS.
CI/CD

.github/workflows/ci.yml — push/PR: typecheck + lint + format:check + build.
.github/workflows/release.yml — v* tag: build + push ghcr.io/ginnoir/famapp:vX.Y.Z, :X.Y, :latest to GHCR.
Docs

deploy/README.md — full deploy/rollback/release runbook.
CHANGELOG.md — release log seeded with an Unreleased entry.
docs/tasks/09-pre-deploy-checklist.md — task 09 reframed from one-shot removal to a recurring pre-deploy checklist.
STATUS.md — updated.
Verified: pnpm typecheck, pnpm format, pnpm build, and docker compose config all clean.
2026-05-06 17:37:37 -05:00
ginnoirandClaude Sonnet 4.6 285a460eb8 Implement tasks 60, 61, 62: backups, rate limiting, structured logging
Task 60 — Postgres backups:
- deploy/backups/: backup.sh (pg_dump -Fc nightly), retain.sh (14/8/6 tiers),
  restore.sh, entrypoint.sh, crontab
- famapp-backup Alpine service + backups volume added to deploy/compose.yaml
- Restore procedure in deploy/backups/README.md

Task 61 — Rate limiting on share links:
- src/lib/rate-limit.ts: Edge-compatible sliding-window counter (50/min, LRU eviction)
  with consume(), isRateLimited(), recordFailure() exports
- middleware.ts: enforces 429 with Retry-After: 60 for /s/[token] (IP + token prefix)
- /s/[token]/page.tsx: tracks only failed resolveShareToken calls via recordFailure()

Task 62 — Structured logging:
- pino + pino-pretty installed; serverExternalPackages added to next.config.ts
- src/lib/logger.ts: JSON in production, pretty in dev, level from LOG_LEVEL env
- middleware.ts: structured JSON request log (method, path, status, ms, authenticated)
- _core/push.ts, notify.ts, reminders.ts: console.error/log → logger.error/info

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:23:29 -05:00
ginnoirandClaude Sonnet 4.6 b6abe052c9 Implement tasks 40, 41, 42: web push, notification bus, reminders engine
Task 40 — Web Push (VAPID):
- Add web-push package + @types/web-push
- pnpm vapid:generate script prints VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, NEXT_PUBLIC_VAPID_PUBLIC_KEY
- push_subscriptions schema + migration 0013
- _core/push.ts: sendPush() iterates subscriptions, prunes 404/410 stale entries
- SW push/notificationclick event handlers added to generated sw.js template
- PushOptIn client component on /settings (opt-in, disable, send test)

Task 42 — Notification bus + ntfy adapter:
- notifications table + notif_push/notif_inapp/notif_ntfy user columns (migration 0013)
- _core/notify.ts: notify() fans out to push, in-app DB, and optional ntfy POST
- NotificationBell server component in AppNav: unread badge, dropdown inbox, mark-read
- NotifyChannelToggles client component in /settings

Task 41 — Reminders engine:
- fired_at + created_by added to reminders; default channel changed to 'auto'
- _core/reminders.ts: scheduleReminder (upsert), cancelReminder, listReminders, tickReminders
- tickReminders uses pg_try_advisory_xact_lock for horizontal-scale safety
- src/instrumentation.ts starts reminder worker (30s tick) on Node.js boot
- Notes actions use scheduleReminder/cancelReminder instead of raw SQL
- Calendar createEvent: optional remindMinutesBefore, deleteEvent: cancelReminder
- Calendar-shell: "Remind me 30 min before" checkbox on new event form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:55:02 -05:00
ginnoirandClaude Sonnet 4.6 ec20f1bc87 Fix offline banner stuck after DevTools network toggle
The online event could fire between page load and useEffect registering
its listener, leaving isOffline stuck at true despite navigator.onLine
being true. A zero-delay setTimeout after listener registration re-syncs
from navigator.onLine, covering that race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:20:49 -05:00
ginnoirandClaude Sonnet 4.6 afff22e994 Implement offline shell + service worker caching (task 51)
SW is now generated by next.config.ts on every build with a versioned
cache name (timestamp in prod, "dev" in dev). Strategies: stale-while-
revalidate for static chunks and navigation, network-first with 2s
timeout for API GETs, network-only for mutations. Offline mutations
postMessage to clients; pwa-register.tsx shows amber offline banner,
red mutation-failed toast, and indigo "new version" refresh prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:14:36 -05:00
ginnoirandClaude Sonnet 4.6 8aab25e0ac Implement PWA shell (task 50)
Add web manifest, placeholder icons, app-shell service worker, and
install prompt so famapp is installable on iOS and Android home screens.

- public/manifest.webmanifest — name, short_name, standalone display,
  start_url /, all four icon sizes (192/384/512 + maskable)
- public/icon-{180,192,384,512,512-maskable}.png — indigo placeholder
  squares generated by scripts/generate-icons.mjs (pnpm gen:icons)
- public/icon.svg — house-icon SVG source for future branded export
- public/sw.js — hand-rolled app-shell SW: precaches offline.html,
  cache-first for /_next/static/, network-first navigation with offline
  fallback, network-only for /api/*
- public/offline.html — branded offline fallback page
- src/components/pwa-register.tsx — client SW registration
- src/components/install-prompt.tsx — dismissible install banner:
  beforeinstallprompt on Android, "Add to Home Screen" hint on iOS
- Root layout: viewport themeColor, manifest/appleWebApp metadata,
  apple-touch-icon, mounts PwaRegister + InstallPrompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:00:02 -05:00
ginnoirandClaude Sonnet 4.6 d5a8bf9d95 Implement tasks 25, 26 + completion visibility setting
Task 25: Multiple dashboards — dashboards table + migration, /d/[slug]
route, root redirect, dashboard tabs in nav with create/rename/delete/
set-default. Task 26: Editable dashboard — react-grid-layout drag/resize
editor, widget picker modal with per-widget configurator auto-generated
from default config, save/reset server actions with Zod validation.

Completion visibility: server-side per-user setting (hours) replaces
localStorage timer; queries filter completed items by updatedAt cutoff;
Settings page dropdown persists preference via server action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:52:59 -05:00
ginnoirandClaude Sonnet 4.6 2ad9521ef9 Delay item disappearance after check-off; add setting in Settings
Items on the lists page and dashboard widget now show as crossed-out for
a configurable duration (default 3s) before vanishing. Unchecking within
the window cancels the removal. The delay is stored in localStorage and
configurable via Settings → Lists (options: 1s / 3s / 5s / 10s / 30s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:02:04 -05:00
ginnoirandClaude Sonnet 4.6 8fda3e47dd Inline items on lists page and interactive dashboard widget
- Lists page: show up to 10 open items per list in collapsible cards;
  items can be checked off directly without opening the list
- Dashboard widget: replace static span-checkboxes with a real client
  component using useOptimistic so items can be toggled from the dashboard
- listWidgetItems now returns listId for navigation links
- New listListsWithItems query fetches counts + top 10 open items per list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:57:01 -05:00
ginnoirandClaude Sonnet 4.6 734268cb75 Wire ShareButton into list/note/calendar pages; fix nav and calendar select labels
- Add ShareButton to list detail, note editor, and calendar event panel
- Add Settings gear icon to AppNav (core page not in module registry)
- Fix calendar event dialog showing raw UUID: pass explicit children to
  SelectValue so labels display before SelectContent mounts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:41:10 -05:00
ginnoirandClaude Sonnet 4.6 39aa5704f9 Implement share viewer (task 31)
- Makes actorId nullable in activity_log (migration 0010) with onDelete:
  set null so audit history survives user deletion; adds logShareActivity
  for anonymous mutations
- Adds resolveShareToken return of householdId for downstream validation
- Adds renderSharedView to EntityTypeRegistration; each module implements
  loadForShare (no-session bare queries) and renderSharedView
- Calendar: CalendarSharedView (upcoming 90-day events) and
  EventSharedView (title/time/location/notes)
- Lists: ListSharedView (client component with optimistic toggles via
  toggleShareListItem server action in lists/server/share-actions.ts)
- Notes: NoteSharedView (title + body + updated date)
- /app/s/[token]/page.tsx: resolves token, dispatches to loadForShare +
  renderSharedView, friendly error for invalid/expired tokens, noindex
- Middleware /s/* exemption confirmed present (no change needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:26:38 -05:00
ginnoirandClaude Sonnet 4.6 d5deee9a46 Implement share-link service (task 30)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:09:10 -05:00
ginnoirandClaude Sonnet 4.6 28475e483d Implement activity log (task 22)
- activity_log table in _core/schema.ts with household+created_at index;
  migration 0008_activity_log.sql applied
- logActivity() in _core/activity.ts reads current session and inserts a row
- ActivityLogEntry type + renderActivity?(entry): string added to
  EntityTypeRegistration so modules declare human-readable labels without
  any if/else branches in the widget
- core.activity widget replaced with a real async server component that
  queries the last 20 rows and renders via the registry
- logActivity wired into every create/update/delete in calendar, lists,
  and notes server actions; getAuthorizedItem also returns text so
  toggle/delete can include item text in the payload
- pnpm typecheck, lint, build, and all 4 E2E specs pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:57:03 -05:00
ginnoirandClaude Sonnet 4.6 1133fa8aac Implement quick-add registry (task 21)
- Add url field to QuickAddAction; make action optional
- Add getQuickAdds() + SerializedQuickAddItem to registry so quick-add
  data can cross the server→client RSC boundary without serializing fns
- Update calendar/lists/notes manifests with navigation URLs
- QuickAddProvider wraps root layout: holds sheet/palette open state and
  global cmd/ctrl+k shortcut
- QuickAddSheet: bottom drawer on mobile, right-side popover on desktop,
  actions grouped by module
- CommandPalette: cmdk-powered modal with fuzzy filter and full keyboard
  nav (arrows + enter + esc)
- QuickAddFab: client button replacing the plain + stub in the dashboard
- Add .claude/** to ESLint ignores to prevent stale worktree artifacts
  from failing lint

All 4 E2E specs pass; typecheck, lint, and build clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:44:33 -05:00
ginnoirandGitHub b9d3cce73a Merge pull request #2 from ginnoir/claude/great-einstein-b073e2
Implement dashboard composition (task 20)
2026-05-06 13:26:32 -05:00
ginnoirandClaude Sonnet 4.6 b4dde92757 Implement dashboard composition (task 20)
- Add default_dashboard_layout jsonb to users + migration 0007
- Create _core/manifest.tsx with core.activity placeholder widget
- Register core manifest alongside calendar/lists/notes
- Update all three module manifests with real async server-component
  widget renders (upcoming events, month view, list items, notes)
- Add src/lib/dashboard.ts: computeDefaultLayout greedy packer +
  parseDashboardLayout Zod validator
- Build src/app/page.tsx: 12-col CSS Grid, static smColSpan lookup,
  per-widget Suspense for parallel loading, generic widget.render()
  dispatch — no widgetId branches
- Add tests/e2e/dashboard.spec.ts; all 4 E2E specs pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:24:56 -05:00
ginnoirandGitHub d7069d14a2 Merge pull request #1 from ginnoir/codex/notes-module
Add notes module
2026-05-06 04:12:14 -05:00
174 changed files with 19296 additions and 995 deletions
-1
View File
@@ -2,7 +2,6 @@ node_modules
.next
.git
deploy
drizzle
docs
*.md
.env*
+4 -1
View File
@@ -19,7 +19,7 @@ AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=replace-me
AUTH_OIDC_CLIENT_SECRET=replace-me
# Web Push (generate with: pnpm vapid:generate)
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:you@example.com
@@ -30,3 +30,6 @@ NTFY_TOPIC=
# Logging
LOG_LEVEL=info
# GitHub (required for pnpm release — creates a GitHub Release)
GITHUB_TOKEN=
+19 -8
View File
@@ -1,7 +1,17 @@
# ── famapp ────────────────────────────────────────────────────────────────────
# Public URL for the app (used in share links, OIDC redirect URIs, etc.)
NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com
# Image tag to deploy. Pin to a specific version after first deploy
# (e.g. ghcr.io/ginnoir/famapp:v0.1.0). `latest` is fine for staging/initial.
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:latest
# `always` pulls on every `up`; set `missing` if you want to skip pulls.
FAMAPP_PULL_POLICY=always
# Authentik image tag. Bump in lockstep with Authentik release notes.
AUTHENTIK_IMAGE_TAG=2024.12.3
# Run drizzle migrations on container start. Leave true.
RUN_MIGRATIONS=true
# Public URL for the app — used by Auth.js for OIDC redirect URIs.
AUTH_URL=https://fam.ginnoir.com
# famapp Postgres credentials (used to build DATABASE_URL inside compose.yaml)
FAMAPP_DB_USER=famapp
@@ -11,20 +21,21 @@ FAMAPP_DB_NAME=famapp
# Auth.js session secret — generate with: openssl rand -base64 32
AUTH_SECRET=replace-with-openssl-rand-base64-32
# OIDC provider (Authentik) — task 06 will fill these in after bootstrapping
# OIDC provider (Authentik) — fill in after bootstrapping Authentik
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=replace-me
AUTH_OIDC_CLIENT_SECRET=replace-me
# Web Push VAPID keys — generate with: pnpm vapid:generate
# Web Push VAPID keys — generate with: pnpm vapid:generate (run from the repo)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Must be "mailto:<address>" or a URL
VAPID_SUBJECT=mailto:you@example.com
VAPID_SUBJECT=mailto:3nigma.matt@gmail.com
# ntfy (optional push fallback — leave blank to disable)
NTFY_URL=
NTFY_TOPIC=
# ntfy push fallback — uses your existing ntfy instance at ntfy.ginnoir.com
# Leave NTFY_URL blank to disable ntfy fanout.
NTFY_URL=https://ntfy.ginnoir.com
NTFY_TOPIC=famapp
# Log level: error | warn | info | debug
LOG_LEVEL=info
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.3
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm format:check
- run: pnpm build
env:
# Build only — no DB connection. Provide a placeholder so any
# module-level reads of DATABASE_URL don't crash the build.
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
AUTH_SECRET: ci-placeholder-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
+42
View File
@@ -0,0 +1,42 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+7 -2
View File
@@ -9,6 +9,9 @@ dist/
build/
*.tsbuildinfo
# Generated at build time by next.config.ts — do not commit
public/sw.js
# Auto-generated by Next.js — never edit, never commit
next-env.d.ts
@@ -41,8 +44,10 @@ playwright-report/
test-results/
tests/.auth/
# Drizzle generated artifacts (migrations themselves are committed)
drizzle/meta/
# drizzle/meta/ intentionally committed — migrator requires _journal.json at runtime
# Backups
deploy/backups/data/
# Design handoff bundle (reference only, not committed)
.design-tmp/
+1
View File
@@ -0,0 +1 @@
pnpm commitlint --edit "$1"
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+33
View File
@@ -0,0 +1,33 @@
{
"npm": {
"publish": false
},
"git": {
"commitMessage": "chore: release v${version}",
"tagName": "v${version}",
"requireBranch": "main"
},
"github": {
"release": true,
"releaseName": "v${version}"
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": {
"name": "conventionalcommits",
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactoring" },
{ "type": "docs", "section": "Documentation" },
{ "type": "chore", "section": "Maintenance", "hidden": true },
{ "type": "ci", "section": "CI/CD", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true }
]
},
"infile": "CHANGELOG.md",
"header": "# Changelog\n\nAll notable changes to famapp are documented here.\n"
}
}
}
+35
View File
@@ -0,0 +1,35 @@
# Changelog
All notable changes to famapp are documented here.
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
### Features
- add dev startup script, fix push notifications, and scope test sends to device ([0c12b05](https://github.com/ginnoir/famapp/commit/0c12b05d0989630b3ec1445f65048bd766d5f8d1))
- expand theme system to 10 full-skin palettes with light and dark modes ([c3c8ae3](https://github.com/ginnoir/famapp/commit/c3c8ae37a280206186dfde82ecb270aea5e0c512))
### Bug Fixes
- refresh server components after nav style change to fix broken layout ([f55d685](https://github.com/ginnoir/famapp/commit/f55d685a794a539d2d6256d97c1a9c4e5fa3b8bf))
- scope rail nav-label hiding to sidebar and fall back fab-only to sidebar on desktop ([671b5fa](https://github.com/ginnoir/famapp/commit/671b5fae469b3cfcafe08b20ee2ce8ac15eef994))
### Documentation
- add github_token to .env.example ([5630537](https://github.com/ginnoir/famapp/commit/563053727908186a9816416fdf54c914002011de))
# Changelog
All notable changes to famapp are documented here.
<!-- release-it prepends new entries above this line -->
## Pre-release history
Changes from before the release workflow was established (2026-06-01):
- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist.
- famapp/authentik-server now expose host ports (3010/9200) so existing Caddy stack can proxy by IP, matching the existing homelab pattern.
- Auth.js: replaced `NEXT_PUBLIC_APP_URL` with `AUTH_URL` in compose (correct next-auth v5 var).
- Push opt-in: VAPID public key now passed as prop from server component instead of `NEXT_PUBLIC_*` so pre-built images work without a build-time env var.
- ntfy integration wired to existing `ntfy.ginnoir.com` instance via `NTFY_URL`/`NTFY_TOPIC` env vars.
+13 -1
View File
@@ -32,6 +32,18 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Migration assets — drizzle/ holds SQL + meta journal; migrate.mjs runs
# at startup via the entrypoint. Explicitly copy the full drizzle-orm and
# postgres packages because the standalone tracer only includes subpaths
# the app itself imports, not the postgres-js/migrator subpath.
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/scripts/migrate.mjs ./scripts/migrate.mjs
COPY --from=builder --chown=nextjs:nodejs /app/scripts/seed.mjs ./scripts/seed.mjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules/postgres
COPY --chown=nextjs:nodejs docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+23 -1
View File
@@ -19,10 +19,32 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
- **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
- **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass.
- **20 — Dashboard composition (single-dashboard MVP)**. Added `default_dashboard_layout` jsonb column to `users` + migration `0007_uneven_living_lightning.sql`. Created `src/modules/_core/manifest.tsx` (`core.activity` placeholder widget) and registered it. Updated all three module manifests (calendar, lists, notes) with real async server component widget renders (data-fetching, empty states). Created `src/lib/dashboard.ts` (layout parsing + `computeDefaultLayout` greedy packer). Built `src/app/page.tsx` — 12-col CSS Grid, static `smColSpan` lookup for Tailwind class safety, per-widget `<Suspense>` for parallel loading, graceful skip for unknown widget IDs. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
- **21 — Quick-add registry**. Added `url: string` to `QuickAddAction` type (action is now optional). Added `getQuickAdds()` / `SerializedQuickAddItem` to registry (strips non-serializable `action` fn before crossing server→client boundary). Updated all three module manifests with navigation URLs. Built `QuickAddProvider` (context + cmd+k global shortcut), `QuickAddFab` (opens sheet, replaces plain button in dashboard), `QuickAddSheet` (bottom drawer / desktop popover grouped by module), and `CommandPalette` (cmdk-powered modal with arrow + enter + esc keyboard nav). Provider in root layout receives actions from `getQuickAdds()` at render time — adding a module's `quickAdds` automatically appears in both surfaces. Also added `.claude/**` to ESLint ignores to prevent stale worktree build artifacts from failing lint. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
- **22 — Activity log**. Added `activity_log` table to `_core/schema.ts` with index on `(household_id, created_at desc)`. Migration `0008_activity_log.sql` applied. `logActivity()` server function in `_core/activity.ts` reads current session and inserts a row. Added `ActivityLogEntry` type and optional `renderActivity?(entry): string` to `EntityTypeRegistration` in `_core/module.ts`. All three module manifests implement `renderActivity` for each entity type (human-readable, no hardcoded branches in the widget). Replaced `core.activity` widget stub with a real async server component that queries the last 20 rows via `getEntityType(entry.entityType)?.renderActivity(entry)`. Wired `logActivity()` into every create/update/delete in calendar, lists, and notes server actions. Also added `text` to `getAuthorizedItem` select so toggle/delete log the item text. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
- **30 — Share-link service**. Added `share_links` table to `_core/schema.ts` + migration `0009_share_links.sql`. Created `_core/share.ts` with `createShareLink`, `resolveShareToken`, `revokeShareLink`, and `getActiveShareLinks`. Token is 32 random bytes (URL-safe base64), stored as SHA-256 hash — raw token only returned at creation. `createShareLink` guards that the entity type is registered with `canShare === true`. `resolveShareToken` returns null for expired or revoked tokens. All three functions exported from `_core/index.ts`. `/settings` page gained a Share links card: lists active links (entity label, read/write capabilities, expiry) with a Revoke button per link (server action in `settings/actions.ts`). `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
- **25 — Multiple dashboards per user**. Added `dashboards` table (migration `0012_dashboards.sql`). Migrated each user's `default_dashboard_layout` into a "Home" dashboard row with `is_default = true`; dropped the interim column. Server actions: `listDashboards`, `createDashboard`, `renameDashboard`, `deleteDashboard`, `setDefaultDashboard`, `reorderDashboards`, `saveDashboardLayout`, `resetDashboardLayout`, `resolveWidgetConfigOptions`. `/` redirects to the user's default `/d/<slug>`. Dashboard switcher in AppNav renders tabs (active highlighted client-side) with a `+` button to create new dashboards and a kebab menu on the active tab for rename / set-default / delete. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
- **26 — Customizable layout + widget configuration**. Installed `react-grid-layout` v2. Dashboard pages check `?edit=1` to enter edit mode, rendering a client `DashboardEditor` instead of the static grid. Editor uses `react-grid-layout` with `gridConfig`/`dragConfig` v2 API; each widget shell shows a drag handle, configure button (⚙), and remove button (🗑). `WidgetPicker` is a two-step modal: step 1 lists all registry widgets grouped by category; step 2 is a `WidgetConfigurator` auto-generated from the widget's default config — handles `"all"|string[]` multi-selects, booleans, numbers, and enums. `resolveWidgetConfigOptions` server action fetches dynamic options (calendars, lists). Save validates each config against its registered Zod schema. Reset to defaults calls `computeDefaultLayout()`. `pnpm typecheck`, `pnpm build` pass.
- **31 — Public share viewer**. Made `actorId` nullable in `activity_log` (migration `0010_nullable_actor_id.sql`, `onDelete: "set null"`) for anonymous share-page mutations. Added `logShareActivity` to `_core/activity.ts` (no session, explicit `householdId`). Added `householdId` to `resolveShareToken` return. Added `renderSharedView` to `EntityTypeRegistration` type. Each module implements `loadForShare` (bare DB queries, no session) and `renderSharedView`: calendar shows upcoming 90-day events or single-event details, lists shows items with optional toggle, notes shows title + body. `toggleShareListItem` server action lives in `lists/server/share-actions.ts` — validates token write capability, verifies item→list→household chain, logs `share.toggle` with `actorId = null`. `/app/s/[token]/page.tsx` resolves token, dispatches to `loadForShare` + `renderSharedView`, returns friendly error for invalid/expired tokens, sets `noindex`. Middleware `/s/*` exemption confirmed present. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
- **50 — PWA shell**. `public/manifest.webmanifest` (name, short_name, icons, theme_color, display: standalone, start_url `/`). Placeholder PNG icons at 180, 192, 384, 512 (regular + maskable) generated by `scripts/generate-icons.mjs` (`pnpm gen:icons`); `public/icon.svg` committed as source. Hand-rolled service worker at `public/sw.js`: precaches `offline.html` on install, cache-first for `/_next/static/`, network-first for navigation with offline fallback, network-only for API routes. `src/components/pwa-register.tsx` registers the SW client-side. `src/components/install-prompt.tsx` shows a dismissible banner: `beforeinstallprompt` on Android/Chrome, a one-time "Add to Home Screen" hint on iOS (detected via UA + `navigator.maxTouchPoints`, suppressed in standalone mode). Root layout exports `viewport` (themeColor), updated `metadata` (manifest, appleWebApp, apple-touch-icon), and mounts both new components. `pnpm typecheck`, `pnpm lint`, `pnpm build` all clean.
- **51 — Offline shell + service worker caching**. `next.config.ts` generates `public/sw.js` as a side effect on every `next build` / `next dev` invocation, embedding a build timestamp as `CACHE_VERSION` (stable `"dev"` string in development to avoid hot-reload cache churn; epoch milliseconds in production). SW strategies: stale-while-revalidate for `/_next/static/` chunks and navigation HTML (cached page served instantly, network update fires in background); network-first with 2-second abort timeout for API GETs falling back to cache; network-only for mutations (POST/PATCH/DELETE/PUT) — if offline, all controlled clients receive `{ type: "OFFLINE_MUTATION" }` via `postMessage` and a synthetic 503 is returned. Activate handler evicts all `famapp-*` caches whose suffix doesn't match the current version, then claims clients. `pwa-register.tsx` extended with three inline toasts: amber "offline" banner (persistent, driven by `navigator.onLine` + `online`/`offline` events), red "changes can't be saved" toast (auto-dismisses in 4 s, driven by SW postMessage), and indigo "new version available — refresh" bottom toast (driven by `controllerchange` with `hadController` guard). `pnpm typecheck`, `pnpm build` pass.
- **40 — Web Push (VAPID)**. Installed `web-push` + `@types/web-push`. Added `pnpm vapid:generate` script (`scripts/vapid-generate.mjs`) that prints all three env vars to stdout. Added `push_subscriptions` table to `_core/schema.ts` + migration `0013_push_notify_reminders.sql`. Created `_core/push.ts` with `sendPush(userId, payload)` — iterates subscriptions, removes 404/410 stale entries. Added `push` and `notificationclick` event handlers to the generated `public/sw.js` template. Created `<PushOptIn />` client component on `/settings` (opt-in button → `subscribeToPush` server action, disable button → `unsubscribeFromPush`, test button → `sendTestNotification`). Documented `NEXT_PUBLIC_VAPID_PUBLIC_KEY` in `.env.example`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
- **42 — Notification bus + ntfy adapter**. Added `notifications` table and `notif_push`/`notif_inapp`/`notif_ntfy` columns on `users` (migration `0013_push_notify_reminders.sql`). Created `_core/notify.ts` with `notify(userId, { title, body, url, channels? })` — fans out to push (if VAPID configured), in-app DB insert, and ntfy POST (if `NTFY_URL`+`NTFY_TOPIC` set). Added `<NotificationBell />` async server component in `AppNav`: queries last 20 notifications, shows unread badge, dropdown inbox with mark-read and mark-all-read. Added `<NotifyChannelToggles />` client component with per-channel checkboxes in `/settings`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
- **41 — Reminders engine**. Added `fired_at` and `created_by` columns to `reminders` table (migration `0013_push_notify_reminders.sql`); default channel changed to `'auto'`. Created `_core/reminders.ts` with `scheduleReminder` (upsert by entity), `cancelReminder`, `listReminders`, and `tickReminders` (30 s tick, `pg_try_advisory_xact_lock` guard). `startReminderWorker()` started via `src/instrumentation.ts` on the Node.js runtime. Notes actions updated to use `scheduleReminder`/`cancelReminder` instead of raw SQL. Calendar `createEvent` accepts optional `remindMinutesBefore` and schedules a reminder; `deleteEvent` calls `cancelReminder`. Calendar-shell event dialog shows "Remind me 30 min before" checkbox (new events only, checked by default). Reminder worker confirmed starting on server boot (logged in dev server output). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
- **60 — Postgres backups**. `famapp-backup` Alpine service added to `deploy/compose.yaml`; scripts in `deploy/backups/`: `backup.sh` (`pg_dump -Fc` for famapp-db + authentik-db nightly at 02:00), `retain.sh` (14 daily / 8 weekly / 6 monthly), `restore.sh` (restore from any dump file), `entrypoint.sh` (installs postgresql-client, sets up crontab, starts crond). Backup files stored in `backups` named Docker volume. Restore procedure in `deploy/backups/README.md`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
- **61 — Rate limiting on share links**. `src/lib/rate-limit.ts`: pure-JS Edge-compatible sliding-window counter (50 req/min, 1-min window, 10k-key LRU eviction) with `consume()`, `isRateLimited()`, and `recordFailure()` exports. `src/middleware.ts` calls `consume(ip:prefix)` for every `/s/[token]` request and returns 429 with `Retry-After: 60` when the bucket is exceeded. `src/app/s/[token]/page.tsx` additionally tracks only failed `resolveShareToken` lookups via `recordFailure()` in the Node.js runtime (separate module instance from middleware; Redis would unify them for multi-replica deployments). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
- **62 — Structured logging**. Installed `pino` + `pino-pretty` (dev). `src/lib/logger.ts`: pino instance — JSON in production (`stdout`), pretty-printed in dev; level from `LOG_LEVEL` env (default `info`); `pid`/`hostname` stripped, ISO timestamps. `src/middleware.ts` logs every request as structured JSON via `console.log` (Edge-compatible; pino not available in Edge runtime) with `method`, `path`, `status`, `ms`, `authenticated`. All `console.error`/`console.log` calls in `_core/push.ts`, `_core/notify.ts`, `_core/reminders.ts` replaced with `logger.error`/`logger.info`; sensitive fields (endpoint URLs, keys) are never logged as named fields. `next.config.ts` adds `serverExternalPackages: ["pino","pino-pretty"]` so webpack does not bundle them. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
## Next up
- Next task in `docs/tasks/`.
- **Ready to tag v0.1.0.** All phase 17 tasks complete and production wiring verified.
- Run `docs/tasks/09-pre-deploy-checklist.md` before pushing the tag.
- On the server: clone repo to `/srv/famapp`, copy `.env.production.example``/srv/famapp/deploy/.env`, fill in secrets (AUTH_SECRET, DB passwords, AUTHENTIK_SECRET_KEY, VAPID keys, AUTH_OIDC_CLIENT_ID/SECRET), add the Caddyfile snippet, bootstrap Authentik per `deploy/authentik/README.md`, then `docker compose -f deploy/compose.yaml up -d`.
## Development login/testing notes
+12
View File
@@ -0,0 +1,12 @@
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
["feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "revert"],
],
"subject-case": [2, "always", "lower-case"],
"subject-max-length": [2, "always", 100],
},
};
+4 -3
View File
@@ -1,9 +1,10 @@
# Include from main Caddyfile or paste into the existing one.
# famapp — paste into your existing Caddyfile.
# famapp runs on 3010, authentik-server on 9200 (both bound to the host by deploy/compose.yaml).
fam.ginnoir.com {
reverse_proxy famapp:3000
reverse_proxy 192.168.1.69:3010
}
auth.ginnoir.com {
reverse_proxy authentik-server:9000
reverse_proxy 192.168.1.69:9200
}
+45
View File
@@ -0,0 +1,45 @@
# Deploying famapp
Trunk-based: `main` is always green. Production deploys only from version tags (`vX.Y.Z`). The dev-login flow is retained for local development behind a double gate (`NODE_ENV !== "production"` **and** `ENABLE_DEV_LOGIN=true`); a startup assertion in `src/lib/dev-login-config.ts` makes a misconfigured prod fail loud instead of silently exposing it.
## One-time host setup
1. Install Docker + Compose plugin on the host.
2. `git clone` this repo to e.g. `/srv/famapp`.
3. Copy `.env.production.example``/srv/famapp/deploy/.env` and fill in real values.
- `openssl rand -base64 32` for `AUTH_SECRET`.
- `openssl rand -base64 60` for `AUTHENTIK_SECRET_KEY`.
- `pnpm vapid:generate` (locally) for the three VAPID lines.
4. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
5. Wire Caddy with `deploy/Caddyfile.snippet`.
## Cutting a release
```bash
# from your dev machine, on main, with a clean working tree
git tag v0.1.0
git push origin v0.1.0
```
`.github/workflows/release.yml` builds + pushes `ghcr.io/ginnoir/famapp:v0.1.0`, `:0.1`, and `:latest` to GHCR.
## Deploying a release on the host
```bash
cd /srv/famapp/deploy
# pin to the tag you just cut
echo 'FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.1.0' >> .env # or edit in place
docker compose pull famapp
docker compose up -d famapp
docker compose logs -f famapp # watch migrations + boot
```
The container's entrypoint runs `node scripts/migrate.mjs` before starting the server. To skip migrations on a given start (rare — e.g. emergency rollback to an older schema-compatible image), set `RUN_MIGRATIONS=false`.
## Rollback
Edit `.env` to point `FAMAPP_IMAGE` at the previous tag, then `docker compose up -d famapp`. If the rollback target predates a migration that's already applied, restore from backup (`deploy/backups/README.md`) before bringing the older image up.
## Pre-deploy checklist
Run [docs/tasks/09-pre-deploy-checklist.md](../docs/tasks/09-pre-deploy-checklist.md) before every deploy.
+102
View File
@@ -0,0 +1,102 @@
# Backups
The `famapp-backup` service performs nightly compressed `pg_dump` of both
`famapp-db` and `authentik-db` at **02:00 server time**.
## Storage layout
```
/backups/ (named Docker volume: famapp_backups)
famapp/
daily/ ← last 14 days
weekly/ ← last 8 Sundays
monthly/ ← last 6 first-of-month dumps
authentik/
daily/
weekly/
monthly/
```
Dump files are named `YYYY-MM-DD.dump` in custom (`-Fc`) format (internal
compression, ~35× smaller than plain SQL).
## Retention
| Tier | Kept | Trigger |
| ------- | ---- | ---------------- |
| daily | 14 | every night |
| weekly | 8 | Sunday night |
| monthly | 6 | 1st of the month |
Retention is enforced by `retain.sh` at the end of each `backup.sh` run.
## Restore procedure
### 1. Identify the dump
```sh
# List available dumps
docker exec famapp-backup-1 ls /backups/famapp/daily/
```
### 2a. Restore inside the backup container (recommended)
```sh
docker exec famapp-backup-1 /scripts/restore.sh \
/backups/famapp/daily/2024-06-01.dump \
postgres://famapp:SECRET@famapp-db:5432/famapp
```
Replace `SECRET` with the value of `FAMAPP_DB_PASSWORD` in your `.env` file.
For authentik:
```sh
docker exec famapp-backup-1 /scripts/restore.sh \
/backups/authentik/daily/2024-06-01.dump \
postgres://authentik:SECRET@authentik-db:5432/authentik
```
### 2b. Restore to a separate database (safe — non-destructive)
Create a fresh target database first, then restore into it:
```sh
# Create the target DB
docker exec famapp-db-1 createdb \
-U "$FAMAPP_DB_USER" famapp_restore
# Restore
docker exec famapp-backup-1 /scripts/restore.sh \
/backups/famapp/daily/2024-06-01.dump \
postgres://famapp:SECRET@famapp-db:5432/famapp_restore
```
### 2c. Restore on a fresh host (disaster recovery)
```sh
# Copy the dump file out of the volume
docker cp famapp-backup-1:/backups/famapp/daily/2024-06-01.dump ./
# Spin up a temporary Postgres container and restore
docker run --rm \
-e PGPASSWORD=SECRET \
-v "$(pwd)/2024-06-01.dump:/dump.dump:ro" \
postgres:16-alpine \
pg_restore -h <new-db-host> -U famapp -d famapp \
--no-owner --no-acl /dump.dump
```
## Off-site replication
The backups live in the `famapp_backups` Docker named volume. To copy them
to another host, rsync the volume's data directory periodically (e.g. from a
host cron job):
```sh
# On the Docker host, add to /etc/cron.d/famapp-rsync:
30 3 * * * root rsync -a --delete \
/var/lib/docker/volumes/famapp_backups/_data/ \
user@offsite-server:/opt/famapp-backups/
```
Encryption at rest is handled at the disk/filesystem layer (e.g. LUKS).
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Nightly pg_dump for famapp-db and authentik-db.
# Called by crond. Outputs to /backups/<db>/daily/<YYYY-MM-DD>.dump
# and copies into weekly/ (Sundays) and monthly/ (1st of month).
set -eu
TODAY=$(date +%Y-%m-%d)
DOW=$(date +%u) # 1=Mon … 7=Sun
DOM=$(date +%d | sed 's/^0*//') # day-of-month without leading zero
dump_db() {
local name="$1"
local host="$2"
local port="$3"
local user="$4"
local pass="$5"
local dbname="$6"
local daily_dir="/backups/$name/daily"
local dest="$daily_dir/$TODAY.dump"
mkdir -p "$daily_dir" \
"/backups/$name/weekly" \
"/backups/$name/monthly"
echo "[backup] dumping $name ..."
PGPASSWORD="$pass" pg_dump \
-h "$host" -p "$port" -U "$user" -d "$dbname" \
-Fc -f "$dest"
echo "[backup] $name$dest"
if [ "$DOW" = "7" ]; then
cp "$dest" "/backups/$name/weekly/$TODAY.dump"
echo "[backup] weekly copy saved for $name"
fi
if [ "$DOM" = "1" ]; then
cp "$dest" "/backups/$name/monthly/$TODAY.dump"
echo "[backup] monthly copy saved for $name"
fi
}
dump_db famapp \
"${FAMAPP_DB_HOST:-famapp-db}" \
"${FAMAPP_DB_PORT:-5432}" \
"$FAMAPP_DB_USER" \
"$FAMAPP_DB_PASSWORD" \
"$FAMAPP_DB_NAME"
dump_db authentik \
"${AUTHENTIK_DB_HOST:-authentik-db}" \
"${AUTHENTIK_DB_PORT:-5432}" \
"$AUTHENTIK_DB_USER" \
"$AUTHENTIK_DB_PASSWORD" \
"$AUTHENTIK_DB_NAME"
/scripts/retain.sh
echo "[backup] complete"
+1
View File
@@ -0,0 +1 @@
0 2 * * * /scripts/backup.sh >> /var/log/backup.log 2>&1
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Container entrypoint: install postgresql-client, create backup directories,
# install crontab, and start crond.
set -eu
apk add --no-cache postgresql-client >/dev/null
mkdir -p \
/backups/famapp/daily /backups/famapp/weekly /backups/famapp/monthly \
/backups/authentik/daily /backups/authentik/weekly /backups/authentik/monthly
# Make scripts executable (volume mount may strip +x).
chmod +x /scripts/backup.sh /scripts/retain.sh /scripts/restore.sh
# Install root crontab from the mounted file.
mkdir -p /var/spool/cron/crontabs
cp /scripts/crontab /var/spool/cron/crontabs/root
chmod 600 /var/spool/cron/crontabs/root
echo "[entrypoint] backup service started — first run at 02:00"
exec crond -f -l 2
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Restore a pg_dump (-Fc format) to a target Postgres database.
# The target DB must already exist and be empty (or you accept overwriting data).
#
# Usage:
# restore.sh <dump-file> <target-db-url>
#
# Example (inside the backup container):
# /scripts/restore.sh \
# /backups/famapp/daily/2024-06-01.dump \
# postgres://famapp:secret@famapp-db:5432/famapp
#
# Example (from the host via docker exec):
# docker exec famapp-backup-1 /scripts/restore.sh \
# /backups/famapp/daily/2024-06-01.dump \
# postgres://famapp:secret@famapp-db:5432/famapp_restore
set -eu
DUMP_FILE="${1:?Usage: restore.sh <dump-file> <target-db-url>}"
TARGET_URL="${2:?Usage: restore.sh <dump-file> <target-db-url>}"
[ -f "$DUMP_FILE" ] || { echo "[restore] ERROR: $DUMP_FILE not found"; exit 1; }
echo "[restore] $DUMP_FILE$TARGET_URL"
pg_restore -d "$TARGET_URL" --no-owner --no-acl --exit-on-error "$DUMP_FILE"
echo "[restore] done"
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# Prune old backup files according to retention policy.
# daily: keep 14 weekly: keep 8 monthly: keep 6
set -eu
prune() {
local dir="$1"
local keep="$2"
[ -d "$dir" ] || return 0
# Files are YYYY-MM-DD.dump; lexicographic sort = chronological.
# tail skips the N newest; xargs deletes the rest.
ls -1 "$dir"/*.dump 2>/dev/null \
| sort \
| head -n "-$keep" \
| xargs -r rm -f --
}
for db in famapp authentik; do
prune "/backups/$db/daily" 14
prune "/backups/$db/weekly" 8
prune "/backups/$db/monthly" 6
done
echo "[retain] done"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -e
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
echo "running migrations..."
node /app/scripts/migrate.mjs
else
echo "skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
fi
exec node /app/server.js
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
echo "running migrations..."
node /app/scripts/migrate.mjs
else
echo "skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
fi
node /app/scripts/seed.mjs
exec node /app/server.js
+36
View File
@@ -0,0 +1,36 @@
# 0003 — Release workflow
Date: 2026-06-01
Status: accepted
## Context
The project needed a consistent, enforced standard for commit messages, changelog generation, versioning, and GitHub releases — especially important since both humans and AI agents (Claude Code, Codex) commit to the repo.
## Decision
- **commitlint** (`@commitlint/config-conventional`) enforces conventional commit format at the `commit-msg` git hook
- **husky** v9 wires the hooks; `prepare` installs them on `pnpm install`
- **lint-staged** runs prettier + eslint on staged files at the `pre-commit` hook
- **release-it** + `@release-it/conventional-changelog` manages the full release cycle:
- bumps `package.json` version (semver)
- generates/prepends to `CHANGELOG.md`
- creates a signed git tag (`v{version}`)
- creates a GitHub Release with the generated notes
- pushes the tag and commit
Release commands:
- `pnpm release` — interactive (prompts for increment type)
- `pnpm release:patch` / `:minor` / `:major` — non-interactive
- `pnpm release:dry` — preview without writing anything
- Requires `GITHUB_TOKEN` in env for GitHub Release creation
Conventional commit types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
## Consequences
- Every commit is validated; bad format is rejected immediately
- CHANGELOG.md is auto-generated from commit history — no manual upkeep
- Releases are reproducible: one command, idempotent output
- `chore`, `ci`, `test` commits are hidden in the changelog; `feat`, `fix`, `perf`, `refactor`, `docs` are surfaced
+57 -8
View File
@@ -34,20 +34,69 @@ This note tracks the development-only work added to make the app easy to run and
## Current Local Run Procedure
One command starts the database, runs migrations, seeds, and launches the dev server:
```powershell
docker compose -f docker-compose.dev.yaml up -d
pnpm db:migrate
pnpm db:seed
pnpm dev
pnpm dev:local
```
Then open:
The script prints three URLs at startup:
```text
http://127.0.0.1:3000/login
| URL | Use for |
|-----|---------|
| `http://localhost:3000` | Browser on this machine |
| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) |
| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) |
Then open `/login` and click **Dev login**.
### HMR and restarts
`next dev` has hot module replacement — most `.ts`/`.tsx` changes apply instantly without a restart.
A full restart (`Ctrl+C``pnpm dev:local`) is needed for:
- `.env` changes
- `next.config.ts` changes
### Clean slate
To delete all local data and start fresh (e.g. after a destructive schema change):
```powershell
pnpm dev:reset
```
Click **Dev login**.
### HTTPS for push notification and PWA testing (one-time setup)
Service workers and Web Push require HTTPS. The plain LAN address won't work for these.
Route through the existing Caddy server on the home server instead — no extra tooling needed.
**Step 1 — DHCP reservation**
Set a reservation on the router so the dev machine always gets `192.168.1.74`.
**Step 2 — DNS record**
Add a `dev.ginnoir.com` A record pointing to the same public IP as `fam.ginnoir.com`.
**Step 3 — Windows Firewall**
Allow inbound TCP 3000 on the dev machine (run once in an elevated PowerShell):
```powershell
New-NetFirewallRule -DisplayName "famapp dev" -Direction Inbound `
-Protocol TCP -LocalPort 3000 -Action Allow
```
**Step 4 — Caddy snippet**
Paste `deploy/Caddyfile.dev.snippet` into the home server Caddyfile and reload:
```bash
caddy reload --config /path/to/Caddyfile
```
After this, `https://dev.ginnoir.com` proxies to the dev machine with a real Let's Encrypt cert.
## Current Local E2E Procedure
+12 -4
View File
@@ -25,11 +25,19 @@ Cheap to set up before modules exist, painful to retrofit afterwards. Locking in
Use shadcn's CSS-variable conventions, scoped by `data-theme` on `<html>`:
```css
:root { /* default light tokens */ }
.dark { /* default dark tokens */ }
:root {
/* default light tokens */
}
.dark {
/* default dark tokens */
}
[data-theme="warm"] { /* warm light */ }
[data-theme="warm"].dark { /* warm dark */ }
[data-theme="warm"] {
/* warm light */
}
[data-theme="warm"].dark {
/* warm dark */
}
```
Ship at least **two** themes (`default` + one more) so the architecture is actually exercised. Token values can be placeholder — refining the palettes is a separate later concern.
+36
View File
@@ -0,0 +1,36 @@
# 09 — Pre-deploy checklist (recurring)
Run this before every production deploy (first deploy and each tagged release). Dev-login is intentionally retained behind a double gate; this checklist is what keeps that gate honest.
## Env hygiene
- [ ] Server `.env` (next to `deploy/compose.yaml`) does **not** set:
- `ENABLE_DEV_LOGIN`
- `DEV_LOGIN_EMAIL`, `DEV_LOGIN_NAME`, `DEV_HOUSEHOLD_NAME`
- [ ] Server `.env` sets real values for:
- `NEXT_PUBLIC_APP_URL` (https)
- `AUTH_SECRET` (`openssl rand -base64 32`)
- `AUTH_OIDC_ISSUER`, `AUTH_OIDC_CLIENT_ID`, `AUTH_OIDC_CLIENT_SECRET`
- `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`
- `FAMAPP_DB_*`, `AUTHENTIK_DB_*`, `AUTHENTIK_SECRET_KEY`
- [ ] `FAMAPP_IMAGE` pins a specific version tag (`ghcr.io/ginnoir/famapp:vX.Y.Z`), not `latest`, after first deploy.
## Code-side guard
`src/lib/dev-login-config.ts` throws on import if `NODE_ENV=production` and `ENABLE_DEV_LOGIN=true`. Container will refuse to start.
- [ ] Confirm guard is still present (do not remove without updating this checklist).
## Smoke after deploy
- [ ] `https://fam.ginnoir.com/login` shows only **Sign in with SSO** (no Dev login button).
- [ ] Real Authentik sign-in succeeds, lands on dashboard.
- [ ] First user appears in DB with household membership, default calendars, default lists.
- [ ] No `dev@famapp.local` row in production `users` table.
- [ ] Push opt-in works (settings → enable → test notification arrives).
- [ ] A share link created from `/settings` resolves at `/s/<token>` while signed out.
## Backup / rollback
- [ ] Last `famapp-backup` cron run succeeded (check container logs).
- [ ] Previous image tag is known and recorded in `CHANGELOG.md`, so rollback = bump `FAMAPP_IMAGE` and `docker compose up -d famapp`.
@@ -1,47 +0,0 @@
# 09 — Production dev-login removal gate
## Goal
Before the first production deployment, verify that development-only login/test shortcuts cannot be enabled accidentally in production.
## Depends on
- 06
- 07
- local dev-login setup in `docs/dev-login.md`
## Scope
- Review all production env sources:
- `.env.production.example`
- `deploy/compose.yaml`
- any host-level Docker Compose override files
- deployment secrets on the server
- Confirm production does not set:
- `ENABLE_DEV_LOGIN=true`
- `DEV_LOGIN_EMAIL`
- `DEV_LOGIN_NAME`
- `DEV_HOUSEHOLD_NAME`
- Confirm production Authentik variables are real:
- `AUTH_OIDC_ISSUER`
- `AUTH_OIDC_CLIENT_ID`
- `AUTH_OIDC_CLIENT_SECRET`
- Build with production-like env and verify `/login` renders only the SSO login path.
- Verify the app still protects private routes without a valid Auth.js session cookie.
- Verify real Authentik login creates the expected user, household membership, default calendars, and default lists.
- Remove any accidental dev users from the production database.
## Optional hardening
- Remove `src/lib/dev-login.ts` and the Dev login form from `src/app/login/page.tsx` entirely before first production deployment.
- If retaining the code for future local development, keep the current double gate:
- `NODE_ENV !== "production"`
- `ENABLE_DEV_LOGIN=true`
## Acceptance criteria
- [ ] Production env cannot enable Dev login accidentally.
- [ ] `/login` in production does not show **Dev login**.
- [ ] Direct dev-login action execution is unavailable in production.
- [ ] Real Authentik login works on the production domain.
- [ ] No `dev@famapp.local` or configured dev-login user exists in production data.
+1 -1
View File
@@ -6,7 +6,7 @@ Implement the `calendar` module: first-class **calendars** (multiple per househo
## Why
Calendars are entities, not a singleton concept. A user might run a *Personal* (private) calendar, a *Family* (household-shared) calendar, and a *Wedding planning* calendar that gets share-linked publicly. The calendar module ships that abstraction; widgets and the share-link service then reuse it generically.
Calendars are entities, not a singleton concept. A user might run a _Personal_ (private) calendar, a _Family_ (household-shared) calendar, and a _Wedding planning_ calendar that gets share-linked publicly. The calendar module ships that abstraction; widgets and the share-link service then reuse it generically.
## Depends on
+11 -2
View File
@@ -22,5 +22,14 @@ A single global `+` button on the dashboard (and a `cmd+k` palette anywhere) tha
## Acceptance criteria
- [ ] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
- [ ] Keyboard navigation works in the palette (arrows + enter + escape).
- [x] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
- [x] Keyboard navigation works in the palette (arrows + enter + escape).
## Implementation notes
- `QuickAddAction` gained a `url: string` field; `action` made optional (future inline modals).
- `SerializedQuickAddItem` strips the non-serializable `action` fn so it can cross the server→client RSC boundary as props to `QuickAddProvider`.
- `getQuickAdds()` in the registry returns one `SerializedQuickAddItem` per registered quick-add, enriched with `moduleId` / `moduleName` for grouping.
- Root layout calls `getQuickAdds()` and passes the result to `<QuickAddProvider>` which wraps the whole app. `<QuickAddSheet>` and `<CommandPalette>` live inside the provider and read from context.
- `cmdk` installed for the command palette; keyboard shortcut (cmd/ctrl+k) wired via `useEffect` in the provider.
- `.claude/**` added to ESLint ignores to prevent stale worktree `.next` build artifacts from failing lint.
+1 -1
View File
@@ -6,7 +6,7 @@ Each user can create any number of named dashboards, switch between them, and ch
## Why
Decouples "the dashboard" from "user's home page". A user might have a *Daily*, *Garden*, and *Wedding planning* dashboard, each composing different widgets. This task introduces the entity; task 26 adds the editor.
Decouples "the dashboard" from "user's home page". A user might have a _Daily_, _Garden_, and _Wedding planning_ dashboard, each composing different widgets. This task introduces the entity; task 26 adds the editor.
## Depends on
+9 -2
View File
@@ -52,8 +52,15 @@ Same component is reused for **Configure** on an already-placed widget. Re-runs
{
"version": 1,
"widgets": [
{ "widgetId": "lists.list", "config": { "listIds": ["..."], "showCompleted": false }, "x": 0, "y": 0, "w": 6, "h": 4 }
]
{
"widgetId": "lists.list",
"config": { "listIds": ["..."], "showCompleted": false },
"x": 0,
"y": 0,
"w": 6,
"h": 4,
},
],
}
```
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "default_dashboard_layout" jsonb;
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "activity_log" (
"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,
"actor_id" uuid NOT NULL,
"action" text NOT NULL,
"payload" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "activity_log_household_created_idx" ON "activity_log" USING btree ("household_id","created_at");
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE "share_links" (
"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,
"token" text NOT NULL,
"capabilities" jsonb NOT NULL,
"created_by" uuid NOT NULL,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "share_links_token_unique" UNIQUE("token")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "share_links" ADD CONSTRAINT "share_links_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "share_links" ADD CONSTRAINT "share_links_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX "share_links_household_idx" ON "share_links" USING btree ("household_id");
--> statement-breakpoint
CREATE INDEX "share_links_entity_idx" ON "share_links" USING btree ("entity_type","entity_id");
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE "activity_log" ALTER COLUMN "actor_id" DROP NOT NULL;
--> statement-breakpoint
ALTER TABLE "activity_log" DROP CONSTRAINT "activity_log_actor_id_users_id_fk";
--> statement-breakpoint
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "completion_visibility_hours" integer NOT NULL DEFAULT 24;
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE "dashboards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"slug" text NOT NULL,
"is_default" boolean NOT NULL DEFAULT false,
"position" integer NOT NULL DEFAULT 0,
"layout" jsonb NOT NULL DEFAULT '{"version":1,"widgets":[]}',
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "dashboards_user_slug_uq" ON "dashboards" ("user_id", "slug");
-- Migrate each existing user's default_dashboard_layout into a "Home" dashboard.
-- Idempotent: ON CONFLICT DO NOTHING.
INSERT INTO "dashboards" ("user_id", "name", "slug", "is_default", "position", "layout")
SELECT
"id",
'Home',
'home',
true,
0,
COALESCE("default_dashboard_layout", '{"version":1,"widgets":[]}')
FROM "users"
ON CONFLICT DO NOTHING;
ALTER TABLE "users" DROP COLUMN IF EXISTS "default_dashboard_layout";
+39
View File
@@ -0,0 +1,39 @@
-- Task 40: push_subscriptions table
CREATE TABLE "push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"endpoint" text NOT NULL UNIQUE,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"user_agent" text,
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "push_subscriptions_user_idx" ON "push_subscriptions" ("user_id");
-- Task 42: notifications table
CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"title" text NOT NULL,
"body" text NOT NULL,
"url" text,
"read_at" timestamp with time zone,
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "notifications_user_read_idx" ON "notifications" ("user_id", "read_at");
-- Task 42: per-user notification channel preferences
ALTER TABLE "users"
ADD COLUMN "notif_push" boolean NOT NULL DEFAULT true,
ADD COLUMN "notif_inapp" boolean NOT NULL DEFAULT true,
ADD COLUMN "notif_ntfy" boolean NOT NULL DEFAULT false;
-- Task 41: add fired_at and created_by to reminders
ALTER TABLE "reminders"
ADD COLUMN "fired_at" timestamp with time zone,
ADD COLUMN "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL;
-- Task 41: update default channel value to 'auto'
UPDATE "reminders" SET "channel" = 'auto' WHERE "channel" = 'in_app';
+7
View File
@@ -0,0 +1,7 @@
ALTER TABLE "users" ADD COLUMN "theme_palette" text NOT NULL DEFAULT 'clay';--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "theme_font_pair" text NOT NULL DEFAULT 'serif-sans';--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "theme_density" text NOT NULL DEFAULT 'regular';--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "theme_dash_layout" text NOT NULL DEFAULT 'classic';--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "theme_cal_view" text NOT NULL DEFAULT 'month';--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "theme_nav_style" text NOT NULL DEFAULT 'rail-desktop';--> statement-breakpoint
ALTER TABLE "users" DROP COLUMN IF EXISTS "theme";
+176
View File
@@ -0,0 +1,176 @@
{
"id": "afe12cff-df99-417d-977c-3e6a1c77981a",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.household_members": {
"name": "household_members",
"schema": "",
"columns": {
"household_id": {
"name": "household_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "household_member_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'member'"
}
},
"indexes": {},
"foreignKeys": {
"household_members_household_id_households_id_fk": {
"name": "household_members_household_id_households_id_fk",
"tableFrom": "household_members",
"tableTo": "households",
"columnsFrom": [
"household_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"household_members_user_id_users_id_fk": {
"name": "household_members_user_id_users_id_fk",
"tableFrom": "household_members",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"household_members_household_id_user_id_pk": {
"name": "household_members_household_id_user_id_pk",
"columns": [
"household_id",
"user_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.households": {
"name": "households",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.household_member_role": {
"name": "household_member_role",
"schema": "public",
"values": [
"owner",
"member"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+343
View File
@@ -0,0 +1,343 @@
{
"id": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89012",
"prevId": "afe12cff-df99-417d-977c-3e6a1c77981a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_account_id": {
"name": "provider_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accounts_provider_provider_account_id_pk": {
"name": "accounts_provider_provider_account_id_pk",
"columns": ["provider", "provider_account_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification_tokens": {
"name": "verification_tokens",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
"name": "verification_tokens_identifier_token_pk",
"columns": ["identifier", "token"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.household_members": {
"name": "household_members",
"schema": "",
"columns": {
"household_id": {
"name": "household_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "household_member_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'member'"
}
},
"indexes": {},
"foreignKeys": {
"household_members_household_id_households_id_fk": {
"name": "household_members_household_id_households_id_fk",
"tableFrom": "household_members",
"tableTo": "households",
"columnsFrom": ["household_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"household_members_user_id_users_id_fk": {
"name": "household_members_user_id_users_id_fk",
"tableFrom": "household_members",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"household_members_household_id_user_id_pk": {
"name": "household_members_household_id_user_id_pk",
"columns": ["household_id", "user_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.households": {
"name": "households",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.household_member_role": {
"name": "household_member_role",
"schema": "public",
"values": ["owner", "member"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+387
View File
@@ -0,0 +1,387 @@
{
"id": "eac5a1bb-3598-4f33-999f-58c4e562feb8",
"prevId": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89012",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_account_id": {
"name": "provider_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accounts_provider_provider_account_id_pk": {
"name": "accounts_provider_provider_account_id_pk",
"columns": [
"provider",
"provider_account_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.household_members": {
"name": "household_members",
"schema": "",
"columns": {
"household_id": {
"name": "household_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "household_member_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'member'"
}
},
"indexes": {},
"foreignKeys": {
"household_members_household_id_households_id_fk": {
"name": "household_members_household_id_households_id_fk",
"tableFrom": "household_members",
"tableTo": "households",
"columnsFrom": [
"household_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"household_members_user_id_users_id_fk": {
"name": "household_members_user_id_users_id_fk",
"tableFrom": "household_members",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"household_members_household_id_user_id_pk": {
"name": "household_members_household_id_user_id_pk",
"columns": [
"household_id",
"user_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.households": {
"name": "households",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'default'"
},
"theme_mode": {
"name": "theme_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'system'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification_tokens": {
"name": "verification_tokens",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
"name": "verification_tokens_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.household_member_role": {
"name": "household_member_role",
"schema": "public",
"values": [
"owner",
"member"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+670
View File
@@ -0,0 +1,670 @@
{
"id": "ba74770c-55d7-4238-97c6-49431a4fcb4c",
"prevId": "eac5a1bb-3598-4f33-999f-58c4e562feb8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_account_id": {
"name": "provider_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accounts_provider_provider_account_id_pk": {
"name": "accounts_provider_provider_account_id_pk",
"columns": [
"provider",
"provider_account_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.household_members": {
"name": "household_members",
"schema": "",
"columns": {
"household_id": {
"name": "household_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "household_member_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'member'"
}
},
"indexes": {},
"foreignKeys": {
"household_members_household_id_households_id_fk": {
"name": "household_members_household_id_households_id_fk",
"tableFrom": "household_members",
"tableTo": "households",
"columnsFrom": [
"household_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"household_members_user_id_users_id_fk": {
"name": "household_members_user_id_users_id_fk",
"tableFrom": "household_members",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"household_members_household_id_user_id_pk": {
"name": "household_members_household_id_user_id_pk",
"columns": [
"household_id",
"user_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.households": {
"name": "households",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'default'"
},
"theme_mode": {
"name": "theme_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'system'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification_tokens": {
"name": "verification_tokens",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
"name": "verification_tokens_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.calendar_events": {
"name": "calendar_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"calendar_id": {
"name": "calendar_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_at": {
"name": "start_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"end_at": {
"name": "end_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"all_day": {
"name": "all_day",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"owner_id": {
"name": "owner_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"rrule": {
"name": "rrule",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_source": {
"name": "external_source",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"calendar_events_calendar_start_idx": {
"name": "calendar_events_calendar_start_idx",
"columns": [
{
"expression": "calendar_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "start_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"calendar_events_calendar_id_calendars_id_fk": {
"name": "calendar_events_calendar_id_calendars_id_fk",
"tableFrom": "calendar_events",
"tableTo": "calendars",
"columnsFrom": [
"calendar_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"calendar_events_owner_id_users_id_fk": {
"name": "calendar_events_owner_id_users_id_fk",
"tableFrom": "calendar_events",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"calendar_events_range_check": {
"name": "calendar_events_range_check",
"value": "\"calendar_events\".\"end_at\" >= \"calendar_events\".\"start_at\""
}
},
"isRLSEnabled": false
},
"public.calendars": {
"name": "calendars",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"household_id": {
"name": "household_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"owner_id": {
"name": "owner_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'household'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"calendars_household_idx": {
"name": "calendars_household_idx",
"columns": [
{
"expression": "household_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"calendars_owner_idx": {
"name": "calendars_owner_idx",
"columns": [
{
"expression": "owner_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"calendars_household_id_households_id_fk": {
"name": "calendars_household_id_households_id_fk",
"tableFrom": "calendars",
"tableTo": "households",
"columnsFrom": [
"household_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"calendars_owner_id_users_id_fk": {
"name": "calendars_owner_id_users_id_fk",
"tableFrom": "calendars",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"calendars_visibility_check": {
"name": "calendars_visibility_check",
"value": "\"calendars\".\"visibility\" in ('private', 'household')"
}
},
"isRLSEnabled": false
}
},
"enums": {
"public.household_member_role": {
"name": "household_member_role",
"schema": "public",
"values": [
"owner",
"member"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+927
View File
@@ -0,0 +1,927 @@
{
"id": "5ca276cc-01ef-429a-aa67-822ef6ed7cbd",
"prevId": "ba74770c-55d7-4238-97c6-49431a4fcb4c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_account_id": {
"name": "provider_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accounts_provider_provider_account_id_pk": {
"name": "accounts_provider_provider_account_id_pk",
"columns": [
"provider",
"provider_account_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.household_members": {
"name": "household_members",
"schema": "",
"columns": {
"household_id": {
"name": "household_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "household_member_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'member'"
}
},
"indexes": {},
"foreignKeys": {
"household_members_household_id_households_id_fk": {
"name": "household_members_household_id_households_id_fk",
"tableFrom": "household_members",
"tableTo": "households",
"columnsFrom": [
"household_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"household_members_user_id_users_id_fk": {
"name": "household_members_user_id_users_id_fk",
"tableFrom": "household_members",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"household_members_household_id_user_id_pk": {
"name": "household_members_household_id_user_id_pk",
"columns": [
"household_id",
"user_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.households": {
"name": "households",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'default'"
},
"theme_mode": {
"name": "theme_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'system'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification_tokens": {
"name": "verification_tokens",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
"name": "verification_tokens_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.calendar_events": {
"name": "calendar_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"calendar_id": {
"name": "calendar_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_at": {
"name": "start_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"end_at": {
"name": "end_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"all_day": {
"name": "all_day",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"owner_id": {
"name": "owner_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"rrule": {
"name": "rrule",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_source": {
"name": "external_source",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"calendar_events_calendar_start_idx": {
"name": "calendar_events_calendar_start_idx",
"columns": [
{
"expression": "calendar_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "start_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"calendar_events_calendar_id_calendars_id_fk": {
"name": "calendar_events_calendar_id_calendars_id_fk",
"tableFrom": "calendar_events",
"tableTo": "calendars",
"columnsFrom": [
"calendar_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"calendar_events_owner_id_users_id_fk": {
"name": "calendar_events_owner_id_users_id_fk",
"tableFrom": "calendar_events",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"calendar_events_range_check": {
"name": "calendar_events_range_check",
"value": "\"calendar_events\".\"end_at\" >= \"calendar_events\".\"start_at\""
}
},
"isRLSEnabled": false
},
"public.calendars": {
"name": "calendars",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"household_id": {
"name": "household_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"owner_id": {
"name": "owner_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'household'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"calendars_household_idx": {
"name": "calendars_household_idx",
"columns": [
{
"expression": "household_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"calendars_owner_idx": {
"name": "calendars_owner_idx",
"columns": [
{
"expression": "owner_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"calendars_household_id_households_id_fk": {
"name": "calendars_household_id_households_id_fk",
"tableFrom": "calendars",
"tableTo": "households",
"columnsFrom": [
"household_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"calendars_owner_id_users_id_fk": {
"name": "calendars_owner_id_users_id_fk",
"tableFrom": "calendars",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"calendars_visibility_check": {
"name": "calendars_visibility_check",
"value": "\"calendars\".\"visibility\" in ('private', 'household')"
}
},
"isRLSEnabled": false
},
"public.list_items": {
"name": "list_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"list_id": {
"name": "list_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"done": {
"name": "done",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"qty": {
"name": "qty",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"due_at": {
"name": "due_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"assignee_id": {
"name": "assignee_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"list_items_list_position_idx": {
"name": "list_items_list_position_idx",
"columns": [
{
"expression": "list_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "position",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"list_items_assignee_idx": {
"name": "list_items_assignee_idx",
"columns": [
{
"expression": "assignee_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"list_items_list_id_lists_id_fk": {
"name": "list_items_list_id_lists_id_fk",
"tableFrom": "list_items",
"tableTo": "lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"list_items_assignee_id_users_id_fk": {
"name": "list_items_assignee_id_users_id_fk",
"tableFrom": "list_items",
"tableTo": "users",
"columnsFrom": [
"assignee_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lists": {
"name": "lists",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"household_id": {
"name": "household_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"lists_household_type_idx": {
"name": "lists_household_type_idx",
"columns": [
{
"expression": "household_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"lists_household_archived_idx": {
"name": "lists_household_archived_idx",
"columns": [
{
"expression": "household_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "archived",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"lists_household_id_households_id_fk": {
"name": "lists_household_id_households_id_fk",
"tableFrom": "lists",
"tableTo": "households",
"columnsFrom": [
"household_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.household_member_role": {
"name": "household_member_role",
"schema": "public",
"values": [
"owner",
"member"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1778048377946,
"tag": "0000_silent_magma",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1746533400000,
"tag": "0001_auth_tables",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1778053473887,
"tag": "0002_naive_starbolt",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778054818403,
"tag": "0003_rainy_ravenous",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1778055440944,
"tag": "0004_opposite_wraith",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1778056000000,
"tag": "0005_auth_schema_repair",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1778057808712,
"tag": "0006_new_hannibal_king",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1778093344354,
"tag": "0007_uneven_living_lightning",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1778120000000,
"tag": "0008_activity_log",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1778200000000,
"tag": "0009_share_links",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1778260000000,
"tag": "0010_nullable_actor_id",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1778300000000,
"tag": "0011_completion_visibility",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1778350000000,
"tag": "0012_dashboards",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1778400000000,
"tag": "0013_push_notify_reminders",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1778600000000,
"tag": "0014_paper_ink_theme",
"breakpoints": true
}
]
}
+3
View File
@@ -8,8 +8,11 @@ export default tseslint.config(
ignores: [
"node_modules/**",
".next/**",
".claude/**",
".design-tmp/**",
"dist/**",
"drizzle/**",
"public/sw.js",
],
},
js.configs.recommended,
+176
View File
@@ -1,8 +1,184 @@
import type { NextConfig } from "next";
import { writeFileSync } from "fs";
import { resolve } from "path";
// Stable in dev to avoid cache churn on hot-reload; unique per production build.
const BUILD_TIME = process.env.NODE_ENV === "development" ? "dev" : String(Date.now());
writeFileSync(resolve(process.cwd(), "public/sw.js"), buildSwContent(BUILD_TIME));
function buildSwContent(version: string): string {
return `// famapp service worker — v${version}
// Generated at build time. Do not edit directly.
const CACHE_VERSION = "${version}";
const SHELL = "famapp-shell-" + CACHE_VERSION;
const API = "famapp-api-" + CACHE_VERSION;
const OFFLINE = "/offline.html";
// --- install: precache the offline fallback ---
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(SHELL).then((c) => c.add(OFFLINE)));
self.skipWaiting();
});
// --- activate: evict old-version caches, claim clients ---
self.addEventListener("activate", (e) => {
e.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter(
(k) =>
k.startsWith("famapp-") && !k.endsWith("-" + CACHE_VERSION)
)
.map((k) => caches.delete(k))
)
)
.then(() => self.clients.claim())
);
});
// --- fetch: strategy dispatch ---
self.addEventListener("fetch", (e) => {
const { request } = e;
const url = new URL(request.url);
// Mutations — network-only; notify clients if the network is unreachable.
if (request.method !== "GET") {
e.respondWith(
fetch(request).catch(() => {
self.clients
.matchAll({ includeUncontrolled: true })
.then((cs) =>
cs.forEach((c) => c.postMessage({ type: "OFFLINE_MUTATION" }))
);
return new Response(JSON.stringify({ error: "offline" }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
})
);
return;
}
// Auth endpoints — always network-only.
if (url.pathname.startsWith("/api/auth/")) return;
// Next.js immutable static chunks — stale-while-revalidate.
if (url.pathname.startsWith("/_next/static/")) {
e.respondWith(staleWhileRevalidate(request, SHELL));
return;
}
// API GETs — network-first with 2-second timeout, fall back to cache.
if (url.pathname.startsWith("/api/")) {
e.respondWith(networkFirstWithTimeout(request, API, 2000));
return;
}
// Navigation — stale-while-revalidate; fall back to offline page.
if (request.mode === "navigate") {
e.respondWith(navigateWithFallback(request));
return;
}
});
// --- push notifications ---
self.addEventListener("push", (e) => {
if (!e.data) return;
const data = e.data.json();
e.waitUntil(
self.registration.showNotification(data.title || "famapp", {
body: data.body || "",
data: { url: data.url || "/" },
icon: "/icon-192.png",
badge: "/icon-192.png",
})
);
});
self.addEventListener("notificationclick", (e) => {
e.notification.close();
const url = e.notification.data?.url || "/";
e.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((cs) => {
const match = cs.find((c) => c.url.includes(self.location.origin));
if (match) {
match.focus();
return match.navigate(url);
}
return self.clients.openWindow(url);
})
);
});
// --- strategy helpers ---
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const update = fetch(request)
.then((res) => {
if (res.ok) cache.put(request, res.clone());
return res;
})
.catch(() => null);
// Serve cached immediately; let update happen in the background.
return cached ?? (await update);
}
async function networkFirstWithTimeout(request, cacheName, ms) {
const cache = await caches.open(cacheName);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ms);
try {
const res = await fetch(request, { signal: ac.signal });
clearTimeout(timer);
if (res.ok) cache.put(request, res.clone());
return res;
} catch {
clearTimeout(timer);
return (await cache.match(request)) ?? new Response(null, { status: 503 });
}
}
async function navigateWithFallback(request) {
const cache = await caches.open(SHELL);
const cached = await cache.match(request);
const networkFetch = fetch(request)
.then((res) => {
if (res.ok) cache.put(request, res.clone());
return res;
})
.catch(() => null);
if (cached) {
// Return cached immediately; revalidate in the background.
networkFetch;
return cached;
}
const net = await networkFetch;
if (net) return net;
return (
(await cache.match(OFFLINE)) ?? new Response("Offline", { status: 503 })
);
}
`;
}
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
// Keep pino and pino-pretty as native Node.js requires so their worker-thread
// transport and stream internals work correctly inside the standalone bundle.
serverExternalPackages: ["pino", "pino-pretty", "drizzle-orm", "postgres"],
};
export default nextConfig;
+39 -2
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.0.0",
"version": "0.3.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
@@ -9,6 +9,9 @@
},
"scripts": {
"dev": "next dev",
"dev:network": "next dev --hostname 0.0.0.0",
"dev:local": "node scripts/dev.mjs",
"dev:reset": "node scripts/dev-reset.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint .",
@@ -20,21 +23,51 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx --env-file=.env scripts/seed.ts",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"gen:icons": "node scripts/generate-icons.mjs",
"vapid:generate": "node scripts/vapid-generate.mjs",
"prepare": "husky",
"release": "dotenv -e .env -- release-it",
"release:patch": "dotenv -e .env -- release-it patch",
"release:minor": "dotenv -e .env -- release-it minor",
"release:major": "dotenv -e .env -- release-it major",
"release:dry": "dotenv -e .env -- release-it --dry-run"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{js,mjs,cjs}": [
"prettier --write"
],
"*.{json,md,css,yaml,yml}": [
"prettier --write"
]
},
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@release-it/conventional-changelog": "^11.0.1",
"@tailwindcss/postcss": "^4.2.4",
"@types/node": "^22.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-grid-layout": "^2.1.0",
"@types/web-push": "^3.6.4",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.15.0",
"eslint-config-next": "^16.2.4",
"globals": "^15.12.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"pino-pretty": "^13.1.3",
"prettier": "^3.3.3",
"release-it": "^20.2.0",
"tailwindcss": "^4.2.4",
"tsx": "^4.19.4",
"typescript": "^5.6.3",
@@ -50,16 +83,20 @@
"@fullcalendar/timegrid": "^6.1.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.14.0",
"next": "^15.5.15",
"next-auth": "5.0.0-beta.31",
"pino": "^10.3.1",
"postgres": "^3.4.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-grid-layout": "^2.2.3",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
}
+2562
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- Background -->
<rect width="512" height="512" rx="80" fill="#4F46E5"/>
<!-- House body -->
<polygon points="256,108 396,234 364,234 364,392 148,392 148,234 116,234" fill="white"/>
<!-- Door -->
<rect x="212" y="296" width="88" height="96" rx="6" fill="#4F46E5"/>
<!-- Left window -->
<rect x="164" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
<!-- Right window -->
<rect x="282" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+32
View File
@@ -0,0 +1,32 @@
{
"name": "famapp",
"short_name": "famapp",
"description": "Family coordination app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4F46E5",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+95
View File
@@ -0,0 +1,95 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#4F46E5" />
<title>famapp — offline</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--indigo: #4f46e5;
--indigo-light: #e0e7ff;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: 2rem;
background: #f9fafb;
color: #111827;
text-align: center;
}
.icon {
width: 72px;
height: 72px;
border-radius: 16px;
background: var(--indigo);
display: flex;
align-items: center;
justify-content: center;
}
.icon svg {
width: 40px;
height: 40px;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
}
p {
font-size: 0.9375rem;
color: #6b7280;
max-width: 28ch;
}
button {
margin-top: 0.5rem;
padding: 0.625rem 1.5rem;
background: var(--indigo);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
}
button:active {
opacity: 0.85;
}
</style>
</head>
<body>
<div class="icon">
<!-- House silhouette -->
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<polygon points="20,5 34,17 31,17 31,35 9,35 9,17 6,17" fill="white" />
<rect x="15" y="22" width="10" height="13" rx="1" fill="#4F46E5" />
</svg>
</div>
<h1>You're offline</h1>
<p>famapp needs a connection — check your network and try again.</p>
<button onclick="location.reload()">Retry</button>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
/**
* Dev environment reset script.
*
* Tears down the local Postgres container and deletes its volume (all local
* data is lost), then performs a fresh startup: container up, migrate, seed,
* Next.js dev server.
*
* Use this when you want a completely clean local database e.g. after a
* destructive schema change or to reproduce a fresh-install scenario.
*
* Usage: pnpm dev:reset
*/
import { execSync } from "child_process";
import { main } from "./dev.mjs";
console.log("\n⚠ Resetting dev environment — all local data will be deleted.");
execSync("docker compose -f docker-compose.dev.yaml down -v", {
stdio: "inherit",
shell: true,
});
console.log("\n▸ Restarting from scratch...");
await main();
+118
View File
@@ -0,0 +1,118 @@
/**
* Dev environment startup script.
*
* Starts the local Postgres container, waits for it to be ready, runs
* migrations + seed, then spawns `next dev` bound to 0.0.0.0 so the app
* is reachable from other devices on the LAN.
*
* Usage: pnpm dev:local
*/
import { execSync, spawn } from "child_process";
import net from "net";
import os from "os";
import { fileURLToPath } from "url";
/** Return the first LAN IPv4 address (prefers 192.168.x / 10.x ranges). */
function getLanIp() {
const all = Object.values(os.networkInterfaces())
.flat()
.filter((n) => n.family === "IPv4" && !n.internal)
.map((n) => n.address);
return (
all.find((a) => a.startsWith("192.168.") || a.startsWith("10.")) ??
all[0] ??
"unknown"
);
}
/** Poll TCP host:port until it accepts a connection or the timeout expires. */
function waitForTcp(host, port, timeoutMs = 30_000) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
function attempt() {
const sock = net.createConnection({ host, port });
sock.on("connect", () => {
sock.destroy();
resolve();
});
sock.on("error", () => {
sock.destroy();
if (Date.now() >= deadline) {
reject(
new Error(
`Postgres not ready on ${host}:${port} after ${timeoutMs / 1000}s. ` +
"Is Docker running? Check: docker compose -f docker-compose.dev.yaml logs"
)
);
} else {
setTimeout(attempt, 500);
}
});
}
attempt();
});
}
/** Run a shell command synchronously, streaming output to the terminal. */
function run(cmd) {
execSync(cmd, { stdio: "inherit", shell: true });
}
export async function main() {
// 1. Start DB container (idempotent — safe to call when already running)
console.log("\n▸ Starting database container...");
run("docker compose -f docker-compose.dev.yaml up -d");
// 2. Wait for Postgres to accept connections
process.stdout.write("▸ Waiting for Postgres");
const tick = setInterval(() => process.stdout.write("."), 500);
try {
await waitForTcp("127.0.0.1", 5432);
} finally {
clearInterval(tick);
process.stdout.write(" ready\n");
}
// 3. Apply any pending migrations (no-op if already current)
console.log("▸ Running migrations...");
run("pnpm db:migrate");
// 4. Seed default data (idempotent)
console.log("▸ Seeding...");
run("pnpm db:seed");
// 5. Print access URLs before the Next.js banner appears
const lanIp = getLanIp();
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(` Local → http://localhost:3000`);
console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`);
console.log(` HTTPS → https://dev.ginnoir.com (push/PWA — needs Caddy snippet)`);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
// 6. Spawn Next.js dev server bound to all interfaces.
// HMR handles most code changes without a restart.
// Restart needed for: .env changes, next.config.ts changes.
// Pass command as a single string with no args array — shell handles .cmd
// resolution on Windows and the empty args avoids DEP0190.
const next = spawn("pnpm run dev:network", [], { stdio: "inherit", shell: true });
// Forward Ctrl+C / SIGTERM to the child so it shuts down cleanly.
const forward = (sig) => () => next.kill(sig);
process.on("SIGINT", forward("SIGINT"));
process.on("SIGTERM", forward("SIGTERM"));
await new Promise((resolve) => next.on("exit", (code) => resolve(code)));
process.exit(0);
}
// Run when invoked directly (not imported by dev-reset.mjs)
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] === __filename) {
main().catch((err) => {
console.error("\ndev startup failed:", err.message);
process.exit(1);
});
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Generates placeholder PNG icons for the famapp PWA.
*
* Usage: node scripts/generate-icons.mjs
*
* Produces:
* public/icon-192.png
* public/icon-384.png
* public/icon-512.png
* public/icon-512-maskable.png (same image; OS applies its own mask)
* public/icon-180.png (apple-touch-icon)
*
* All images are solid indigo (#4F46E5) squares replace with a real
* branded export from icon.svg when assets are finalised.
*/
import { deflateSync } from "zlib";
import { writeFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dir = dirname(fileURLToPath(import.meta.url));
const publicDir = resolve(__dir, "../public");
// Build CRC-32 lookup table once.
const CRC_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
CRC_TABLE[i] = c;
}
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function u32(n) {
const b = Buffer.alloc(4);
b.writeUInt32BE(n, 0);
return b;
}
function pngChunk(type, data) {
const typeBytes = Buffer.from(type, "ascii");
const crc = u32(crc32(Buffer.concat([typeBytes, data])));
return Buffer.concat([u32(data.length), typeBytes, data, crc]);
}
/**
* Returns a Buffer containing a valid PNG of size×size filled with (r, g, b).
*/
function solidPNG(size, r, g, b) {
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = pngChunk(
"IHDR",
Buffer.concat([
u32(size),
u32(size),
Buffer.from([8, 2, 0, 0, 0]), // 8-bit RGB, no interlace
]),
);
// One filter byte (0 = None) followed by size×3 RGB bytes per row.
const rowLen = 1 + size * 3;
const raw = Buffer.alloc(size * rowLen);
for (let y = 0; y < size; y++) {
const base = y * rowLen;
raw[base] = 0;
for (let x = 0; x < size; x++) {
raw[base + 1 + x * 3] = r;
raw[base + 2 + x * 3] = g;
raw[base + 3 + x * 3] = b;
}
}
const idat = pngChunk("IDAT", deflateSync(raw));
const iend = pngChunk("IEND", Buffer.alloc(0));
return Buffer.concat([PNG_SIG, ihdr, idat, iend]);
}
// Indigo-600 (#4F46E5)
const [R, G, B] = [0x4f, 0x46, 0xe5];
const icons = [
{ name: "icon-192.png", size: 192 },
{ name: "icon-384.png", size: 384 },
{ name: "icon-512.png", size: 512 },
{ name: "icon-512-maskable.png", size: 512 },
{ name: "icon-180.png", size: 180 },
];
for (const { name, size } of icons) {
const out = resolve(publicDir, name);
writeFileSync(out, solidPNG(size, R, G, B));
console.log(` ✓ public/${name} (${size}×${size})`);
}
+25
View File
@@ -0,0 +1,25 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is required");
process.exit(1);
}
const migrationsFolder = process.env.MIGRATIONS_FOLDER ?? "./drizzle";
const sql = postgres(url, { max: 1 });
const db = drizzle(sql);
try {
await migrate(db, { migrationsFolder });
console.log(
JSON.stringify({ level: "info", msg: "migrations applied", folder: migrationsFolder }),
);
} catch (err) {
console.error(JSON.stringify({ level: "error", msg: "migrations failed", err: String(err) }));
process.exit(1);
} finally {
await sql.end({ timeout: 5 });
}
+24
View File
@@ -0,0 +1,24 @@
import postgres from "postgres";
const url = process.env.DATABASE_URL;
if (!url) {
console.error(JSON.stringify({ level: "error", msg: "DATABASE_URL is required" }));
process.exit(1);
}
const sql = postgres(url, { max: 1 });
try {
const [existing] = await sql`SELECT id, name FROM households LIMIT 1`;
if (existing) {
console.log(JSON.stringify({ level: "info", msg: "household exists", name: existing.name }));
} else {
await sql`INSERT INTO households (name) VALUES ('Home')`;
console.log(JSON.stringify({ level: "info", msg: "seeded household Home" }));
}
} catch (err) {
console.error(JSON.stringify({ level: "error", msg: "seed failed", err: String(err) }));
process.exit(1);
} finally {
await sql.end({ timeout: 5 });
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Dev script: send a test push notification to all subscriptions for the dev user.
* Usage: tsx --env-file=.env scripts/send-test-push.mts
*/
import webPush from "web-push";
import postgres from "postgres";
const subject = process.env["VAPID_SUBJECT"];
const publicKey = process.env["VAPID_PUBLIC_KEY"];
const privateKey = process.env["VAPID_PRIVATE_KEY"];
const databaseUrl = process.env["DATABASE_URL"];
if (!subject || !publicKey || !privateKey) {
console.error("VAPID env vars not set");
process.exit(1);
}
if (!databaseUrl) {
console.error("DATABASE_URL not set");
process.exit(1);
}
webPush.setVapidDetails(subject, publicKey, privateKey);
const sql = postgres(databaseUrl);
const subs = await sql`SELECT id, user_id, endpoint, p256dh, auth FROM push_subscriptions`;
console.log(`Found ${subs.length} push subscription(s)`);
if (subs.length === 0) {
console.log("No subscriptions — enable push notifications in Settings first.");
await sql.end();
process.exit(0);
}
for (const sub of subs) {
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title: "famapp test", body: "Push notifications are working!", url: "/settings" }),
);
console.log(`Sent to ${sub.endpoint.slice(0, 60)}...`);
} catch (err: unknown) {
const e = err as { statusCode?: number; message?: string };
console.error(`Failed (${e.statusCode ?? "?"}): ${e.message}`);
}
}
await sql.end();
+5
View File
@@ -0,0 +1,5 @@
import webPush from "web-push";
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
+11 -2
View File
@@ -1,5 +1,7 @@
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
import { getCurrentSession } from "@/lib/session";
import type { CalView } from "@/modules/_core/themes";
export default async function CalendarPage() {
const now = new Date();
@@ -8,10 +10,17 @@ export default async function CalendarPage() {
const to = new Date(now);
to.setMonth(to.getMonth() + 10);
const [calendars, events] = await Promise.all([
const [{ user }, calendars, events] = await Promise.all([
getCurrentSession(),
listCalendars(),
listEvents({ from, to, calendarIds: "all" }),
]);
return <CalendarShell calendars={calendars} events={events} />;
return (
<CalendarShell
calendars={calendars}
events={events}
defaultView={user.themeCalView as CalView}
/>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core";
import { getDashboardBySlug } from "@/app/d/actions";
import { DashboardEditor } from "@/components/dashboard-editor";
import { EditDashboardButton } from "@/components/edit-dashboard-button";
import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { dashboards } from "@/modules/_core/schema";
import { asc, eq } from "drizzle-orm";
import type { DashLayout } from "@/modules/_core/themes";
const smColSpan: Record<number, string> = {
1: "sm:col-span-1",
2: "sm:col-span-2",
3: "sm:col-span-3",
4: "sm:col-span-4",
5: "sm:col-span-5",
6: "sm:col-span-6",
7: "sm:col-span-7",
8: "sm:col-span-8",
9: "sm:col-span-9",
10: "sm:col-span-10",
11: "sm:col-span-11",
12: "sm:col-span-12",
};
const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
export default async function DashboardPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ edit?: string }>;
}) {
const { slug } = await params;
const { edit } = await searchParams;
const isEditing = edit === "1";
const { user, household } = await getCurrentSession();
const dashboard = await getDashboardBySlug(slug);
if (!dashboard) notFound();
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const widgetMetas = getWidgetMetas();
if (isEditing) {
return (
<DashboardEditor
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
layout={layout}
widgetMetas={widgetMetas}
/>
);
}
// For dashboard tabs sub-header, pull the user's dashboards (small list).
const session = await auth();
const userDashboards = session?.user?.id
? await db
.select({
id: dashboards.id,
name: dashboards.name,
slug: dashboards.slug,
isDefault: dashboards.isDefault,
position: dashboards.position,
})
.from(dashboards)
.where(eq(dashboards.userId, session.user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
: [];
const ctx = { userId: user.id, householdId: household.id };
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
const dashLayout = (user.themeDashLayout as DashLayout) ?? "classic";
const containerCls =
dashLayout === "glance" ? "max-w-[760px] mx-auto" : dashLayout === "split" ? "" : "";
const greeting = greetingForHour(new Date().getHours());
const today = new Date().toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
const firstName = (user.name ?? "").split(" ")[0] ?? "";
return (
<div className={containerCls}>
{userDashboards.length > 1 && (
<div className="flex items-center gap-1 mb-4 overflow-x-auto">
{userDashboards.map((d) => (
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
))}
<DashboardSwitcher dashboards={userDashboards} />
</div>
)}
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
{greeting}
{firstName && `, ${firstName}`}.
</h1>
<p className="muted mt-1 text-[13px]">{today}</p>
</div>
<EditDashboardButton />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
{placements.map((placement, i) => {
const widget = getWidget(placement.widgetId);
if (!widget) return null;
const colClass = smColSpan[placement.w] ?? "sm:col-span-12";
return (
<div key={i} className={`col-span-1 ${colClass}`}>
<Card className="h-full">
<CardHeader>
<CardTitle>{widget.title}</CardTitle>
</CardHeader>
<CardContent>
<Suspense
fallback={
<div className="animate-pulse space-y-2">
<div className="h-3 w-3/4 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
<div className="h-3 w-2/3 rounded bg-muted" />
</div>
}
>
{widget.render({ config: placement.config, ctx })}
</Suspense>
</CardContent>
</Card>
</div>
);
})}
</div>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use server";
import { and, asc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { getWidget } from "@/modules/_core";
import { dashboards } from "@/modules/_core/schema";
import { type DashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
export type DashboardMeta = {
id: string;
name: string;
slug: string;
isDefault: boolean;
position: number;
};
export async function listDashboards(): Promise<DashboardMeta[]> {
const { user } = await getCurrentSession();
const rows = await db
.select({
id: dashboards.id,
name: dashboards.name,
slug: dashboards.slug,
isDefault: dashboards.isDefault,
position: dashboards.position,
})
.from(dashboards)
.where(eq(dashboards.userId, user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
return rows;
}
export async function getDefaultDashboardSlug(): Promise<string> {
const { user } = await getCurrentSession();
const rows = await db
.select({ slug: dashboards.slug })
.from(dashboards)
.where(and(eq(dashboards.userId, user.id), eq(dashboards.isDefault, true)))
.limit(1);
if (rows[0]) return rows[0].slug;
// Fallback: first dashboard by position
const [first] = await db
.select({ slug: dashboards.slug })
.from(dashboards)
.where(eq(dashboards.userId, user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
.limit(1);
if (first) return first.slug;
// No dashboards yet — insert one directly without revalidatePath (we're in a
// render path; the redirect that follows is a fresh request anyway).
const slug = await uniqueSlug(user.id, "home");
await db
.insert(dashboards)
.values({ userId: user.id, name: "Home", slug, isDefault: false, position: 0 });
return slug;
}
export async function getDashboardBySlug(slug: string) {
const { user } = await getCurrentSession();
const [row] = await db
.select()
.from(dashboards)
.where(and(eq(dashboards.userId, user.id), eq(dashboards.slug, slug)))
.limit(1);
return row ?? null;
}
function toSlug(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "dashboard"
);
}
async function uniqueSlug(userId: string, base: string): Promise<string> {
let slug = base;
let attempt = 1;
for (;;) {
const [existing] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.userId, userId), eq(dashboards.slug, slug)))
.limit(1);
if (!existing) return slug;
attempt++;
slug = `${base}-${attempt}`;
}
}
export async function createDashboard(name: string): Promise<DashboardMeta> {
const parsed = z.string().trim().min(1).max(80).parse(name);
const { user } = await getCurrentSession();
const slug = await uniqueSlug(user.id, toSlug(parsed));
const rows = await db
.insert(dashboards)
.values({ userId: user.id, name: parsed, slug, isDefault: false, position: 9999 })
.returning();
const row = rows[0];
if (!row) throw new Error("Insert failed");
revalidatePath("/");
return {
id: row.id,
name: row.name,
slug: row.slug,
isDefault: row.isDefault,
position: row.position,
};
}
export async function renameDashboard(id: string, name: string): Promise<void> {
const parsedName = z.string().trim().min(1).max(80).parse(name);
const { user } = await getCurrentSession();
await db
.update(dashboards)
.set({ name: parsedName, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
revalidatePath("/");
}
export async function deleteDashboard(id: string): Promise<void> {
const { user } = await getCurrentSession();
const all = await db
.select({ id: dashboards.id, isDefault: dashboards.isDefault, slug: dashboards.slug })
.from(dashboards)
.where(eq(dashboards.userId, user.id));
if (all.length <= 1) throw new Error("Cannot delete your only dashboard");
const target = all.find((d) => d.id === id);
if (!target) throw new Error("Dashboard not found");
await db.delete(dashboards).where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
// If we deleted the default, promote another
if (target.isDefault) {
const next = all.find((d) => d.id !== id);
if (next) {
await db
.update(dashboards)
.set({ isDefault: true })
.where(and(eq(dashboards.id, next.id), eq(dashboards.userId, user.id)));
}
}
revalidatePath("/");
redirect("/");
}
export async function setDefaultDashboard(id: string): Promise<void> {
const { user } = await getCurrentSession();
await db.update(dashboards).set({ isDefault: false }).where(eq(dashboards.userId, user.id));
await db
.update(dashboards)
.set({ isDefault: true })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
revalidatePath("/");
}
export async function reorderDashboards(orderedIds: string[]): Promise<void> {
const { user } = await getCurrentSession();
await Promise.all(
orderedIds.map((id, i) =>
db
.update(dashboards)
.set({ position: i })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))),
),
);
revalidatePath("/");
}
export async function saveDashboardLayout(id: string, layout: DashboardLayout): Promise<void> {
const { user } = await getCurrentSession();
// Validate each widget's config against its registered schema
for (const placement of layout.widgets) {
const widget = getWidget(placement.widgetId);
if (!widget) continue;
widget.configSchema.parse(placement.config);
}
await db
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
revalidatePath("/");
}
export async function resetDashboardLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const layout = computeDefaultLayout();
await db
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
revalidatePath("/");
}
export async function resolveWidgetConfigOptions(widgetId: string): Promise<unknown> {
const { user, household } = await getCurrentSession();
const widget = getWidget(widgetId);
if (!widget?.resolveConfigOptions) return null;
return widget.resolveConfigOptions({ userId: user.id, householdId: household.id });
}
+1265 -205
View File
File diff suppressed because it is too large Load Diff
+130 -32
View File
@@ -1,71 +1,169 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
import { cn } from "@/lib/utils";
import { AppNav } from "@/components/app-nav";
import "@/modules"; // registers all module manifests
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import { users } from "@/modules/_core/schema";
import { asc, eq } from "drizzle-orm";
import { dashboards, users } from "@/modules/_core/schema";
import type { DashboardMeta } from "@/app/d/actions";
import { getQuickAdds } from "@/modules/_core";
import { QuickAddProvider } from "@/components/quick-add-provider";
import { QuickAddSheet } from "@/components/quick-add-sheet";
import { CommandPalette } from "@/components/command-palette";
import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
const inter = Inter({ subsets: ["latin"], variable: "--sans-inter", display: "swap" });
const sourceSerif = Source_Serif_4({
subsets: ["latin"],
variable: "--serif-source",
display: "swap",
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--serif-newsreader",
display: "swap",
});
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--serif-fraunces",
display: "swap",
});
const jetbrains = JetBrains_Mono({
subsets: ["latin"],
variable: "--mono-jb",
display: "swap",
});
export const viewport: Viewport = {
themeColor: "#1F1B16",
};
export const metadata: Metadata = {
title: "famapp",
description: "Family coordination app",
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "famapp",
},
icons: {
apple: "/icon-180.png",
},
};
// Runs before paint reads localStorage / prefers-color-scheme and applies
// data-theme + dark class to <html> so signed-out pages also get the right theme.
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
const prePaintScript = `(function(){
try {
var t = localStorage.getItem('theme') || 'default';
var m = localStorage.getItem('themeMode') || 'system';
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', t);
if (dark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
var palette = localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay';
var mode = localStorage.getItem('themeMode') || 'system';
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
var density = localStorage.getItem('themeDensity') || 'regular';
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
var dataNav = navStyle === 'compact-rail' ? 'rail'
: navStyle === 'top-nav' ? 'top'
: 'sidebar';
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
}
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var html = document.documentElement;
html.setAttribute('data-theme', palette);
html.setAttribute('data-font-pair', fontPair);
html.setAttribute('data-density', density);
html.setAttribute('data-nav', dataNav);
if (dark) html.classList.add('dark'); else html.classList.remove('dark');
} catch(e) {}
})();`;
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
let theme = "default";
let themeMode = "system";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
let palette: Palette = DEFAULT_THEME.palette;
let mode: ThemeMode = DEFAULT_THEME.mode;
let fontPair: FontPair = DEFAULT_THEME.fontPair;
let density: Density = DEFAULT_THEME.density;
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
let userDashboards: DashboardMeta[] = [];
let signedIn = false;
const session = await auth();
if (session?.user?.id) {
signedIn = true;
const [row] = await db
.select({ theme: users.theme, themeMode: users.themeMode })
.select({
themePalette: users.themePalette,
themeMode: users.themeMode,
themeFontPair: users.themeFontPair,
themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle,
})
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
if (row) {
theme = row.theme;
themeMode = row.themeMode;
palette = row.themePalette as Palette;
mode = row.themeMode as ThemeMode;
fontPair = row.themeFontPair as FontPair;
density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle;
}
userDashboards = await db
.select({
id: dashboards.id,
name: dashboards.name,
slug: dashboards.slug,
isDefault: dashboards.isDefault,
position: dashboards.position,
})
.from(dashboards)
.where(eq(dashboards.userId, session.user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
}
// For system mode we can't know the preference on the server — the inline
// script will correct it before paint. We optimistically render light here.
const isDark = themeMode === "dark";
// Server-side initial dark guess: only for `dark` mode (system mode is corrected
// before paint by the inline script). Avoids a flash on signed-in users.
const isDark = mode === "dark";
const initialDataNav = navStyleToDataNav(navStyle);
const quickAdds = getQuickAdds();
const fontVars = cn(
inter.variable,
sourceSerif.variable,
newsreader.variable,
fraunces.variable,
jetbrains.variable,
);
return (
<html
lang="en"
data-theme={theme}
className={cn("font-sans", geist.variable, isDark ? "dark" : "")}
data-theme={palette}
data-font-pair={fontPair}
data-density={density}
data-nav={initialDataNav}
className={cn(fontVars, isDark ? "dark" : "")}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
</head>
<body className="min-h-screen">
<AppNav />
<main>{children}</main>
<body>
<QuickAddProvider actions={quickAdds}>
<AppShell signedIn={signedIn} navStyle={navStyle} dashboards={userDashboards}>
{children}
</AppShell>
<QuickAddSheet />
<CommandPalette />
<InstallPrompt />
<PwaRegister />
</QuickAddProvider>
</body>
</html>
);
+2 -2
View File
@@ -1,7 +1,7 @@
import { ListsIndex } from "@/modules/lists/components/lists-index";
import { listLists } from "@/modules/lists/server/queries";
import { listListsWithItems } from "@/modules/lists/server/queries";
export default async function ListsPage() {
const lists = await listLists();
const lists = await listListsWithItems();
return <ListsIndex lists={lists} />;
}
+27 -11
View File
@@ -2,23 +2,34 @@ import { signIn } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login";
import { BrandMark } from "@/components/brand-mark";
export default function LoginPage() {
const devLoginEnabled = isDevLoginEnabled();
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8">
<div className="flex flex-col items-center gap-6">
<h1 className="text-4xl font-bold">famapp</h1>
<main
className="flex min-h-screen flex-col items-center justify-center p-8"
style={{ background: "var(--paper)" }}
>
<div
className="rounded-[var(--r-lg)] shadow-[var(--shadow-2)] p-10 max-w-sm w-full text-center"
style={{ background: "var(--card)", border: "0.5px solid var(--hair)" }}
>
<div className="flex justify-center mb-3">
<BrandMark />
</div>
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
<form
action={async () => {
"use server";
await signIn("authentik", { redirectTo: "/" });
}}
>
<Button type="submit" size="lg">
<Button type="submit" size="lg" className="w-full">
Sign in with SSO
</Button>
</form>
@@ -26,18 +37,23 @@ export default function LoginPage() {
<form
action={async () => {
"use server";
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
// trustHost, and proxy headers. Set both so the session is found
// regardless — this is dev-only code, correctness > elegance.
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
cookieStore.set(DEV_LOGIN_COOKIE, sessionToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
expires,
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}}
className="mt-3"
>
<Button type="submit" variant="outline" size="lg">
<Button type="submit" variant="outline" size="lg" className="w-full">
Dev login
</Button>
</form>
+6 -7
View File
@@ -1,8 +1,7 @@
export default function Page() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8">
<h1 className="text-4xl font-bold">famapp</h1>
<p className="mt-4 text-muted-foreground">Dashboard coming soon</p>
</main>
);
import { redirect } from "next/navigation";
import { getDefaultDashboardSlug } from "@/app/d/actions";
export default async function RootPage() {
const slug = await getDefaultDashboardSlug();
redirect(`/d/${slug}`);
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { eq } from "drizzle-orm";
import { resolveShareToken } from "@/modules/_core/share";
import { getEntityType } from "@/modules/_core/registry";
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { ShareFrame } from "@/components/share/share-frame";
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
const RL_PREFIX_LEN = 8;
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const headersList = await headers();
const ip =
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
headersList.get("x-real-ip") ??
"0.0.0.0";
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
if (isRateLimited(rlKey)) {
return <ShareRateLimitError />;
}
const resolved = await resolveShareToken(token);
if (!resolved) {
recordFailure(rlKey);
return <ShareError />;
}
const entityReg = getEntityType(resolved.entityType);
if (!entityReg?.loadForShare || !entityReg.renderSharedView) {
return <ShareError message="This content type cannot be shared." />;
}
const data = await entityReg.loadForShare(resolved.entityId);
if (!data) {
recordFailure(rlKey);
return <ShareError />;
}
// Best-effort lookup of the creator's display name. Doesn't reveal email.
const [creator] = resolved.createdBy
? await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, resolved.createdBy))
.limit(1)
: [];
return (
<ShareFrame
expiresAt={resolved.expiresAt}
capabilities={resolved.capabilities}
token={token}
sharedByName={creator?.name ?? null}
>
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
</ShareFrame>
);
}
function ShareRateLimitError() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
<h1 className="serif text-[28px] font-medium tracking-tight">Too many requests</h1>
<p className="max-w-sm text-[13.5px] muted">
You have made too many requests in a short period. Please wait a minute and try again.
</p>
</div>
);
}
function ShareError({ message }: { message?: string }) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
<h1 className="serif text-[28px] font-medium tracking-tight">Link not found</h1>
<p className="max-w-sm text-[13.5px] muted">
{message ??
"This share link may have expired or been revoked. Ask the sender for a new link."}
</p>
</div>
);
}
+80 -15
View File
@@ -1,25 +1,90 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
import {
VALID_PALETTES,
VALID_THEME_MODES,
VALID_FONT_PAIRS,
VALID_DENSITIES,
VALID_DASH_LAYOUTS,
VALID_CAL_VIEWS,
VALID_NAV_STYLES,
} from "@/modules/_core/themes";
import type {
Palette,
ThemeMode,
FontPair,
Density,
DashLayout,
CalView,
NavStyle,
} from "@/modules/_core/themes";
import { getCurrentSession } from "@/lib/session";
import { revokeShareLink } from "@/modules/_core/share";
export async function setUserTheme({
theme,
mode,
}: {
theme: ThemeId;
mode: ThemeMode;
}) {
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
export interface ThemePatch {
palette?: Palette;
mode?: ThemeMode;
fontPair?: FontPair;
density?: Density;
dashLayout?: DashLayout;
calView?: CalView;
navStyle?: NavStyle;
}
export async function setUserTheme(patch: ThemePatch) {
const update: Record<string, string> = {};
if (patch.palette !== undefined) {
if (!VALID_PALETTES.has(patch.palette)) throw new Error("Invalid palette");
update["themePalette"] = patch.palette;
}
if (patch.mode !== undefined) {
if (!VALID_THEME_MODES.has(patch.mode)) throw new Error("Invalid theme mode");
update["themeMode"] = patch.mode;
}
if (patch.fontPair !== undefined) {
if (!VALID_FONT_PAIRS.has(patch.fontPair)) throw new Error("Invalid font pair");
update["themeFontPair"] = patch.fontPair;
}
if (patch.density !== undefined) {
if (!VALID_DENSITIES.has(patch.density)) throw new Error("Invalid density");
update["themeDensity"] = patch.density;
}
if (patch.dashLayout !== undefined) {
if (!VALID_DASH_LAYOUTS.has(patch.dashLayout)) throw new Error("Invalid dashboard layout");
update["themeDashLayout"] = patch.dashLayout;
}
if (patch.calView !== undefined) {
if (!VALID_CAL_VIEWS.has(patch.calView)) throw new Error("Invalid calendar view");
update["themeCalView"] = patch.calView;
}
if (patch.navStyle !== undefined) {
if (!VALID_NAV_STYLES.has(patch.navStyle)) throw new Error("Invalid nav style");
update["themeNavStyle"] = patch.navStyle;
}
if (Object.keys(update).length === 0) return;
const { user } = await getCurrentSession();
await db
.update(users)
.set({ theme, themeMode: mode })
.where(eq(users.id, user.id));
await db.update(users).set(update).where(eq(users.id, user.id));
}
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
const parsed = Number(hours);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 8760)
throw new Error("Invalid hours value");
const { user } = await getCurrentSession();
await db.update(users).set({ completionVisibilityHours: parsed }).where(eq(users.id, user.id));
revalidatePath("/settings");
}
export async function revokeShareLinkAction(formData: FormData): Promise<void> {
const id = formData.get("id");
if (typeof id !== "string") throw new Error("Missing id");
await revokeShareLink(id);
revalidatePath("/settings");
}
+1 -4
View File
@@ -13,10 +13,7 @@ export async function renameHousehold(formData: FormData) {
const name = formData.get("name");
if (typeof name !== "string" || !name.trim()) throw new Error("Invalid name");
await db
.update(households)
.set({ name: name.trim() })
.where(eq(households.id, household.id));
await db.update(households).set({ name: name.trim() }).where(eq(households.id, household.id));
revalidatePath("/settings/household");
}
+2 -9
View File
@@ -4,12 +4,7 @@ import { getCurrentSession } from "@/lib/session";
import { householdMembers, users } from "@/modules/_core/schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { renameHousehold } from "./actions";
export default async function HouseholdSettingsPage() {
@@ -34,9 +29,7 @@ export default async function HouseholdSettingsPage() {
{members.map(({ user, role: memberRole }) => (
<li key={user.id} className="flex items-center justify-between">
<span>{user.name ?? user.email}</span>
<span className="text-sm text-muted-foreground capitalize">
{memberRole}
</span>
<span className="text-sm text-muted-foreground capitalize">{memberRole}</span>
</li>
))}
</ul>
+40
View File
@@ -0,0 +1,40 @@
"use server";
import { and, eq, isNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { notifications, users } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session";
export async function markNotificationRead(id: string): Promise<void> {
const { user } = await getCurrentSession();
await db
.update(notifications)
.set({ readAt: new Date() })
.where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
revalidatePath("/");
}
export async function markAllNotificationsRead(): Promise<void> {
const { user } = await getCurrentSession();
await db
.update(notifications)
.set({ readAt: new Date() })
.where(and(eq(notifications.userId, user.id), isNull(notifications.readAt)));
revalidatePath("/");
}
export async function setNotifChannel(
channel: "push" | "inapp" | "ntfy",
enabled: boolean,
): Promise<void> {
const { user } = await getCurrentSession();
const col =
channel === "push"
? { notifPush: enabled }
: channel === "inapp"
? { notifInApp: enabled }
: { notifNtfy: enabled };
await db.update(users).set(col).where(eq(users.id, user.id));
revalidatePath("/settings");
}
+341 -24
View File
@@ -1,34 +1,351 @@
import { getCurrentSession } from "@/lib/session";
import { getActiveShareLinks } from "@/modules/_core/share";
import { getEntityType } from "@/modules/_core/registry";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ThemePicker } from "@/components/theme-picker";
import { CompletionDelaySetting } from "@/components/completion-delay-setting";
import { PushOptIn } from "@/components/push-opt-in";
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section";
import { revokeShareLinkAction } from "./actions";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
import Link from "next/link";
import { NavIcon } from "@/components/nav-icon";
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus } from "lucide-react";
export default async function SettingsPage() {
const { user } = await getCurrentSession();
const VALID_SECTIONS = new Set<SectionId>([
"household",
"sharing",
"notifications",
"calendars",
"appearance",
"data",
]);
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ s?: string }>;
}) {
const sp = await searchParams;
const section: SectionId = VALID_SECTIONS.has(sp.s as SectionId)
? (sp.s as SectionId)
: "household";
const { user, household } = await getCurrentSession();
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
return (
<div className="container max-w-2xl py-8 space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
</CardHeader>
<CardContent>
<ThemePicker
initialTheme={user.theme as "default" | "warm"}
initialMode={user.themeMode as "light" | "dark" | "system"}
signedIn
/>
</CardContent>
</Card>
<Link
href="/settings/household"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Household Settings
</Link>
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
<ResponsiveSidebar active={section} />
<div className="space-y-4">
{section === "household" && <HouseholdSection household={household} userName={user.name} />}
{section === "sharing" && <SharingSection />}
{section === "notifications" && (
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
)}
{section === "calendars" && <CalendarsAndListsSection />}
{section === "appearance" && <AppearanceSection user={user} />}
{section === "data" && <DataSection />}
</div>
</div>
);
}
function HouseholdSection({
household,
userName,
}: {
household: { id: string; name: string };
userName: string | null;
}) {
return (
<>
<Card>
<CardHeader>
<CardTitle>{household.name}</CardTitle>
<span className="meta">Self-hosted</span>
</CardHeader>
<CardContent>
<p className="muted text-[13px] mb-3">
Household name shown on share links and the iOS PWA.
</p>
<Link
href="/settings/household"
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-[13px] font-medium border-[0.5px] hover:bg-[var(--shade)]"
style={{ borderColor: "var(--hair-2)" }}
>
<Pencil className="size-3.5" />
Edit household & members
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>You</CardTitle>
</CardHeader>
<CardContent>
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
{(userName ?? "?").trim()[0]?.toUpperCase()}
</span>
<div className="label">
<div className="t">{userName ?? "Anonymous"}</div>
<div className="d">Signed in via Authentik</div>
</div>
</div>
</CardContent>
</Card>
</>
);
}
async function SharingSection() {
const shareLinks = await getActiveShareLinks();
return (
<Card>
<CardHeader>
<CardTitle>Public share links</CardTitle>
</CardHeader>
<CardContent>
<p className="muted text-[12.5px] mb-3">
Share any calendar, list, note, or event with people outside the household. Links expire
on their own no logins needed.
</p>
{shareLinks.length === 0 ? (
<p className="muted text-[13px]">No active share links.</p>
) : (
<div className="flex flex-col gap-2">
{shareLinks.map((link) => {
const registration = getEntityType(link.entityType);
const label = registration?.label.singular ?? link.entityType;
const iconName = link.entityType.startsWith("calendar.")
? "calendar"
: link.entityType.startsWith("notes.")
? "note"
: link.entityType.startsWith("lists.")
? "list"
: "link";
return (
<div
key={link.id}
className="flex items-center gap-3 rounded-[var(--r-md)] border-[0.5px] p-3"
style={{ borderColor: "var(--hair)" }}
>
<div
className="size-9 rounded-md flex items-center justify-center shrink-0"
style={{ background: "var(--paper-2)" }}
>
<NavIcon name={iconName} className="size-4 text-[var(--ink-soft)]" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13.5px] font-medium">{label}</div>
<div className="flex flex-wrap gap-2 items-center text-[11.5px] muted mt-0.5">
<code style={{ fontFamily: "var(--mono)" }}>
{link.capabilities.write ? "edit" : "view-only"}
</code>
{link.expiresAt && (
<span>· expires {link.expiresAt.toLocaleDateString()}</span>
)}
</div>
</div>
<form action={revokeShareLinkAction}>
<input type="hidden" name="id" value={link.id} />
<Button variant="destructive" size="sm" type="submit">
Revoke
</Button>
</form>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
function NotificationsSection({
user,
vapidKey,
ntfyConfigured,
}: {
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
vapidKey: string;
ntfyConfigured: boolean;
}) {
return (
<>
<Card>
<CardHeader>
<CardTitle>Push notifications</CardTitle>
</CardHeader>
<CardContent>
<PushOptIn vapidKey={vapidKey} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notification channels</CardTitle>
</CardHeader>
<CardContent>
<NotifyChannelToggles
push={user.notifPush}
inapp={user.notifInApp}
ntfy={user.notifNtfy}
ntfyConfigured={ntfyConfigured}
/>
</CardContent>
</Card>
</>
);
}
async function CalendarsAndListsSection() {
const [calendars, lists] = await Promise.all([listCalendars(), listLists()]);
return (
<>
<Card>
<CardHeader>
<CardTitle>Calendars</CardTitle>
<Link
href="/calendar"
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[12.5px] hover:bg-[var(--shade)]"
>
<Plus className="size-3.5" />
New
</Link>
</CardHeader>
<div>
{calendars.length === 0 ? (
<div className="muted px-[14px] py-4 text-[13px]">No calendars yet.</div>
) : (
calendars.map((c) => (
<div key={c.id} className="set-row">
<span
className="dot"
style={{ background: c.color ?? "var(--c-household)", width: 12, height: 12 }}
/>
<div className="label">
<div className="t">{c.name}</div>
<div className="d">
{c.visibility === "private" ? "Private" : "Household · everyone sees it"}
</div>
</div>
{c.visibility === "private" && <Lock className="size-3.5 text-[var(--ink-mute)]" />}
</div>
))
)}
</div>
</Card>
<Card>
<CardHeader>
<CardTitle>Lists</CardTitle>
</CardHeader>
<CardContent>
<CompletionDelaySetting initialHours={24} />
</CardContent>
<div>
{lists.length === 0 ? (
<div className="muted px-[14px] py-4 text-[13px]">No lists yet.</div>
) : (
lists.map((l) => (
<div key={l.id} className="set-row">
<NavIcon
name={l.type === "shopping" ? "cart" : "check-square"}
className="size-4 text-[var(--ink-soft)]"
/>
<div className="label">
<div className="t">{l.name}</div>
<div className="d">
{l.type} · {l.openCount} open
</div>
</div>
</div>
))
)}
</div>
</Card>
</>
);
}
function AppearanceSection({
user,
}: {
user: {
themePalette: string;
themeMode: string;
themeFontPair: string;
themeDensity: string;
themeDashLayout: string;
themeCalView: string;
themeNavStyle: string;
};
}) {
return (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<Sun className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<ThemePicker
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
initialMode={user.themeMode as "light" | "dark" | "system"}
initialFontPair={
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
}
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
initialCalView={user.themeCalView as "month" | "week" | "day"}
initialNavStyle={
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
}
signedIn
/>
</CardContent>
</Card>
);
}
function DataSection() {
return (
<Card>
<CardHeader>
<CardTitle>Data & backups</CardTitle>
</CardHeader>
<div>
<div className="set-row">
<History className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Auto-backup</div>
<div className="d">Daily 03:00 /var/backups/famapp/. Configured via host cron.</div>
</div>
</div>
<div className="set-row">
<Globe className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Server</div>
<div className="d">Self-hosted via Docker Compose</div>
</div>
</div>
<div className="set-row" style={{ borderBottom: "0" }}>
<Mail className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Export</div>
<div className="d">Not yet implemented coming in v0.5</div>
</div>
<Bell className="size-4 text-[var(--ink-faint)]" />
</div>
</div>
</Card>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use server";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { pushSubscriptions } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session";
import { sendPushToEndpoint } from "@/modules/_core/push";
type PushSubscriptionJSON = {
endpoint: string;
keys: { p256dh: string; auth: string };
};
export async function subscribeToPush(sub: PushSubscriptionJSON, userAgent: string): Promise<void> {
const { user } = await getCurrentSession();
await db
.insert(pushSubscriptions)
.values({
userId: user.id,
endpoint: sub.endpoint,
p256dh: sub.keys.p256dh,
auth: sub.keys.auth,
userAgent: userAgent.slice(0, 512),
})
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
});
}
export async function unsubscribeFromPush(endpoint: string): Promise<void> {
const { user } = await getCurrentSession();
await db
.delete(pushSubscriptions)
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
}
export async function sendTestNotification(endpoint: string): Promise<void> {
const { user } = await getCurrentSession();
// Verify the endpoint belongs to the current user before sending.
const [owned] = await db
.select({ id: pushSubscriptions.id })
.from(pushSubscriptions)
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)))
.limit(1);
if (!owned) return;
await sendPushToEndpoint(endpoint, {
title: "famapp test",
body: "Push notifications are working!",
url: "/settings",
});
}
-24
View File
@@ -1,24 +0,0 @@
import Link from "next/link";
import { getRegistry } from "@/modules/_core/registry";
export function AppNav() {
const { modules } = getRegistry();
const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : []));
return (
<nav className="border-b px-4 py-3 flex items-center gap-6">
<Link href="/" className="font-semibold text-sm">
famapp
</Link>
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.label}
</Link>
))}
</nav>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { headers } from "next/headers";
import type { NavStyle } from "@/modules/_core/themes";
import type { DashboardMeta } from "@/app/d/actions";
import { Sidebar } from "@/components/sidebar";
import { Topbar } from "@/components/topbar";
import { BottomNav } from "@/components/bottom-nav";
import { Fab } from "@/components/fab";
import { NavModeProvider } from "@/components/nav-mode-provider";
const BARE_PREFIXES = ["/s/", "/login"];
interface Props {
signedIn: boolean;
navStyle: NavStyle;
dashboards: DashboardMeta[];
children: React.ReactNode;
}
export async function AppShell({ signedIn, navStyle, children }: Props) {
const h = await headers();
const pathname = h.get("x-pathname") ?? "";
const bare = BARE_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(p + "/") || pathname === p.replace(/\/$/, ""),
);
if (bare || !signedIn) {
// No shell — share viewer and signed-out pages render bare.
return <>{children}</>;
}
const useTopVariant = navStyle === "top-nav";
return (
<div className="app">
{useTopVariant ? <Sidebar variant="top" /> : <Sidebar variant="side" />}
<main className="main">
<Topbar />
<div className="scroll-area">{children}</div>
</main>
<BottomNav />
<Fab />
<NavModeProvider navStyle={navStyle} />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { NavLink } from "@/components/nav-link";
const ITEMS: Array<{ href: string; label: string; icon: string }> = [
{ href: "/", label: "Dashboard", icon: "home" },
{ href: "/calendar", label: "Calendar", icon: "calendar" },
{ href: "/lists", label: "Lists", icon: "list" },
{ href: "/notes", label: "Notes", icon: "note" },
{ href: "/settings", label: "Settings", icon: "settings" },
];
export function BottomNav() {
return (
<nav className="bottom-nav" aria-label="Primary navigation">
{ITEMS.map((it) => (
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
))}
</nav>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
return (
<span
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
aria-hidden="true"
>
f
</span>
);
}
export function BrandWordmark({ className }: { className?: string }) {
return (
<span className={cn("brand", className)}>
<BrandMark />
<span className="brand-name">famapp</span>
</span>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef } from "react";
import { Command } from "cmdk";
import { useRouter } from "next/navigation";
import { useQuickAdd } from "./quick-add-provider";
export function CommandPalette() {
const { paletteOpen, closePalette, actions } = useQuickAdd();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (paletteOpen) {
// Let the DOM mount, then focus
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [paletteOpen]);
if (!paletteOpen) return null;
function handleSelect(url: string) {
closePalette();
router.push(url);
}
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-50 bg-black/50" onClick={closePalette} aria-hidden="true" />
{/* Palette modal */}
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
className="fixed left-1/2 top-1/4 z-50 w-full max-w-md -translate-x-1/2 rounded-xl bg-background shadow-2xl ring-1 ring-border"
>
<Command
className="flex flex-col overflow-hidden rounded-xl"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
closePalette();
}
}}
>
<div className="border-b px-3 py-2">
<Command.Input
ref={inputRef}
placeholder="Quick add…"
className="w-full bg-transparent py-1 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<Command.List className="max-h-80 overflow-y-auto p-2">
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
No actions found.
</Command.Empty>
{groupByModule(actions).map(([moduleId, group]) => (
<Command.Group key={moduleId} heading={group.name}>
{group.items.map((action) => (
<Command.Item
key={action.id}
value={`${action.moduleName} ${action.label}`}
onSelect={() => handleSelect(action.url)}
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<span className="text-base leading-none">{iconEmoji(action.icon)}</span>
{action.label}
</Command.Item>
))}
</Command.Group>
))}
</Command.List>
<div className="border-t px-3 py-2 text-xs text-muted-foreground">
<kbd className="rounded border px-1 py-0.5 font-mono"></kbd> navigate ·{" "}
<kbd className="rounded border px-1 py-0.5 font-mono"></kbd> select ·{" "}
<kbd className="rounded border px-1 py-0.5 font-mono">esc</kbd> close
</div>
</Command>
</div>
</>
);
}
type GroupEntry = [
string,
{ name: string; items: import("@/modules/_core").SerializedQuickAddItem[] },
];
function groupByModule(actions: import("@/modules/_core").SerializedQuickAddItem[]): GroupEntry[] {
const map = new Map<
string,
{ name: string; items: import("@/modules/_core").SerializedQuickAddItem[] }
>();
for (const action of actions) {
if (!map.has(action.moduleId)) {
map.set(action.moduleId, { name: action.moduleName, items: [] });
}
map.get(action.moduleId)!.items.push(action);
}
return [...map.entries()];
}
function iconEmoji(icon?: string): string {
const map: Record<string, string> = {
"calendar-plus": "📅",
"calendar-days": "🗓️",
"shopping-cart": "🛒",
"list-checks": "✅",
"list-plus": "📋",
"file-plus": "📝",
};
return icon ? (map[icon] ?? "") : "";
}
@@ -0,0 +1,46 @@
"use client";
import { useTransition } from "react";
import { setCompletionVisibilityHours } from "@/app/settings/actions";
const OPTIONS = [
{ label: "1 hour", value: 1 },
{ label: "4 hours", value: 4 },
{ label: "8 hours", value: 8 },
{ label: "24 hours (default)", value: 24 },
{ label: "48 hours", value: 48 },
{ label: "7 days", value: 168 },
{ label: "Never hide", value: 8760 },
];
export function CompletionDelaySetting({ initialHours }: { initialHours: number }) {
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const hours = Number(e.target.value);
startTransition(() => setCompletionVisibilityHours(hours));
}
return (
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Show completed items for</p>
<p className="text-xs text-muted-foreground">
How long checked-off items remain visible on list cards and the dashboard.
</p>
</div>
<select
defaultValue={initialHours}
onChange={handleChange}
disabled={isPending}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
>
{OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
+281
View File
@@ -0,0 +1,281 @@
"use client";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { GridLayout } from "react-grid-layout";
import type { Layout } from "react-grid-layout";
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
import { computePresetLayoutFromMetas } from "@/lib/dashboard";
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions";
import { Button } from "@/components/ui/button";
import { WidgetPicker } from "./widget-picker";
function placementKey(placement: WidgetPlacement, index: number) {
return `${placement.widgetId}::${index}`;
}
export function DashboardEditor({
dashboard,
layout: initialLayout,
widgetMetas,
}: {
dashboard: { id: string; name: string; slug: string };
layout: DashboardLayout;
widgetMetas: SerializedWidgetMeta[];
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [placements, setPlacements] = useState<WidgetPlacement[]>(initialLayout.widgets);
const [isDirty, setIsDirty] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [configuringIndex, setConfiguringIndex] = useState<number | null>(null);
const [containerWidth, setContainerWidth] = useState(1200);
const [presetMenuOpen, setPresetMenuOpen] = useState(false);
useEffect(() => {
function measure() {
const el = document.getElementById("dashboard-editor-grid");
if (el) setContainerWidth(el.offsetWidth);
}
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") handleCancel();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.slug]);
function handleCancel() {
router.push(`/d/${dashboard.slug}`);
}
function handleSave() {
startTransition(async () => {
await saveDashboardLayout(dashboard.id, { version: 1, widgets: placements });
router.push(`/d/${dashboard.slug}`);
});
}
function handleReset() {
startTransition(async () => {
await resetDashboardLayout(dashboard.id);
router.push(`/d/${dashboard.slug}`);
});
}
function handleLayoutChange(items: Layout) {
setPlacements((current) =>
current.map((p, i) => {
const key = placementKey(p, i);
const item = items.find((it) => it.i === key);
if (!item) return p;
return { ...p, x: item.x, y: item.y, w: item.w, h: item.h };
}),
);
setIsDirty(true);
}
function removeWidget(index: number) {
setPlacements((current) => current.filter((_, i) => i !== index));
setIsDirty(true);
}
function addWidget(widgetId: string, config: unknown) {
const meta = widgetMetas.find((m) => m.id === widgetId);
if (!meta) return;
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
setPlacements((current) => [
...current,
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h },
]);
setIsDirty(true);
setPickerOpen(false);
}
function updateConfig(index: number, config: unknown) {
setPlacements((current) => current.map((p, i) => (i === index ? { ...p, config } : p)));
setIsDirty(true);
setConfiguringIndex(null);
}
function applyPreset(preset: PresetId) {
const next = computePresetLayoutFromMetas(preset, widgetMetas);
setPlacements(next.widgets);
setIsDirty(true);
setPresetMenuOpen(false);
}
const gridItems: Layout = placements.map((p, i) => ({
i: placementKey(p, i),
x: p.x,
y: p.y,
w: p.w,
h: p.h,
minW: widgetMetas.find((m) => m.id === p.widgetId)?.minSize?.w ?? 2,
minH: widgetMetas.find((m) => m.id === p.widgetId)?.minSize?.h ?? 1,
maxW: widgetMetas.find((m) => m.id === p.widgetId)?.maxSize?.w ?? 12,
}));
return (
<div>
<div className="mb-6 flex items-center justify-between gap-4 flex-wrap">
<h1 className="serif text-[26px] tracking-tight">{dashboard.name}</h1>
<div className="flex items-center gap-2 flex-wrap relative">
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setPresetMenuOpen((o) => !o)}
disabled={isPending}
>
<LayoutGrid className="size-4 mr-1" />
Preset
</Button>
{presetMenuOpen && (
<div
className="absolute right-0 top-9 z-50 min-w-[180px] rounded-md border-[0.5px] bg-card shadow-[var(--shadow-pop)]"
style={{ borderColor: "var(--hair-2)" }}
onMouseLeave={() => setPresetMenuOpen(false)}
>
<button
type="button"
onClick={() => applyPreset("classic")}
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
>
Classic main + side rail
</button>
<button
type="button"
onClick={() => applyPreset("split")}
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
>
Split two even columns
</button>
<button
type="button"
onClick={() => applyPreset("glance")}
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--shade)]"
>
Glance single column
</button>
</div>
)}
</div>
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
<RotateCcw className="size-4 mr-1" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
disabled={isPending}
>
<Plus className="size-4 mr-1" />
Add widget
</Button>
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!isDirty || isPending}>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
<p className="mb-4 text-sm text-muted-foreground">
Drag to reorder · resize from the bottom-right corner · ESC to cancel
</p>
<div id="dashboard-editor-grid">
<GridLayout
layout={gridItems}
width={containerWidth}
gridConfig={{
cols: 12,
rowHeight: 60,
margin: [16, 16] as [number, number],
containerPadding: [0, 0] as [number, number],
}}
dragConfig={{ handle: ".drag-handle" }}
onLayoutChange={handleLayoutChange}
>
{placements.map((placement, i) => {
const meta = widgetMetas.find((m) => m.id === placement.widgetId);
return (
<div
key={placementKey(placement, i)}
className="rounded-lg border bg-card text-card-foreground flex flex-col overflow-hidden"
>
<div className="drag-handle flex items-center gap-2 px-3 py-2 bg-muted/40 cursor-grab active:cursor-grabbing select-none border-b">
<GripVertical className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{meta?.title ?? placement.widgetId}
</span>
<button
type="button"
onClick={() => setConfiguringIndex(i)}
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
aria-label="Configure widget"
>
<Settings2 className="size-4" />
</button>
<button
type="button"
onClick={() => removeWidget(i)}
className="text-muted-foreground hover:text-destructive transition-colors p-0.5"
aria-label="Remove widget"
>
<Trash2 className="size-4" />
</button>
</div>
<div className="flex-1 flex items-center justify-center p-4">
<p className="text-xs text-muted-foreground">{meta?.description}</p>
</div>
</div>
);
})}
</GridLayout>
</div>
{placements.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 gap-4 text-center">
<p className="text-muted-foreground">No widgets yet.</p>
<Button onClick={() => setPickerOpen(true)}>
<Plus className="size-4 mr-1" />
Add widget
</Button>
</div>
)}
{pickerOpen && (
<WidgetPicker
onClose={() => setPickerOpen(false)}
widgetMetas={widgetMetas}
onAdd={addWidget}
/>
)}
{configuringIndex !== null && (
<WidgetPicker
onClose={() => setConfiguringIndex(null)}
widgetMetas={widgetMetas}
onAdd={(_, config) => updateConfig(configuringIndex, config)}
initialWidgetId={placements[configuringIndex]?.widgetId}
initialConfig={placements[configuringIndex]?.config}
/>
)}
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { MoreHorizontal, Plus, Star, Trash2, PenLine } from "lucide-react";
import {
createDashboard,
deleteDashboard,
renameDashboard,
setDefaultDashboard,
} from "@/app/d/actions";
import type { DashboardMeta } from "@/app/d/actions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function DashboardSwitcher({ dashboards }: { dashboards: DashboardMeta[] }) {
const pathname = usePathname();
const router = useRouter();
const [, startTransition] = useTransition();
const [creatingNew, setCreatingNew] = useState(false);
const [newName, setNewName] = useState("");
function activeSlug() {
const m = pathname.match(/^\/d\/([^/]+)/);
return m?.[1] ?? null;
}
function handleCreate() {
if (!newName.trim()) return;
startTransition(async () => {
const created = await createDashboard(newName.trim());
setCreatingNew(false);
setNewName("");
router.push(`/d/${created.slug}`);
});
}
return (
<div className="flex items-center gap-1">
{/* Active tab highlights — overlay on top of the server-rendered links */}
{dashboards.map((d) => {
const isActive = activeSlug() === d.slug;
return (
<span
key={d.id}
aria-hidden
className={`absolute pointer-events-none border-b-2 transition-colors ${
isActive ? "border-primary" : "border-transparent"
}`}
style={{ display: "none" }}
/>
);
})}
{creatingNew ? (
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
className="flex items-center gap-1 ml-1"
>
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Dashboard name"
className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onKeyDown={(e) => e.key === "Escape" && setCreatingNew(false)}
/>
<button
type="submit"
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Add
</button>
<button
type="button"
onClick={() => setCreatingNew(false)}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Cancel
</button>
</form>
) : (
<button
type="button"
onClick={() => setCreatingNew(true)}
className="shrink-0 flex items-center gap-1 px-2 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
aria-label="New dashboard"
>
<Plus className="size-3.5" />
</button>
)}
{dashboards.map((d) => {
const isActive = activeSlug() === d.slug;
if (!isActive) return null;
return <DashboardKebab key={d.id} dashboard={d} canDelete={dashboards.length > 1} />;
})}
</div>
);
}
function DashboardKebab({
dashboard,
canDelete,
}: {
dashboard: DashboardMeta;
canDelete: boolean;
}) {
const [, startTransition] = useTransition();
const [renaming, setRenaming] = useState(false);
const [newName, setNewName] = useState(dashboard.name);
function handleRename() {
if (!newName.trim() || newName === dashboard.name) {
setRenaming(false);
return;
}
startTransition(async () => {
await renameDashboard(dashboard.id, newName.trim());
setRenaming(false);
});
}
if (renaming) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleRename();
}}
className="flex items-center gap-1 ml-1"
>
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onKeyDown={(e) => e.key === "Escape" && setRenaming(false)}
/>
<button type="submit" className="text-xs text-muted-foreground hover:text-foreground px-1">
Save
</button>
<button
type="button"
onClick={() => setRenaming(false)}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Cancel
</button>
</form>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className="shrink-0 flex items-center px-1 py-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Dashboard options"
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<PenLine className="size-4 mr-2" />
Rename
</DropdownMenuItem>
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star className="size-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 className="size-4 mr-2" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function DashboardTab({ slug, name }: { slug: string; name: string }) {
const pathname = usePathname();
const isActive = pathname === `/d/${slug}` || pathname.startsWith(`/d/${slug}/`);
return (
<Link
href={`/d/${slug}`}
className={`shrink-0 px-3 py-2 text-sm transition-colors border-b-2 ${
isActive
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{name}
</Link>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
export function EditDashboardButton() {
const router = useRouter();
const pathname = usePathname();
return (
<button type="button" onClick={() => router.push(`${pathname}?edit=1`)} className="btn btn-sm">
Edit dashboard
</button>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon";
export function Fab() {
const { openSheet } = useQuickAdd();
return (
<button type="button" className="fab" aria-label="Quick add" onClick={openSheet}>
<NavIcon name="plus" className="size-6" strokeWidth={2.4} />
</button>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import { X, Share, Plus } from "lucide-react";
const DISMISSED_KEY = "pwa-install-dismissed";
type Prompt = "android" | "ios";
function isIos() {
return (
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
// iPad on iOS 13+ reports as Mac
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
);
}
function isInStandaloneMode() {
return (
"standalone" in window.navigator &&
(window.navigator as { standalone?: boolean }).standalone === true
);
}
export function InstallPrompt() {
const [prompt, setPrompt] = useState<Prompt | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [deferredEvent, setDeferredEvent] = useState<any>(null);
useEffect(() => {
if (localStorage.getItem(DISMISSED_KEY)) return;
// Android / Chrome desktop — beforeinstallprompt fires
const handler = (e: Event) => {
e.preventDefault();
setDeferredEvent(e);
setPrompt("android");
};
window.addEventListener("beforeinstallprompt", handler);
// iOS — no beforeinstallprompt; defer setState out of the synchronous
// effect body to satisfy react-hooks/set-state-in-effect.
let iosTimer: ReturnType<typeof setTimeout> | undefined;
if (isIos() && !isInStandaloneMode()) {
iosTimer = setTimeout(() => setPrompt("ios"), 0);
}
return () => {
window.removeEventListener("beforeinstallprompt", handler);
clearTimeout(iosTimer);
};
}, []);
function dismiss() {
localStorage.setItem(DISMISSED_KEY, "1");
setPrompt(null);
}
async function install() {
if (!deferredEvent) return;
deferredEvent.prompt();
const { outcome } = await deferredEvent.userChoice;
if (outcome === "accepted" || outcome === "dismissed") {
dismiss();
}
}
if (!prompt) return null;
return (
<div
role="banner"
className="fixed bottom-[80px] left-0 right-0 z-50 flex items-start gap-3 px-4 py-3 sm:bottom-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 sm:px-5 sm:py-4"
style={{
background: "var(--card)",
border: "0.5px solid var(--hair-2)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-pop)",
margin: "0 14px",
}}
>
<div
className="mt-0.5 size-10 shrink-0 overflow-hidden flex items-center justify-center"
style={{
borderRadius: 8,
background: "var(--ink)",
color: "var(--paper)",
fontFamily: "var(--serif)",
fontSize: 18,
fontWeight: 500,
}}
>
f
</div>
<div className="flex-1 text-[13.5px]">
<p className="font-medium leading-snug text-[var(--ink)] m-0">
Add famapp to your home screen
</p>
{prompt === "android" && (
<>
<p className="mt-1 muted text-[12.5px] m-0">
Install for a faster, app-like experience.
</p>
<button onClick={install} className="btn btn-sm btn-primary mt-2">
Install
</button>
</>
)}
{prompt === "ios" && (
<p className="mt-1 muted text-[12.5px] m-0">
Tap <Share className="inline-block size-3.5 align-text-bottom" aria-label="Share" />{" "}
then{" "}
<strong className="font-medium">
<Plus className="inline-block size-3 align-text-bottom" />
&nbsp;Add to Home Screen
</strong>
.
</p>
)}
</div>
<button
onClick={dismiss}
aria-label="Dismiss"
className="mt-0.5 shrink-0 rounded p-1 text-[var(--ink-mute)] hover:bg-[var(--shade)]"
>
<X className="size-4" />
</button>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import {
Bell,
Calendar,
CalendarDays,
CheckSquare,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
Clock,
Eye,
FileText,
Filter,
Globe,
History,
Home,
Link as LinkIcon,
ListChecks,
Lock,
Mail,
Menu,
MoreHorizontal,
Pencil,
Phone,
Pin,
PinOff,
Plus,
Search,
Settings,
Share2,
ShoppingCart,
Sparkles,
Sun,
Trash2,
Users,
X,
} from "lucide-react";
import type { LucideProps } from "lucide-react";
const ICONS: Record<string, React.ComponentType<LucideProps>> = {
home: Home,
calendar: Calendar,
"calendar-days": CalendarDays,
list: ListChecks,
"check-square": CheckSquare,
"file-text": FileText,
note: FileText,
settings: Settings,
history: History,
link: LinkIcon,
people: Users,
users: Users,
plus: Plus,
search: Search,
bell: Bell,
pin: Pin,
"pin-off": PinOff,
cart: ShoppingCart,
"shopping-cart": ShoppingCart,
share: Share2,
lock: Lock,
eye: Eye,
clock: Clock,
sparkles: Sparkles,
pencil: Pencil,
trash: Trash2,
globe: Globe,
phone: Phone,
filter: Filter,
sun: Sun,
mail: Mail,
menu: Menu,
more: MoreHorizontal,
x: X,
"chevron-left": ChevronLeft,
"chevron-right": ChevronRight,
"chevron-down": ChevronDown,
};
export function NavIcon({ name, ...rest }: { name: string } & LucideProps) {
const Icon = ICONS[name] ?? Circle;
return <Icon strokeWidth={1.6} {...rest} />;
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { NavIcon } from "@/components/nav-icon";
import { cn } from "@/lib/utils";
function isActive(pathname: string, href: string): boolean {
// Strip query/fragment from href before matching.
const cleaned = href.split("#")[0]?.split("?")[0] ?? href;
if (cleaned === "/") return pathname === "/" || pathname.startsWith("/d/");
return pathname === cleaned || pathname.startsWith(cleaned + "/");
}
interface Props {
href: string;
icon: string;
label: string;
className?: string;
iconClassName?: string;
variant?: "sidebar" | "bottom";
}
export function NavLink({
href,
icon,
label,
className,
iconClassName,
variant = "sidebar",
}: Props) {
const pathname = usePathname() ?? "";
const active = isActive(pathname, href);
if (variant === "bottom") {
return (
<Link href={href} aria-current={active ? "page" : undefined} className={className}>
<NavIcon name={icon} className={cn("size-5", iconClassName)} />
<span>{label}</span>
</Link>
);
}
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={cn("nav-item", className)}
title={label}
>
<NavIcon name={icon} className={cn("size-4", iconClassName)} />
<span className="nav-label">{label}</span>
</Link>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
import type { NavStyle } from "@/modules/_core/themes";
import { navStyleToDataNav } from "@/modules/_core/themes";
const MOBILE_QUERY = "(max-width: 759px)";
/** Listens to the mobile breakpoint and overrides data-nav on <html>.
* The pre-paint script in layout.tsx already applies the correct value
* on first paint; this picks up subsequent resizes. Runs once and stays
* mounted as long as the shell is mounted. */
export function NavModeProvider({ navStyle }: { navStyle: NavStyle }) {
useEffect(() => {
const html = document.documentElement;
const desktopNav = navStyleToDataNav(navStyle);
const apply = () => {
const isMobile = window.matchMedia(MOBILE_QUERY).matches;
const next = isMobile ? (navStyle === "fab-only" ? "fab" : "bottom") : desktopNav;
html.setAttribute("data-nav", next);
};
apply();
const mq = window.matchMedia(MOBILE_QUERY);
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, [navStyle]);
return null;
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { Bell } from "lucide-react";
import { markNotificationRead, markAllNotificationsRead } from "@/app/settings/notify-actions";
type NotifItem = {
id: string;
title: string;
body: string;
url: string | null;
createdAt: Date;
};
export function NotificationBell({
initialUnread,
initialItems,
}: {
initialUnread: number;
initialItems: NotifItem[];
}) {
const [open, setOpen] = useState(false);
const [unread, setUnread] = useState(initialUnread);
const [items, setItems] = useState(initialItems);
const [isPending, startTransition] = useTransition();
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function markRead(id: string) {
setItems((prev) => prev.map((n) => (n.id === id ? { ...n, readAt: new Date() } : n)));
setUnread((u) => Math.max(0, u - 1));
startTransition(() => markNotificationRead(id));
}
function markAll() {
setItems((prev) => prev.map((n) => ({ ...n, readAt: new Date() })));
setUnread(0);
startTransition(() => markAllNotificationsRead());
}
return (
<div className="relative" ref={panelRef}>
<button
type="button"
aria-label={`Notifications${unread > 0 ? ` (${unread} unread)` : ""}`}
onClick={() => setOpen((o) => !o)}
className="relative text-muted-foreground hover:text-foreground transition-colors"
>
<Bell className="size-4" />
{unread > 0 && (
<span className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-8 z-50 w-80 rounded-lg border bg-background shadow-lg">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-semibold">Notifications</span>
{unread > 0 && (
<button
type="button"
onClick={markAll}
disabled={isPending}
className="text-xs text-muted-foreground hover:text-foreground"
>
Mark all read
</button>
)}
</div>
<ul className="max-h-80 overflow-y-auto divide-y">
{items.length === 0 && (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
No notifications
</li>
)}
{items.map((n) => {
const isUnread = !("readAt" in n && (n as { readAt?: Date }).readAt);
return (
<li key={n.id}>
<button
type="button"
className={`w-full text-left px-4 py-3 hover:bg-accent transition-colors ${isUnread ? "font-medium" : "opacity-60"}`}
onClick={() => {
if (isUnread) markRead(n.id);
if (n.url) window.location.href = n.url;
setOpen(false);
}}
>
<p className="text-sm">{n.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{n.body}</p>
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useTransition } from "react";
import { setNotifChannel } from "@/app/settings/notify-actions";
type Channel = "push" | "inapp" | "ntfy";
const LABEL: Record<Channel, string> = {
push: "Web push",
inapp: "In-app inbox",
ntfy: "ntfy",
};
export function NotifyChannelToggles({
push,
inapp,
ntfy,
ntfyConfigured,
}: {
push: boolean;
inapp: boolean;
ntfy: boolean;
ntfyConfigured: boolean;
}) {
const [isPending, startTransition] = useTransition();
function toggle(channel: Channel, enabled: boolean) {
startTransition(async () => {
await setNotifChannel(channel, enabled);
});
}
const channels: { key: Channel; value: boolean; disabled?: boolean }[] = [
{ key: "push", value: push },
{ key: "inapp", value: inapp },
{ key: "ntfy", value: ntfy, disabled: !ntfyConfigured },
];
return (
<div className="space-y-3">
{channels.map(({ key, value, disabled }) => (
<label key={key} className="flex items-center justify-between gap-4">
<span className="text-sm">
{LABEL[key]}
{key === "ntfy" && !ntfyConfigured && (
<span className="ml-2 text-xs text-muted-foreground">(NTFY_URL not configured)</span>
)}
</span>
<input
type="checkbox"
checked={value}
disabled={isPending || disabled}
onChange={(e) => toggle(key, e.target.checked)}
className="size-4 cursor-pointer"
/>
</label>
))}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More