diff --git a/.env.production.example b/.env.production.example index 7077e81..f7264f5 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,5 +1,15 @@ # ── famapp ──────────────────────────────────────────────────────────────────── +# 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 in share links, OIDC redirect URIs, etc.) NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..291574f --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3a39ed0 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2523a44 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +One line per release. "What's in prod" = the highest tag listed under a date that's been deployed. + +Format: `## vX.Y.Z — YYYY-MM-DD` + +## Unreleased + +- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist. diff --git a/Dockerfile b/Dockerfile index 2495a8e..60b0583 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,13 @@ 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 uses +# drizzle-orm + postgres which Next.js standalone already traces in. +COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle +COPY --from=builder --chown=nextjs:nodejs /app/scripts/migrate.mjs ./scripts/migrate.mjs +COPY --chown=nextjs:nodejs deploy/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"] diff --git a/STATUS.md b/STATUS.md index 4eff295..db3aac9 100644 --- a/STATUS.md +++ b/STATUS.md @@ -43,6 +43,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b ## Next up - Phase 7 hardening complete. All acceptance criteria met for tasks 60, 61, 62. +- **09 — Production prep** (scaffolding landed): tag-based release workflow (`.github/workflows/release.yml` → GHCR), CI on push/PR (`.github/workflows/ci.yml`), migration entrypoint in container (`scripts/migrate.mjs` + `deploy/docker-entrypoint.sh`), startup assertion in `src/lib/dev-login-config.ts`, image-pinned compose with `FAMAPP_IMAGE`/`AUTHENTIK_IMAGE_TAG`, `deploy/README.md`, `CHANGELOG.md`. Task 09 itself is now a recurring pre-deploy checklist (`docs/tasks/09-pre-deploy-checklist.md`); run it before every tag. ## Development login/testing notes diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..24308bf --- /dev/null +++ b/deploy/README.md @@ -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. diff --git a/deploy/backups/README.md b/deploy/backups/README.md index f4c94cf..890368e 100644 --- a/deploy/backups/README.md +++ b/deploy/backups/README.md @@ -22,11 +22,11 @@ compression, ~3–5× smaller than plain SQL). ## Retention -| Tier | Kept | Trigger | -| ------- | ---- | --------------------- | -| daily | 14 | every night | -| weekly | 8 | Sunday night | -| monthly | 6 | 1st of the month | +| 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. diff --git a/deploy/docker-entrypoint.sh b/deploy/docker-entrypoint.sh new file mode 100644 index 0000000..a9d63d0 --- /dev/null +++ b/deploy/docker-entrypoint.sh @@ -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 diff --git a/docs/tasks/08-theming.md b/docs/tasks/08-theming.md index f9be264..65cddd0 100644 --- a/docs/tasks/08-theming.md +++ b/docs/tasks/08-theming.md @@ -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 ``: ```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. diff --git a/docs/tasks/09-pre-deploy-checklist.md b/docs/tasks/09-pre-deploy-checklist.md new file mode 100644 index 0000000..a788894 --- /dev/null +++ b/docs/tasks/09-pre-deploy-checklist.md @@ -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/` 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`. diff --git a/docs/tasks/09-production-dev-login-removal.md b/docs/tasks/09-production-dev-login-removal.md deleted file mode 100644 index aecfd23..0000000 --- a/docs/tasks/09-production-dev-login-removal.md +++ /dev/null @@ -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. diff --git a/docs/tasks/10-calendar-module.md b/docs/tasks/10-calendar-module.md index 5895e17..bab9fcd 100644 --- a/docs/tasks/10-calendar-module.md +++ b/docs/tasks/10-calendar-module.md @@ -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 diff --git a/docs/tasks/25-multiple-dashboards.md b/docs/tasks/25-multiple-dashboards.md index 91c971e..f700d22 100644 --- a/docs/tasks/25-multiple-dashboards.md +++ b/docs/tasks/25-multiple-dashboards.md @@ -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 diff --git a/docs/tasks/26-customizable-layout.md b/docs/tasks/26-customizable-layout.md index 9a2ee1d..d34269d 100644 --- a/docs/tasks/26-customizable-layout.md +++ b/docs/tasks/26-customizable-layout.md @@ -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, + }, + ], } ``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b0c028..ae2d119 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,14 +5,7 @@ import nextConfig from "eslint-config-next/core-web-vitals"; export default tseslint.config( { - ignores: [ - "node_modules/**", - ".next/**", - ".claude/**", - "dist/**", - "drizzle/**", - "public/sw.js", - ], + ignores: ["node_modules/**", ".next/**", ".claude/**", "dist/**", "drizzle/**", "public/sw.js"], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/next.config.ts b/next.config.ts index d7909a1..1435e30 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,8 +3,7 @@ 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()); +const BUILD_TIME = process.env.NODE_ENV === "development" ? "dev" : String(Date.now()); writeFileSync(resolve(process.cwd(), "public/sw.js"), buildSwContent(BUILD_TIME)); diff --git a/public/offline.html b/public/offline.html index 93b31b6..a12266f 100644 --- a/public/offline.html +++ b/public/offline.html @@ -1,4 +1,4 @@ - + @@ -6,12 +6,24 @@ famapp — offline
- - + +
diff --git a/public/sw.js b/public/sw.js index 02254c2..964bbfc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,6 @@ -// famapp service worker — vdev +// famapp service worker — v1778106925247 // Generated at build time. Do not edit directly. -const CACHE_VERSION = "dev"; +const CACHE_VERSION = "1778106925247"; const SHELL = "famapp-shell-" + CACHE_VERSION; const API = "famapp-api-" + CACHE_VERSION; const OFFLINE = "/offline.html"; diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 9599f78..bc4e57e 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -32,8 +32,7 @@ for (let i = 0; i < 256; i++) { function crc32(buf) { let crc = 0xffffffff; - for (let i = 0; i < buf.length; i++) - crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } @@ -61,7 +60,7 @@ function solidPNG(size, r, g, b) { 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. diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..fa899c9 --- /dev/null +++ b/scripts/migrate.mjs @@ -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 }); +} diff --git a/src/app/d/[slug]/page.tsx b/src/app/d/[slug]/page.tsx index aad797c..2fc8142 100644 --- a/src/app/d/[slug]/page.tsx +++ b/src/app/d/[slug]/page.tsx @@ -10,10 +10,18 @@ import { EditDashboardButton } from "@/components/edit-dashboard-button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; const smColSpan: Record = { - 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", + 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", }; export default async function DashboardPage({ diff --git a/src/app/d/actions.ts b/src/app/d/actions.ts index 97c5ae9..d033b0c 100644 --- a/src/app/d/actions.ts +++ b/src/app/d/actions.ts @@ -21,7 +21,13 @@ export type DashboardMeta = { export async function listDashboards(): Promise { const { user } = await getCurrentSession(); const rows = await db - .select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position }) + .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)); @@ -60,11 +66,12 @@ export async function getDashboardBySlug(slug: string) { } function toSlug(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - || "dashboard"; + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "dashboard" + ); } async function uniqueSlug(userId: string, base: string): Promise { @@ -93,7 +100,13 @@ export async function createDashboard(name: string): Promise { 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 }; + 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 { @@ -134,10 +147,7 @@ export async function deleteDashboard(id: string): Promise { export async function setDefaultDashboard(id: string): Promise { const { user } = await getCurrentSession(); - await db - .update(dashboards) - .set({ isDefault: false }) - .where(eq(dashboards.userId, user.id)); + await db.update(dashboards).set({ isDefault: false }).where(eq(dashboards.userId, user.id)); await db .update(dashboards) .set({ isDefault: true }) diff --git a/src/app/globals.css b/src/app/globals.css index f1e86cf..e5e0597 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,233 +5,233 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --font-heading: var(--font-sans); - --font-sans: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } /* Warm theme — light */ [data-theme="warm"] { - --background: oklch(0.98 0.012 60); - --foreground: oklch(0.18 0.03 50); - --card: oklch(0.97 0.014 58); - --card-foreground: oklch(0.18 0.03 50); - --popover: oklch(0.97 0.014 58); - --popover-foreground: oklch(0.18 0.03 50); - --primary: oklch(0.52 0.18 40); - --primary-foreground: oklch(0.98 0.01 60); - --secondary: oklch(0.92 0.02 60); - --secondary-foreground: oklch(0.25 0.04 45); - --muted: oklch(0.93 0.018 58); - --muted-foreground: oklch(0.52 0.04 50); - --accent: oklch(0.90 0.03 55); - --accent-foreground: oklch(0.22 0.04 45); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.88 0.025 60); - --input: oklch(0.88 0.025 60); - --ring: oklch(0.65 0.12 45); - --chart-1: oklch(0.72 0.16 40); - --chart-2: oklch(0.62 0.14 55); - --chart-3: oklch(0.52 0.12 45); - --chart-4: oklch(0.45 0.10 40); - --chart-5: oklch(0.35 0.08 35); - --radius: 0.625rem; - --sidebar: oklch(0.94 0.018 58); - --sidebar-foreground: oklch(0.18 0.03 50); - --sidebar-primary: oklch(0.52 0.18 40); - --sidebar-primary-foreground: oklch(0.98 0.01 60); - --sidebar-accent: oklch(0.90 0.03 55); - --sidebar-accent-foreground: oklch(0.22 0.04 45); - --sidebar-border: oklch(0.88 0.025 60); - --sidebar-ring: oklch(0.65 0.12 45); + --background: oklch(0.98 0.012 60); + --foreground: oklch(0.18 0.03 50); + --card: oklch(0.97 0.014 58); + --card-foreground: oklch(0.18 0.03 50); + --popover: oklch(0.97 0.014 58); + --popover-foreground: oklch(0.18 0.03 50); + --primary: oklch(0.52 0.18 40); + --primary-foreground: oklch(0.98 0.01 60); + --secondary: oklch(0.92 0.02 60); + --secondary-foreground: oklch(0.25 0.04 45); + --muted: oklch(0.93 0.018 58); + --muted-foreground: oklch(0.52 0.04 50); + --accent: oklch(0.9 0.03 55); + --accent-foreground: oklch(0.22 0.04 45); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.88 0.025 60); + --input: oklch(0.88 0.025 60); + --ring: oklch(0.65 0.12 45); + --chart-1: oklch(0.72 0.16 40); + --chart-2: oklch(0.62 0.14 55); + --chart-3: oklch(0.52 0.12 45); + --chart-4: oklch(0.45 0.1 40); + --chart-5: oklch(0.35 0.08 35); + --radius: 0.625rem; + --sidebar: oklch(0.94 0.018 58); + --sidebar-foreground: oklch(0.18 0.03 50); + --sidebar-primary: oklch(0.52 0.18 40); + --sidebar-primary-foreground: oklch(0.98 0.01 60); + --sidebar-accent: oklch(0.9 0.03 55); + --sidebar-accent-foreground: oklch(0.22 0.04 45); + --sidebar-border: oklch(0.88 0.025 60); + --sidebar-ring: oklch(0.65 0.12 45); } /* Warm theme — dark */ [data-theme="warm"].dark { - --background: oklch(0.16 0.025 45); - --foreground: oklch(0.95 0.015 60); - --card: oklch(0.22 0.03 48); - --card-foreground: oklch(0.95 0.015 60); - --popover: oklch(0.22 0.03 48); - --popover-foreground: oklch(0.95 0.015 60); - --primary: oklch(0.78 0.14 50); - --primary-foreground: oklch(0.16 0.025 45); - --secondary: oklch(0.28 0.04 50); - --secondary-foreground: oklch(0.95 0.015 60); - --muted: oklch(0.28 0.035 48); - --muted-foreground: oklch(0.68 0.04 55); - --accent: oklch(0.30 0.045 50); - --accent-foreground: oklch(0.95 0.015 60); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.55 0.08 50); - --chart-1: oklch(0.78 0.14 50); - --chart-2: oklch(0.65 0.12 55); - --chart-3: oklch(0.52 0.10 48); - --chart-4: oklch(0.42 0.08 42); - --chart-5: oklch(0.32 0.06 38); - --sidebar: oklch(0.20 0.03 47); - --sidebar-foreground: oklch(0.95 0.015 60); - --sidebar-primary: oklch(0.78 0.14 50); - --sidebar-primary-foreground: oklch(0.16 0.025 45); - --sidebar-accent: oklch(0.30 0.045 50); - --sidebar-accent-foreground: oklch(0.95 0.015 60); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.55 0.08 50); + --background: oklch(0.16 0.025 45); + --foreground: oklch(0.95 0.015 60); + --card: oklch(0.22 0.03 48); + --card-foreground: oklch(0.95 0.015 60); + --popover: oklch(0.22 0.03 48); + --popover-foreground: oklch(0.95 0.015 60); + --primary: oklch(0.78 0.14 50); + --primary-foreground: oklch(0.16 0.025 45); + --secondary: oklch(0.28 0.04 50); + --secondary-foreground: oklch(0.95 0.015 60); + --muted: oklch(0.28 0.035 48); + --muted-foreground: oklch(0.68 0.04 55); + --accent: oklch(0.3 0.045 50); + --accent-foreground: oklch(0.95 0.015 60); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.55 0.08 50); + --chart-1: oklch(0.78 0.14 50); + --chart-2: oklch(0.65 0.12 55); + --chart-3: oklch(0.52 0.1 48); + --chart-4: oklch(0.42 0.08 42); + --chart-5: oklch(0.32 0.06 38); + --sidebar: oklch(0.2 0.03 47); + --sidebar-foreground: oklch(0.95 0.015 60); + --sidebar-primary: oklch(0.78 0.14 50); + --sidebar-primary-foreground: oklch(0.16 0.025 45); + --sidebar-accent: oklch(0.3 0.045 50); + --sidebar-accent-foreground: oklch(0.95 0.015 60); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.55 0.08 50); } @layer base { * { @apply border-border outline-ring/50; - } + } body { @apply bg-background text-foreground; - } + } html { @apply font-sans; - } + } } .fc { - --fc-border-color: var(--border); - --fc-page-bg-color: var(--background); - --fc-neutral-bg-color: var(--muted); - --fc-neutral-text-color: var(--muted-foreground); - --fc-button-bg-color: var(--primary); - --fc-button-border-color: var(--primary); - --fc-button-text-color: var(--primary-foreground); - --fc-button-hover-bg-color: var(--foreground); - --fc-button-hover-border-color: var(--foreground); - color: var(--foreground); + --fc-border-color: var(--border); + --fc-page-bg-color: var(--background); + --fc-neutral-bg-color: var(--muted); + --fc-neutral-text-color: var(--muted-foreground); + --fc-button-bg-color: var(--primary); + --fc-button-border-color: var(--primary); + --fc-button-text-color: var(--primary-foreground); + --fc-button-hover-bg-color: var(--foreground); + --fc-button-hover-border-color: var(--foreground); + color: var(--foreground); } .fc .fc-button { - border-radius: var(--radius-md); - font-size: 0.875rem; - font-weight: 500; - padding: 0.35rem 0.6rem; - text-transform: none; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + padding: 0.35rem 0.6rem; + text-transform: none; } .fc .fc-toolbar-title { - font-size: 1.25rem; - font-weight: 600; + font-size: 1.25rem; + font-weight: 600; } .fc .fc-daygrid-day-number, .fc .fc-col-header-cell-cushion { - color: var(--foreground); - text-decoration: none; + color: var(--foreground); + text-decoration: none; } .fc .fc-event { - border-radius: 6px; - padding: 1px 3px; + border-radius: 6px; + padding: 1px 3px; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index db79069..94ac291 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -49,11 +49,7 @@ const prePaintScript = `(function(){ } catch(e) {} })();`; -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { let theme = "default"; let themeMode = "system"; let userDashboards: DashboardMeta[] = []; @@ -70,7 +66,13 @@ export default async function RootLayout({ themeMode = row.themeMode; } userDashboards = await db - .select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position }) + .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)); diff --git a/src/app/s/[token]/page.tsx b/src/app/s/[token]/page.tsx index 4aba9d3..9e2f7b0 100644 --- a/src/app/s/[token]/page.tsx +++ b/src/app/s/[token]/page.tsx @@ -11,11 +11,7 @@ export const metadata: Metadata = { // Rate-limit prefix length — must match the value used in middleware. const RL_PREFIX_LEN = 8; -export default async function SharePage({ - params, -}: { - params: Promise<{ token: string }>; -}) { +export default async function SharePage({ params }: { params: Promise<{ token: string }> }) { const { token } = await params; const headersList = await headers(); const ip = diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index dff1cfe..49e255f 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -9,21 +9,12 @@ import type { ThemeId, ThemeMode } 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; -}) { +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"); const { user } = await getCurrentSession(); - await db - .update(users) - .set({ theme, themeMode: mode }) - .where(eq(users.id, user.id)); + await db.update(users).set({ theme, themeMode: mode }).where(eq(users.id, user.id)); } export async function setCompletionVisibilityHours(hours: number): Promise { @@ -31,10 +22,7 @@ export async function setCompletionVisibilityHours(hours: number): Promise 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)); + await db.update(users).set({ completionVisibilityHours: parsed }).where(eq(users.id, user.id)); revalidatePath("/settings"); } diff --git a/src/app/settings/household/actions.ts b/src/app/settings/household/actions.ts index ac2f0f2..36b79cd 100644 --- a/src/app/settings/household/actions.ts +++ b/src/app/settings/household/actions.ts @@ -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"); } diff --git a/src/app/settings/household/page.tsx b/src/app/settings/household/page.tsx index 726bb78..cd9fd76 100644 --- a/src/app/settings/household/page.tsx +++ b/src/app/settings/household/page.tsx @@ -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 }) => (
  • {user.name ?? user.email} - - {memberRole} - + {memberRole}
  • ))} diff --git a/src/app/settings/push-actions.ts b/src/app/settings/push-actions.ts index 8752abf..c048bd7 100644 --- a/src/app/settings/push-actions.ts +++ b/src/app/settings/push-actions.ts @@ -11,10 +11,7 @@ type PushSubscriptionJSON = { keys: { p256dh: string; auth: string }; }; -export async function subscribeToPush( - sub: PushSubscriptionJSON, - userAgent: string, -): Promise { +export async function subscribeToPush(sub: PushSubscriptionJSON, userAgent: string): Promise { const { user } = await getCurrentSession(); await db .insert(pushSubscriptions) @@ -35,9 +32,7 @@ export async function unsubscribeFromPush(endpoint: string): Promise { const { user } = await getCurrentSession(); await db .delete(pushSubscriptions) - .where( - and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)), - ); + .where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint))); } export async function sendTestNotification(): Promise { diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index d1c6b81..571a96a 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -27,11 +27,7 @@ export function CommandPalette() { return ( <> {/* Backdrop */} -