From 1ed14cea7987aaf3a2b3ad076888211de33f8d4d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:35:35 -0400 Subject: [PATCH 1/4] chore: promote nightly to main (2026-08-17) (#62) Co-authored-by: Jeremy Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .claude/settings.json | 4 + .dockerignore | 13 +- .env.example | 9 +- .github/workflows/ci.yml | 38 +- .github/workflows/codecov.yml | 43 - .github/workflows/codeql.yml | 4 +- .github/workflows/docker-build.yml | 12 +- .gitignore | 26 +- .vscode/settings.json | 8 + AGENTS.md | 9 - CLAUDE.md | 74 +- Dockerfile | 89 +- README.md | 54 +- audit-ci.json | 5 - backend/.gitignore | 3 + backend/cmd/api/main.go | 87 + backend/go.mod | 55 + backend/go.sum | 156 + backend/internal/api/handlers/auth.go | 159 + backend/internal/api/handlers/chores.go | 199 + backend/internal/api/handlers/deps.go | 71 + backend/internal/api/handlers/household.go | 57 + backend/internal/api/handlers/members.go | 121 + backend/internal/api/handlers/misc.go | 18 + backend/internal/api/handlers/reminders.go | 103 + backend/internal/api/handlers/rewards.go | 126 + backend/internal/api/middleware/auth.go | 108 + backend/internal/api/routes/routes.go | 90 + backend/internal/config/config.go | 52 + backend/internal/database/database.go | 53 + backend/internal/models/models.go | 122 + backend/internal/realtime/hub.go | 154 + backend/internal/services/auth_service.go | 108 + backend/internal/services/chore_service.go | 258 + .../services/household_auth_service.go | 106 + .../internal/services/household_service.go | 47 + backend/internal/services/member_service.go | 147 + backend/internal/services/recurrence.go | 147 + backend/internal/services/reminder_service.go | 103 + backend/internal/services/reward_service.go | 126 + codecov.yml | 71 - docker-compose.yml | 15 +- drizzle.config.ts | 10 - drizzle/0000_curved_storm.sql | 48 - drizzle/0001_bumpy_big_bertha.sql | 37 - drizzle/0002_wide_blizzard.sql | 1 - drizzle/meta/0000_snapshot.json | 356 - drizzle/meta/0001_snapshot.json | 592 - drizzle/meta/0002_snapshot.json | 600 - drizzle/meta/_journal.json | 27 - e2e/household-flow.spec.ts | 135 - eslint.config.mjs | 22 - frontend/.gitignore | 4 + frontend/eslint.config.js | 28 + frontend/index.html | 13 + frontend/package-lock.json | 3383 +++++ frontend/package.json | 35 + frontend/src/App.tsx | 130 + frontend/src/api/auth.ts | 32 + frontend/src/api/chores.ts | 40 + frontend/src/api/client.ts | 43 + frontend/src/api/household.ts | 10 + frontend/src/api/members.ts | 33 + frontend/src/api/profiles.ts | 14 + frontend/src/api/reminders.ts | 25 + frontend/src/api/rewards.ts | 32 + frontend/src/components/AddMemberForm.tsx | 101 + frontend/src/components/AppShell.tsx | 65 + frontend/src/components/ChoreFields.tsx | 155 + frontend/src/components/ChoreForm.tsx | 57 + frontend/src/components/ChoreRow.tsx | 107 + frontend/src/components/HouseholdName.tsx | 75 + .../src/components/MemberCard.tsx | 93 +- frontend/src/components/ReminderForm.tsx | 99 + frontend/src/components/ReminderItem.tsx | 60 + frontend/src/components/RewardCard.tsx | 49 + frontend/src/components/RewardForm.tsx | 76 + frontend/src/components/RewardRow.tsx | 119 + .../src/components/ThemePicker.tsx | 36 +- frontend/src/components/TodayChore.tsx | 68 + frontend/src/context/AuthContext.tsx | 114 + frontend/src/hooks/useRealtime.ts | 62 + src/app/globals.css => frontend/src/index.css | 29 +- frontend/src/main.tsx | 10 + .../src/pages/CalendarPage.tsx | 125 +- frontend/src/pages/ChoresPage.tsx | 63 + .../src/pages/HouseholdPage.tsx | 36 +- frontend/src/pages/HubPage.tsx | 83 + .../src/pages/LoginPage.tsx | 41 +- frontend/src/pages/RemindersPage.tsx | 65 + frontend/src/pages/RewardsPage.tsx | 52 + .../src/pages/SignupPage.tsx | 46 +- frontend/src/pages/SwitchProfilePage.tsx | 113 + frontend/src/types/index.ts | 80 + .../src/utils/avatarOptions.ts | 0 .../src/utils}/recurrence.ts | 42 +- frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.app.json | 28 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 21 + frontend/vite.config.ts | 22 + go.work | 3 + next.config.ts | 15 - package-lock.json | 10995 ---------------- package.json | 51 - playwright.config.ts | 33 - postcss.config.mjs | 7 - scripts/check-patch-coverage.mjs | 235 - scripts/hestia_dep_update.sh | 88 +- src/app/(app)/chores/chore-fields.test.tsx | 54 - src/app/(app)/chores/chore-fields.tsx | 137 - src/app/(app)/chores/chore-form.test.tsx | 40 - src/app/(app)/chores/chore-form.tsx | 32 - src/app/(app)/chores/chore-row.test.tsx | 90 - src/app/(app)/chores/chore-row.tsx | 81 - src/app/(app)/chores/page.tsx | 106 - src/app/(app)/chores/today-chore.test.tsx | 43 - src/app/(app)/chores/today-chore.tsx | 52 - .../(app)/household/add-member-form.test.tsx | 52 - src/app/(app)/household/add-member-form.tsx | 70 - .../(app)/household/household-name.test.tsx | 71 - src/app/(app)/household/household-name.tsx | 66 - src/app/(app)/household/member-card.test.tsx | 105 - src/app/(app)/household/theme-picker.test.tsx | 25 - src/app/(app)/layout.tsx | 73 - src/app/(app)/page.tsx | 127 - src/app/(app)/reminders/page.tsx | 83 - .../(app)/reminders/reminder-form.test.tsx | 56 - src/app/(app)/reminders/reminder-form.tsx | 70 - .../(app)/reminders/reminder-item.test.tsx | 86 - src/app/(app)/reminders/reminder-item.tsx | 58 - src/app/(app)/rewards/page.tsx | 61 - src/app/(app)/rewards/reward-card.test.tsx | 49 - src/app/(app)/rewards/reward-card.tsx | 44 - src/app/(app)/rewards/reward-form.test.tsx | 42 - src/app/(app)/rewards/reward-form.tsx | 48 - src/app/(app)/rewards/reward-row.test.tsx | 63 - src/app/(app)/rewards/reward-row.tsx | 96 - src/app/favicon.ico | Bin 25931 -> 0 bytes src/app/layout.tsx | 35 - src/app/switch-profile/page.tsx | 32 - .../switch-profile/profile-picker.test.tsx | 51 - src/app/switch-profile/profile-picker.tsx | 86 - src/db/index.ts | 14 - src/db/schema.ts | 132 - src/instrumentation.ts | 8 - src/lib/actions/auth.test.ts | 172 - src/lib/actions/auth.ts | 87 - src/lib/actions/chores.test.ts | 286 - src/lib/actions/chores.ts | 260 - src/lib/actions/form-state.ts | 1 - src/lib/actions/household.test.ts | 81 - src/lib/actions/household.ts | 53 - src/lib/actions/members.test.ts | 154 - src/lib/actions/members.ts | 123 - src/lib/actions/profile.test.ts | 107 - src/lib/actions/profile.ts | 45 - src/lib/actions/reminders.test.ts | 138 - src/lib/actions/reminders.ts | 102 - src/lib/actions/rewards.test.ts | 158 - src/lib/actions/rewards.ts | 146 - src/lib/auth/current-user.test.ts | 89 - src/lib/auth/current-user.ts | 42 - src/lib/auth/password.test.ts | 28 - src/lib/auth/password.ts | 27 - src/lib/auth/session.test.ts | 64 - src/lib/auth/session.ts | 80 - src/lib/chores/recurrence.test.ts | 111 - src/lib/theme.test.ts | 27 - src/lib/theme.ts | 20 - src/proxy.test.ts | 73 - src/proxy.ts | 63 - src/test/setup.ts | 58 - tsconfig.json | 41 - vitest.config.ts | 56 - 175 files changed, 8862 insertions(+), 18695 deletions(-) delete mode 100644 .github/workflows/codecov.yml create mode 100644 .vscode/settings.json delete mode 100644 AGENTS.md delete mode 100644 audit-ci.json create mode 100644 backend/.gitignore create mode 100644 backend/cmd/api/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/api/handlers/auth.go create mode 100644 backend/internal/api/handlers/chores.go create mode 100644 backend/internal/api/handlers/deps.go create mode 100644 backend/internal/api/handlers/household.go create mode 100644 backend/internal/api/handlers/members.go create mode 100644 backend/internal/api/handlers/misc.go create mode 100644 backend/internal/api/handlers/reminders.go create mode 100644 backend/internal/api/handlers/rewards.go create mode 100644 backend/internal/api/middleware/auth.go create mode 100644 backend/internal/api/routes/routes.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/database/database.go create mode 100644 backend/internal/models/models.go create mode 100644 backend/internal/realtime/hub.go create mode 100644 backend/internal/services/auth_service.go create mode 100644 backend/internal/services/chore_service.go create mode 100644 backend/internal/services/household_auth_service.go create mode 100644 backend/internal/services/household_service.go create mode 100644 backend/internal/services/member_service.go create mode 100644 backend/internal/services/recurrence.go create mode 100644 backend/internal/services/reminder_service.go create mode 100644 backend/internal/services/reward_service.go delete mode 100644 codecov.yml delete mode 100644 drizzle.config.ts delete mode 100644 drizzle/0000_curved_storm.sql delete mode 100644 drizzle/0001_bumpy_big_bertha.sql delete mode 100644 drizzle/0002_wide_blizzard.sql delete mode 100644 drizzle/meta/0000_snapshot.json delete mode 100644 drizzle/meta/0001_snapshot.json delete mode 100644 drizzle/meta/0002_snapshot.json delete mode 100644 drizzle/meta/_journal.json delete mode 100644 e2e/household-flow.spec.ts delete mode 100644 eslint.config.mjs create mode 100644 frontend/.gitignore create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/chores.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/household.ts create mode 100644 frontend/src/api/members.ts create mode 100644 frontend/src/api/profiles.ts create mode 100644 frontend/src/api/reminders.ts create mode 100644 frontend/src/api/rewards.ts create mode 100644 frontend/src/components/AddMemberForm.tsx create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/ChoreFields.tsx create mode 100644 frontend/src/components/ChoreForm.tsx create mode 100644 frontend/src/components/ChoreRow.tsx create mode 100644 frontend/src/components/HouseholdName.tsx rename src/app/(app)/household/member-card.tsx => frontend/src/components/MemberCard.tsx (50%) create mode 100644 frontend/src/components/ReminderForm.tsx create mode 100644 frontend/src/components/ReminderItem.tsx create mode 100644 frontend/src/components/RewardCard.tsx create mode 100644 frontend/src/components/RewardForm.tsx create mode 100644 frontend/src/components/RewardRow.tsx rename src/app/(app)/household/theme-picker.tsx => frontend/src/components/ThemePicker.tsx (51%) create mode 100644 frontend/src/components/TodayChore.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/hooks/useRealtime.ts rename src/app/globals.css => frontend/src/index.css (79%) create mode 100644 frontend/src/main.tsx rename src/app/(app)/calendar/page.tsx => frontend/src/pages/CalendarPage.tsx (55%) create mode 100644 frontend/src/pages/ChoresPage.tsx rename src/app/(app)/household/page.tsx => frontend/src/pages/HouseholdPage.tsx (53%) create mode 100644 frontend/src/pages/HubPage.tsx rename src/app/login/page.tsx => frontend/src/pages/LoginPage.tsx (63%) create mode 100644 frontend/src/pages/RemindersPage.tsx create mode 100644 frontend/src/pages/RewardsPage.tsx rename src/app/signup/page.tsx => frontend/src/pages/SignupPage.tsx (71%) create mode 100644 frontend/src/pages/SwitchProfilePage.tsx create mode 100644 frontend/src/types/index.ts rename src/app/(app)/household/avatar-options.ts => frontend/src/utils/avatarOptions.ts (100%) rename {src/lib/chores => frontend/src/utils}/recurrence.ts (65%) create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 go.work delete mode 100644 next.config.ts delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 playwright.config.ts delete mode 100644 postcss.config.mjs delete mode 100755 scripts/check-patch-coverage.mjs delete mode 100644 src/app/(app)/chores/chore-fields.test.tsx delete mode 100644 src/app/(app)/chores/chore-fields.tsx delete mode 100644 src/app/(app)/chores/chore-form.test.tsx delete mode 100644 src/app/(app)/chores/chore-form.tsx delete mode 100644 src/app/(app)/chores/chore-row.test.tsx delete mode 100644 src/app/(app)/chores/chore-row.tsx delete mode 100644 src/app/(app)/chores/page.tsx delete mode 100644 src/app/(app)/chores/today-chore.test.tsx delete mode 100644 src/app/(app)/chores/today-chore.tsx delete mode 100644 src/app/(app)/household/add-member-form.test.tsx delete mode 100644 src/app/(app)/household/add-member-form.tsx delete mode 100644 src/app/(app)/household/household-name.test.tsx delete mode 100644 src/app/(app)/household/household-name.tsx delete mode 100644 src/app/(app)/household/member-card.test.tsx delete mode 100644 src/app/(app)/household/theme-picker.test.tsx delete mode 100644 src/app/(app)/layout.tsx delete mode 100644 src/app/(app)/page.tsx delete mode 100644 src/app/(app)/reminders/page.tsx delete mode 100644 src/app/(app)/reminders/reminder-form.test.tsx delete mode 100644 src/app/(app)/reminders/reminder-form.tsx delete mode 100644 src/app/(app)/reminders/reminder-item.test.tsx delete mode 100644 src/app/(app)/reminders/reminder-item.tsx delete mode 100644 src/app/(app)/rewards/page.tsx delete mode 100644 src/app/(app)/rewards/reward-card.test.tsx delete mode 100644 src/app/(app)/rewards/reward-card.tsx delete mode 100644 src/app/(app)/rewards/reward-form.test.tsx delete mode 100644 src/app/(app)/rewards/reward-form.tsx delete mode 100644 src/app/(app)/rewards/reward-row.test.tsx delete mode 100644 src/app/(app)/rewards/reward-row.tsx delete mode 100644 src/app/favicon.ico delete mode 100644 src/app/layout.tsx delete mode 100644 src/app/switch-profile/page.tsx delete mode 100644 src/app/switch-profile/profile-picker.test.tsx delete mode 100644 src/app/switch-profile/profile-picker.tsx delete mode 100644 src/db/index.ts delete mode 100644 src/db/schema.ts delete mode 100644 src/instrumentation.ts delete mode 100644 src/lib/actions/auth.test.ts delete mode 100644 src/lib/actions/auth.ts delete mode 100644 src/lib/actions/chores.test.ts delete mode 100644 src/lib/actions/chores.ts delete mode 100644 src/lib/actions/form-state.ts delete mode 100644 src/lib/actions/household.test.ts delete mode 100644 src/lib/actions/household.ts delete mode 100644 src/lib/actions/members.test.ts delete mode 100644 src/lib/actions/members.ts delete mode 100644 src/lib/actions/profile.test.ts delete mode 100644 src/lib/actions/profile.ts delete mode 100644 src/lib/actions/reminders.test.ts delete mode 100644 src/lib/actions/reminders.ts delete mode 100644 src/lib/actions/rewards.test.ts delete mode 100644 src/lib/actions/rewards.ts delete mode 100644 src/lib/auth/current-user.test.ts delete mode 100644 src/lib/auth/current-user.ts delete mode 100644 src/lib/auth/password.test.ts delete mode 100644 src/lib/auth/password.ts delete mode 100644 src/lib/auth/session.test.ts delete mode 100644 src/lib/auth/session.ts delete mode 100644 src/lib/chores/recurrence.test.ts delete mode 100644 src/lib/theme.test.ts delete mode 100644 src/lib/theme.ts delete mode 100644 src/proxy.test.ts delete mode 100644 src/proxy.ts delete mode 100644 src/test/setup.ts delete mode 100644 tsconfig.json delete mode 100644 vitest.config.ts diff --git a/.claude/settings.json b/.claude/settings.json index 1f73a2b..6238542 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,9 @@ { "worktree": { "bgIsolation": "none" + }, + "attribution": { + "commit": "", + "pr": "" } } diff --git a/.dockerignore b/.dockerignore index c36715d..aa833f5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,16 @@ -node_modules -.next .git data *.md .env* !.env.example +.private/** + +# frontend: installed/built inside the image, not needed from the host +frontend/node_modules +frontend/dist + +# backend: local build output / test artifacts, not needed from the host +backend/hestia +backend/*.db +backend/*.db-* +**/*.test diff --git a/.env.example b/.env.example index c5f7c52..311b42b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Path to the SQLite database file (auto-created on first run). -DATABASE_PATH=./data/hestia.db +DB_PATH=./data/hestia.db # Random secret used to sign session cookies. Generate with: # openssl rand -base64 32 @@ -10,10 +10,3 @@ AUTH_SECRET= # clock, so this should match your household, not wherever the server # physically lives. Defaults to UTC if unset. TZ= - -# Only needed if you're serving Hestia from a subpath behind a reverse proxy -# (e.g. https://home.example.com/hestia/ instead of its own subdomain). -# Must start with a slash and have no trailing slash, e.g. /hestia. Leave -# unset to serve from the domain root. See the reverse-proxy section of the -# README for the full setup (including required proxy headers). -BASE_PATH= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adb56ab..4651f2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,23 +7,29 @@ on: branches: [main, development, nightly] jobs: - lint-and-build: + backend: runs-on: ubuntu-latest + defaults: + run: + working-directory: backend steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: - node-version: 24.19.0 - cache: npm + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum - - run: npm ci - - run: npm run lint - - run: npm run test:coverage - - run: npm run build + - run: go build ./... + - run: go vet ./... + - run: test -z "$(gofmt -l .)" + - run: go test ./... - e2e: + frontend: runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -31,16 +37,8 @@ jobs: with: node-version: 24.19.0 cache: npm + cache-dependency-path: frontend/package-lock.json - run: npm ci - - run: npx playwright install --with-deps chromium - - run: npm run test:e2e - - - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: playwright-report - path: | - playwright-report/ - test-results/ - retention-days: 7 + - run: npm run lint + - run: npm run build diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml deleted file mode 100644 index 3f09113..0000000 --- a/.github/workflows/codecov.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Upload Coverage to Codecov - -on: - push: - branches: [main, development, nightly] - pull_request: - branches: [main, development, nightly] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - codecov: - name: Coverage Upload - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 24.19.0 - cache: npm - - - run: npm ci - - - name: Run tests with coverage - run: npm run test:coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/lcov.info - fail_ci_if_error: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c43808c..d786dbe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,11 +31,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: languages: javascript-typescript queries: security-and-quality - - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: category: /language:javascript-typescript diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 36ec408..be368d6 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -12,7 +12,7 @@ name: Docker Build & Publish # Multi-arch publishing builds each platform on its own NATIVE runner # (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64) rather than # cross-building arm64 under QEMU emulation on an amd64 runner. On -# 2026-08-09 a QEMU-emulated `npm ci` reliably crashed with "qemu: uncaught +# 2026-08-09 a QEMU-emulated `npm ci --legacy-peer-deps` reliably crashed with "qemu: uncaught # target signal 4 (Illegal instruction) - core dumped" while running one of # esbuild's postinstall steps — deterministic on every retry, not a # transient hang. GitHub's Linux arm64 hosted runners are free for public @@ -119,7 +119,7 @@ jobs: - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Log in to GHCR - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 with: max_attempts: 3 timeout_minutes: 2 @@ -204,7 +204,7 @@ jobs: - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Log in to GHCR - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 with: max_attempts: 3 timeout_minutes: 2 @@ -212,7 +212,7 @@ jobs: command: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - name: Log in to Docker Hub - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 with: max_attempts: 3 timeout_minutes: 2 @@ -270,7 +270,7 @@ jobs: trivyignores: .trivyignore - name: Upload Trivy results to Security tab - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 continue-on-error: true with: sarif_file: trivy-results.sarif @@ -293,7 +293,7 @@ jobs: fail-build: false - name: Upload Grype results to Security tab - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 continue-on-error: true with: sarif_file: ${{ steps.grype.outputs.sarif }} diff --git a/.gitignore b/.gitignore index e7a19ba..4cf3c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage -/playwright-report -/test-results -/blob-report - -# next.js -/.next/ -/out/ - -# production -/build +# dependencies (root has no package.json anymore — frontend/.gitignore and +# backend/.gitignore cover their own node_modules/build artifacts) # misc .DS_Store @@ -56,4 +36,4 @@ docker-compose.override.yml # typescript *.tsbuildinfo -next-env.d.ts +.private/** diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..81aaf0b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "yaml.customTags": [ + "!override mapping", + "!override sequence", + "!reset mapping", + "!reset sequence" + ] +} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 643577d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ - - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/CLAUDE.md b/CLAUDE.md index 3eaec82..e30c3df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,3 @@ -@AGENTS.md - # Hestia A self-hosted, family-focused household chore chart. Free, MIT-licensed, no @@ -33,9 +31,22 @@ adding a new external dependency) before just doing it. them down to `development` automatically. Once the project is more mature this will switch to normal PRs against `development` like everything else; ask if it's unclear which regime is current. + Exception: if a workflow file is actively broken in a way that's + failing checks on an already-open PR, fix it directly on that PR's + branch instead — `pull_request`-triggered checks run using the + workflow file from the PR's own head branch, not from `main`, so a + fix on `main` alone won't turn the open PR's checks green. - Otherwise, open PRs against `development`, not `main`. `main` is the release/stable branch; `development` is the integration branch, periodically synced back into `main`. +- PR titles must use a Conventional Commits prefix (`feat:`, `fix:`, + `docs:`, `style:`, `refactor:`, `perf:`, `test:`, `build:`, `ci:`, + `chore:`, `revert:`) — enforced by `pr-title-lint.yml`. PRs are + squash-merged, so the PR title becomes the commit message on `main`, + and `release-please` parses that prefix to derive versions and + changelog entries; an unprefixed title fails the check and would + silently drop out of release automation even if merged. Individual + commit messages within a PR aren't checked — only the PR title matters. ## Product shape @@ -56,36 +67,47 @@ adding a new external dependency) before just doing it. ## Stack and why -- **Next.js (App Router, TypeScript)** — most reliable code generation for a - vibe-coded project, huge ecosystem, one process to deploy. -- **SQLite via Drizzle ORM** (`better-sqlite3` driver) — a self-hoster should - be able to back up the entire app by copying one file. Do not introduce - Postgres/MySQL/Redis/etc. without discussing it first — that's a real - architecture change, not a routine call. -- **Tailwind CSS** for styling. -- **Docker**, built multi-arch (amd64 + arm64) — a lot of the self-hosted - audience runs on Raspberry Pi or ARM NAS boxes. Keep the image and runtime - footprint light; avoid dependencies that only ship prebuilt binaries for - x86. -- Migrations run automatically on server boot via `src/instrumentation.ts` - (uses `drizzle-orm`'s own migrator against the `drizzle/` SQL files — not - the `drizzle-kit` CLI, which isn't in the production image). After editing - `src/db/schema.ts`, run `npm run db:generate` to produce a new migration - and commit it. +- **Go (Gin, GORM)** for the backend API — a single static-ish binary, + cheap to cross-compile for arm64, no runtime/interpreter to ship. +- **SQLite via GORM** using the `glebarez/sqlite` driver (pure Go, backed by + `modernc.org/sqlite` — no cgo) — a self-hoster should be able to back up + the entire app by copying one file, and a cgo-free driver keeps + cross-compilation for arm64 simple. Do not introduce Postgres/MySQL/ + Redis/etc. without discussing it first — that's a real architecture + change, not a routine call. +- **React 19 + Vite + TypeScript** for the frontend, with **Tailwind CSS v4** + for styling, **react-router v8** for routing, and **TanStack Query** for + server-state/data-fetching. +- **`gorilla/websocket`** for realtime sync between household members' + screens (e.g. one profile completing a chore updates another profile's + view live). +- **Auth**: JWT (`golang-jwt/jwt/v5`) + bcrypt implementing the two-tier + household/profile session model described above — one household-account + session, then a lighter profile-switch session layered on top. +- **Docker**, a single multi-stage Dockerfile that cross-compiles the Go + binary for amd64 + arm64 — a lot of the self-hosted audience runs on + Raspberry Pi or ARM NAS boxes. Keep the image and runtime footprint light; + avoid dependencies that only ship prebuilt binaries for x86. +- Migrations are GORM `AutoMigrate` only, run automatically at server boot — + no separate migration-generation step. After editing + `backend/internal/models/models.go`, AutoMigrate handles new + columns/tables automatically at next boot. ## Conventions -- Server Components by default; only add `"use client"` where interactivity - actually requires it (forms, avatar picker, checkboxes). -- Prefer Server Actions over hand-rolled API routes for mutations, unless - something genuinely needs a REST/JSON endpoint. +- Backend: prefer small, focused handlers; keep business logic in + `backend/internal/services`, not in the HTTP handlers themselves. +- Frontend: co-locate API calls under `frontend/src/api`; keep components + small and focused, lifting shared state into context/TanStack Query + rather than prop-drilling. - No abstraction for a single call site. No config/feature-flag scaffolding for hypothetical future options. Three similar lines beat a premature helper. -- Before calling a feature done: run `npm run build` and `npm run lint`. - There's no test suite yet — don't add one speculatively; add tests when - there's logic worth protecting (e.g. recurrence-date calculation), not for - CRUD boilerplate. +- Before calling a feature done: run `cd backend && go build ./... && go vet + ./...` and `cd frontend && npm run build && npm run lint`. There's no test + suite yet — don't add one speculatively; add tests when there's logic + worth protecting (e.g. recurrence-date calculation), not for CRUD + boilerplate. - Keep the Docker image and `docker-compose.yml` in sync with any new required environment variables (update `.env.example` too). diff --git a/Dockerfile b/Dockerfile index 490ca02..ae7fc04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,58 +1,59 @@ # syntax=docker/dockerfile:1 +# --- frontend: build the React/Vite SPA --- # Alpine over Debian slim: dramatically smaller vulnerability surface for # this app (musl/apk's package set vs Debian's) — measured via a full-severity # Trivy scan of both before switching: node:24-slim came back with 152 # findings, node:24-alpine with 11 and zero HIGH/CRITICAL either way. -FROM node:24.19.0-alpine AS base - -# --- deps: install deps once, with build tools available for better-sqlite3's -# native module in case no prebuilt binary exists for the target arch --- -FROM base AS deps -WORKDIR /app -RUN apk add --no-cache python3 make g++ -COPY package.json package-lock.json ./ +FROM node:24.19.0-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci +COPY frontend/ . +RUN npm run build -# --- builder: build the Next.js app --- -FROM base AS builder +# --- backend: cross-compile the Go binary --- +# The SQLite driver (glebarez/sqlite, backed by modernc.org/sqlite) is a +# pure-Go transpile of SQLite with no cgo involved, so a plain +# CGO_ENABLED=0 cross-compile from the Go toolchain's own GOARCH support is +# sufficient — no C cross-compiler or the `tonistiigi/xx` toolchain needed. +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . -RUN npm run build +COPY backend/go.mod backend/go.sum ./backend/ +RUN cd backend && go mod download +COPY backend/ ./backend/ +ARG TARGETARCH +ENV CGO_ENABLED=0 +RUN cd backend && GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/hestia ./cmd/api # --- runner: minimal production image --- -FROM base AS runner +FROM alpine:3.24 AS runner WORKDIR /app -ENV NODE_ENV=production -ENV DATABASE_PATH=/data/hestia.db -ENV PORT=3000 -ENV HOSTNAME=0.0.0.0 - -# Reuse the "node" user (UID/GID 1000) that ships in the base image, which -# matches the default first non-root user on most single-user Linux hosts. -# If your host user has a different UID, either run `chown -R 1000:1000 -# ./data` once, or set `user: ":"` in docker-compose.yml to match -# your host user instead. -RUN mkdir -p /data && chown node:node /data - -# npm (and corepack) ship in the base image but are never invoked here — -# the container only ever runs `node server.js`. Removing them eliminates -# their bundled transitive deps (undici/tar/ip-address) as a source of -# vulnerability findings entirely, rather than waiting on npm's own -# upstream to bump versions we don't control. -RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \ - /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack - -# Next.js standalone output traces and includes only the node_modules actually -# needed at runtime, including better-sqlite3's compiled native binary. -COPY --from=builder /app/public ./public -COPY --from=builder --chown=node:node /app/.next/standalone ./ -COPY --from=builder --chown=node:node /app/.next/static ./.next/static -COPY --from=builder /app/drizzle ./drizzle - -USER node + +# tzdata: lets the TZ env var control time.Local (chore due-dates are +# computed from the container's local clock). ca-certificates: needed for +# any outbound HTTPS calls now or in the future. +RUN apk add --no-cache tzdata ca-certificates + +ENV GIN_MODE=release +ENV DB_PATH=/data/hestia.db +ENV STATIC_DIR=/app/web +ENV PORT=8080 + +# Create a dedicated non-root user at UID/GID 1000, matching the default +# first non-root user on most single-user Linux hosts (mirrors the "node" +# user convention the old Next.js image relied on). If your host user has a +# different UID, either run `chown -R 1000:1000 ./data` once, or set +# `user: ":"` in docker-compose.yml to match your host user +# instead. +RUN addgroup -g 1000 hestia && adduser -D -u 1000 -G hestia hestia \ + && mkdir -p /data && chown hestia:hestia /data + +COPY --from=backend /out/hestia /app/hestia +COPY --from=frontend --chown=hestia:hestia /app/frontend/dist /app/web + +USER hestia VOLUME ["/data"] -EXPOSE 3000 +EXPOSE 8080 -CMD ["node", "server.js"] +CMD ["/app/hestia"] diff --git a/README.md b/README.md index 51e3658..6b968f8 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,13 @@ architecture decisions. ## Tech stack -- [Next.js](https://nextjs.org) (App Router, TypeScript) +- [Go](https://go.dev) with [Gin](https://gin-gonic.com) and + [GORM](https://gorm.io) for the backend API +- [React](https://react.dev) 19 + [Vite](https://vite.dev) + TypeScript for + the frontend - [Tailwind CSS](https://tailwindcss.com) -- [SQLite](https://sqlite.org) via [Drizzle ORM](https://orm.drizzle.team) — - one file, no separate database server to run +- [SQLite](https://sqlite.org) via GORM's `glebarez/sqlite` driver (pure Go, + no cgo) — one file, no separate database server to run - Docker, built for both amd64 and arm64 (Raspberry Pi / ARM NAS friendly) ## Running it (Docker) @@ -46,7 +49,7 @@ cp .env.example .env # set AUTH_SECRET, and TZ to your household's timezone docker compose up -d ``` -The app will be available at `http://localhost:3000`. The SQLite database +The app will be available at `http://localhost:8080`. The SQLite database lives in `./data/hestia.db` on the host, via a bind-mounted volume — back up that one file to back up your whole household's data. @@ -59,45 +62,36 @@ flip to the next day at UTC midnight instead of local midnight. Hestia works out of the box behind Caddy, Traefik, Nginx Proxy Manager, or similar, as long as the proxy forwards the original `Host` header (all three -do this by default) — session cookies and Next.js's Server Action origin -checks rely on it matching the hostname your browser actually used. - -**Serving from a domain or subdomain root** (e.g. +do this by default). Serving from a domain or subdomain root (e.g. `https://hestia.example.com`) needs no extra configuration — just proxy to -the container's port 3000. +the container's port 8080. -**Serving from a subpath** (e.g. `https://home.example.com/hestia/`) needs -`BASE_PATH` set in `.env` to match the proxy's path prefix, e.g.: +Serving Hestia from a reverse-proxy subpath (e.g. +`https://home.example.com/hestia/`) isn't supported yet — proxy from a +dedicated (sub)domain instead. -```bash -BASE_PATH=/hestia -``` +## Developing locally -Example Caddy config for a subpath: +Run the backend and frontend as two separate processes: +```bash +cd backend && cp ../.env.example ../.env # or export AUTH_SECRET/DB_PATH/TZ directly +go run ./cmd/api ``` -home.example.com { - handle_path /hestia/* { - reverse_proxy localhost:3000 - } -} -``` - -## Developing locally ```bash +cd frontend npm install -cp .env.example .env npm run dev ``` -Database migrations run automatically on startup (see -`src/instrumentation.ts`). After changing `src/db/schema.ts`, generate a new -migration with: +The Vite dev server proxies `/api` requests to the Go backend (see +`frontend/vite.config.ts`), so open the frontend's dev URL (typically +`http://localhost:5173`) rather than the backend's port directly. -```bash -npm run db:generate -``` +Database migrations run automatically on startup via GORM's `AutoMigrate`. +After changing `backend/internal/models/models.go`, just restart the +backend — no separate migration-generation step needed. ## License diff --git a/audit-ci.json b/audit-ci.json deleted file mode 100644 index 1093189..0000000 --- a/audit-ci.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/IBM/audit-ci/main/docs/schema.json", - "high": true, - "allowlist": [] -} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..42c46c0 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +hestia +*.db +*.db-* diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..25d9c28 --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,87 @@ +// Command api is Hestia's HTTP server entrypoint: loads config, opens the +// database, runs migrations, wires up the router and realtime hub, and +// starts listening. +package main + +import ( + "log" + "net/http" + "path/filepath" + "strings" + + "github.com/gin-contrib/static" + "github.com/gin-gonic/gin" + + "hestia/backend/internal/api/handlers" + "hestia/backend/internal/api/routes" + "hestia/backend/internal/config" + "hestia/backend/internal/database" + "hestia/backend/internal/realtime" + "hestia/backend/internal/services" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + db, err := database.Open(cfg.DBPath) + if err != nil { + log.Fatalf("database: %v", err) + } + + hub := realtime.NewHub() + go hub.Run() + + authService := services.NewAuthService(cfg.AuthSecret) + + deps := &handlers.Deps{ + Auth: authService, + Household: services.NewHouseholdService(db), + Member: services.NewMemberService(db), + Chore: services.NewChoreService(db), + Reminder: services.NewReminderService(db), + Reward: services.NewRewardService(db), + HHAuth: services.NewHouseholdAuthService(db), + Hub: hub, + Production: cfg.Production, + } + + if cfg.Production { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.Default() + routes.Register(router, deps, db, authService) + + if cfg.StaticDir != "" { + serveStatic(router, cfg.StaticDir) + } + + log.Printf("hestia backend listening on :%s (db: %s)", cfg.Port, cfg.DBPath) + if err := router.Run(":" + cfg.Port); err != nil { + log.Fatalf("server: %v", err) + } +} + +// serveStatic mounts the built frontend (from dir, e.g. frontend/dist) onto +// router: real files (JS/CSS/images/etc.) are served directly, and any other +// GET request that isn't under /api falls back to index.html so client-side +// routes handled by react-router (e.g. /chores) work on a hard refresh. +func serveStatic(router *gin.Engine, dir string) { + router.Use(static.Serve("/", static.LocalFile(dir, false))) + + indexPath := filepath.Join(dir, "index.html") + router.NoRoute(func(c *gin.Context) { + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + c.Status(http.StatusNotFound) + return + } + if strings.HasPrefix(c.Request.URL.Path, "/api/") { + c.Status(http.StatusNotFound) + return + } + c.File(indexPath) + }) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..b473f27 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,55 @@ +module hestia/backend + +go 1.26.6 + +require ( + github.com/gin-contrib/static v1.1.6 + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.55.0 + gorm.io/gorm v1.31.2 +) + +require ( + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.2 // indirect + github.com/bytedance/sonic/loader v0.5.2 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/glebarez/go-sqlite v1.23.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.61.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.2 // indirect + go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect + golang.org/x/arch v0.30.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + modernc.org/libc v1.75.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.0 // indirect + modernc.org/sqlite v1.56.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..ee3ac39 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,156 @@ +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= +github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-contrib/static v1.1.6 h1:4/OIJI9PxO2jsUezNulpVbzI8ORMmdPlJ4P9QGwWgME= +github.com/gin-contrib/static v1.1.6/go.mod h1:e9qkj8wAlsxE6mSFGVL/flqGfVibw5amjNEUa4idmHc= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI= +github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= +github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= +go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU= +golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.3 h1:vCqT5+R0jPXMnvMkGo0T2zXvFNth+lYXVCx5X7CCX/g= +modernc.org/libc v1.75.3/go.mod h1:MjAX68G+0oufI+hNuh0QXcK+Ap+sL8bNPPcIC6EqOfo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU= +modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go new file mode 100644 index 0000000..f1177d1 --- /dev/null +++ b/backend/internal/api/handlers/auth.go @@ -0,0 +1,159 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "hestia/backend/internal/api/middleware" + "hestia/backend/internal/services" +) + +type signupRequest struct { + HouseholdName string `json:"householdName" binding:"required"` + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func (d *Deps) Signup(c *gin.Context) { + var req signupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "all fields are required"}) + return + } + if len(req.Password) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": "password must be at least 8 characters"}) + return + } + + household, user, err := d.HHAuth.Signup(req.HouseholdName, req.Name, req.Email, req.Password) + if err != nil { + if errors.Is(err, services.ErrEmailTaken) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "signup failed"}) + return + } + + if err := d.setSessionCookie(c, household.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"}) + return + } + if err := d.setProfileCookie(c, household.ID, user.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"household": household, "user": user}) +} + +type loginRequest struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func (d *Deps) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) + return + } + + user, err := d.HHAuth.Login(req.Email, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"}) + return + } + + if err := d.setSessionCookie(c, user.HouseholdID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"}) + return + } + if err := d.setProfileCookie(c, user.HouseholdID, user.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) +} + +func (d *Deps) Logout(c *gin.Context) { + d.clearAuthCookies(c) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ListProfiles returns every profile in the household, for the +// avatar-picker screen. Requires only a household session, not an active +// profile. +func (d *Deps) ListProfiles(c *gin.Context) { + household := middleware.CurrentHousehold(c) + users, err := d.Member.List(household.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list profiles"}) + return + } + c.JSON(http.StatusOK, gin.H{"profiles": users}) +} + +type switchProfileRequest struct { + PIN string `json:"pin"` +} + +// SwitchProfile selects a profile via the avatar picker (optionally +// gated by a PIN for admin-role profiles) and issues a profile cookie. +func (d *Deps) SwitchProfile(c *gin.Context) { + household := middleware.CurrentHousehold(c) + userID := c.Param("id") + + var req switchProfileRequest + _ = c.ShouldBindJSON(&req) + + user, err := d.HHAuth.SwitchProfile(household.ID, userID, req.PIN) + if err != nil { + switch { + case errors.Is(err, services.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) + case errors.Is(err, services.ErrIncorrectPIN): + c.JSON(http.StatusForbidden, gin.H{"error": "incorrect PIN"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "switch failed"}) + } + return + } + + if err := d.setProfileCookie(c, household.ID, user.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"}) + return + } + c.JSON(http.StatusOK, gin.H{"user": user}) +} + +// SwitchToPicker clears the active profile session, returning to the +// avatar picker without logging out of the household entirely. +func (d *Deps) SwitchToPicker(c *gin.Context) { + d.clearProfileCookie(c) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// Me reports the current session state: the household (always present, +// since this route requires a household session) and the active profile +// (nil if there's a household session but no profile has been picked yet). +// The frontend uses this on load to decide whether to route to /login, +// /switch-profile, or the app shell. +func (d *Deps) Me(c *gin.Context) { + household := middleware.CurrentHousehold(c) + + var user any + if token, err := c.Cookie(services.ProfileCookie); err == nil && token != "" { + if claims, verr := d.Auth.VerifyProfile(token); verr == nil && claims.HouseholdID == household.ID { + if u, gerr := d.Member.Get(household.ID, claims.UserID); gerr == nil { + user = u + } + } + } + + c.JSON(http.StatusOK, gin.H{"household": household, "user": user}) +} diff --git a/backend/internal/api/handlers/chores.go b/backend/internal/api/handlers/chores.go new file mode 100644 index 0000000..28cab22 --- /dev/null +++ b/backend/internal/api/handlers/chores.go @@ -0,0 +1,199 @@ +package handlers + +import ( + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "hestia/backend/internal/api/middleware" + "hestia/backend/internal/services" +) + +var validRecurrences = map[string]bool{ + "none": true, "daily": true, "weekly": true, "weekdays": true, "custom": true, +} + +func (d *Deps) ListChores(c *gin.Context) { + household := middleware.CurrentHousehold(c) + dueToday := c.Query("due") == "today" + + chores, err := d.Chore.List(household.ID, dueToday) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list chores"}) + return + } + c.JSON(http.StatusOK, gin.H{"chores": chores}) +} + +func (d *Deps) GetChore(c *gin.Context) { + household := middleware.CurrentHousehold(c) + chore, err := d.Chore.Get(household.ID, c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "chore not found"}) + return + } + c.JSON(http.StatusOK, chore) +} + +type choreRequest struct { + Title string `json:"title"` + Description *string `json:"description"` + Points int `json:"points"` + AssignedToUserID string `json:"assignedToUserId"` + Recurrence string `json:"recurrence"` + DueDate string `json:"dueDate"` // YYYY-MM-DD + RecurrenceDays []int `json:"recurrenceDays"` +} + +func (r *choreRequest) toInput() (services.ChoreInput, string) { + if r.Title == "" { + return services.ChoreInput{}, "title is required" + } + if r.AssignedToUserID == "" { + return services.ChoreInput{}, "choose who this is assigned to" + } + if r.Points < 0 { + return services.ChoreInput{}, "points must be zero or a positive number" + } + if r.Recurrence == "" { + r.Recurrence = "none" + } + if !validRecurrences[r.Recurrence] { + return services.ChoreInput{}, "invalid recurrence" + } + dueDate, err := time.ParseInLocation("2006-01-02", r.DueDate, time.Local) + if err != nil { + return services.ChoreInput{}, "choose a valid due date" + } + var recurrenceDays *string + if r.Recurrence == "custom" { + if len(r.RecurrenceDays) == 0 { + return services.ChoreInput{}, "pick at least one day for a custom schedule" + } + s := services.SerializeRecurrenceDays(r.RecurrenceDays) + recurrenceDays = &s + } + + return services.ChoreInput{ + Title: r.Title, + Description: r.Description, + Points: r.Points, + AssignedToUserID: r.AssignedToUserID, + Recurrence: r.Recurrence, + DueDate: dueDate, + RecurrenceDays: recurrenceDays, + }, "" +} + +func (d *Deps) CreateChore(c *gin.Context) { + household := middleware.CurrentHousehold(c) + + var req choreRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + input, errMsg := req.toInput() + if errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) + return + } + + chore, err := d.Chore.Create(household.ID, input) + if err != nil { + if errors.Is(err, services.ErrNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": "assignee not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create chore"}) + return + } + c.JSON(http.StatusCreated, chore) +} + +func (d *Deps) UpdateChore(c *gin.Context) { + household := middleware.CurrentHousehold(c) + + var req choreRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + input, errMsg := req.toInput() + if errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) + return + } + + chore, err := d.Chore.Update(household.ID, c.Param("id"), input) + if err != nil { + if errors.Is(err, services.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "chore not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update chore"}) + return + } + c.JSON(http.StatusOK, chore) +} + +func (d *Deps) DeleteChore(c *gin.Context) { + household := middleware.CurrentHousehold(c) + if err := d.Chore.Delete(household.ID, c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete chore"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (d *Deps) CompleteChore(c *gin.Context) { + household := middleware.CurrentHousehold(c) + user := middleware.CurrentUser(c) + choreID := c.Param("id") + + completion, err := d.Chore.Complete(household.ID, choreID, user.ID, user.Role) + if err != nil { + switch { + case errors.Is(err, services.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "chore not found"}) + case errors.Is(err, services.ErrForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": "this chore isn't assigned to you"}) + case errors.Is(err, services.ErrUnassigned): + c.JSON(http.StatusBadRequest, gin.H{"error": "chore has no assignee"}) + case errors.Is(err, services.ErrAlreadyDone): + c.JSON(http.StatusOK, gin.H{"ok": true, "alreadyDone": true}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete chore"}) + } + return + } + + d.broadcastEvent("chore.completed", map[string]any{"choreId": choreID, "userId": completion.UserID}) + c.JSON(http.StatusOK, completion) +} + +func (d *Deps) UncompleteChore(c *gin.Context) { + household := middleware.CurrentHousehold(c) + user := middleware.CurrentUser(c) + choreID := c.Param("id") + + err := d.Chore.Uncomplete(household.ID, choreID, user.ID, user.Role) + if err != nil { + switch { + case errors.Is(err, services.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "chore not found"}) + case errors.Is(err, services.ErrForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": "this chore isn't assigned to you"}) + case errors.Is(err, services.ErrUnassigned), errors.Is(err, services.ErrNotCompleted): + c.JSON(http.StatusOK, gin.H{"ok": true}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to uncomplete chore"}) + } + return + } + + d.broadcastEvent("chore.uncompleted", map[string]any{"choreId": choreID, "userId": user.ID}) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/internal/api/handlers/deps.go b/backend/internal/api/handlers/deps.go new file mode 100644 index 0000000..25b8ac8 --- /dev/null +++ b/backend/internal/api/handlers/deps.go @@ -0,0 +1,71 @@ +// Package handlers implements the HTTP handlers for Hestia's REST API. +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + + "hestia/backend/internal/realtime" + "hestia/backend/internal/services" +) + +// Deps bundles every dependency the handlers need: services, the auth +// service (for signing/verifying cookies), and the realtime hub. +type Deps struct { + Auth *services.AuthService + Household *services.HouseholdService + Member *services.MemberService + Chore *services.ChoreService + Reminder *services.ReminderService + Reward *services.RewardService + HHAuth *services.HouseholdAuthService + Hub *realtime.Hub + Production bool +} + +func (d *Deps) setSessionCookie(c *gin.Context, householdID string) error { + token, err := d.Auth.SignSession(householdID) + if err != nil { + return err + } + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(services.SessionCookie, token, services.MaxAgeSeconds, "/", "", d.Production, true) + return nil +} + +func (d *Deps) setProfileCookie(c *gin.Context, householdID, userID string) error { + token, err := d.Auth.SignProfile(householdID, userID) + if err != nil { + return err + } + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(services.ProfileCookie, token, services.MaxAgeSeconds, "/", "", d.Production, true) + return nil +} + +func (d *Deps) clearAuthCookies(c *gin.Context) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(services.SessionCookie, "", -1, "/", "", d.Production, true) + c.SetCookie(services.ProfileCookie, "", -1, "/", "", d.Production, true) +} + +func (d *Deps) clearProfileCookie(c *gin.Context) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(services.ProfileCookie, "", -1, "/", "", d.Production, true) +} + +// broadcastEvent fans out a small JSON event over the WS hub, e.g. +// {"type": "chore.completed", "choreId": "...", "userId": "..."}. +func (d *Deps) broadcastEvent(eventType string, fields map[string]any) { + payload := map[string]any{"type": eventType} + for k, v := range fields { + payload[k] = v + } + b, err := json.Marshal(payload) + if err != nil { + return + } + d.Hub.Broadcast(b) +} diff --git a/backend/internal/api/handlers/household.go b/backend/internal/api/handlers/household.go new file mode 100644 index 0000000..9fb5067 --- /dev/null +++ b/backend/internal/api/handlers/household.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "hestia/backend/internal/api/middleware" +) + +func (d *Deps) GetHousehold(c *gin.Context) { + household := middleware.CurrentHousehold(c) + c.JSON(http.StatusOK, household) +} + +type updateHouseholdRequest struct { + Name *string `json:"name"` + ThemePreference *string `json:"themePreference"` +} + +// UpdateHousehold handles rename and/or theme-preference changes in a +// single PATCH, mirroring renameHousehold + updateThemePreference from +// src/lib/actions/household.ts. +func (d *Deps) UpdateHousehold(c *gin.Context) { + household := middleware.CurrentHousehold(c) + + var req updateHouseholdRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + if req.Name != nil { + name := *req.Name + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "household name is required"}) + return + } + updated, err := d.Household.Rename(household.ID, name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "rename failed"}) + return + } + household = updated + } + + if req.ThemePreference != nil { + updated, err := d.Household.UpdateTheme(household.ID, *req.ThemePreference) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid theme preference"}) + return + } + household = updated + } + + c.JSON(http.StatusOK, household) +} diff --git a/backend/internal/api/handlers/members.go b/backend/internal/api/handlers/members.go new file mode 100644 index 0000000..2471d1e --- /dev/null +++ b/backend/internal/api/handlers/members.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "hestia/backend/internal/api/middleware" + "hestia/backend/internal/services" +) + +func (d *Deps) ListMembers(c *gin.Context) { + household := middleware.CurrentHousehold(c) + users, err := d.Member.List(household.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list members"}) + return + } + c.JSON(http.StatusOK, gin.H{"members": users}) +} + +func (d *Deps) GetMember(c *gin.Context) { + household := middleware.CurrentHousehold(c) + user, err := d.Member.Get(household.ID, c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) + return + } + c.JSON(http.StatusOK, user) +} + +type memberRequest struct { + Name string `json:"name"` + Role string `json:"role"` + AvatarEmoji string `json:"avatarEmoji"` + PIN string `json:"pin"` +} + +func (d *Deps) CreateMember(c *gin.Context) { + household := middleware.CurrentHousehold(c) + + var req memberRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + user, err := d.Member.Create(household.ID, services.MemberInput{ + Name: req.Name, Role: req.Role, AvatarEmoji: req.AvatarEmoji, PIN: req.PIN, + }) + if err != nil { + if errors.Is(err, services.ErrInvalidPIN) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create member"}) + return + } + c.JSON(http.StatusCreated, user) +} + +func (d *Deps) UpdateMember(c *gin.Context) { + household := middleware.CurrentHousehold(c) + + var req memberRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + user, err := d.Member.Update(household.ID, c.Param("id"), services.MemberInput{ + Name: req.Name, Role: req.Role, AvatarEmoji: req.AvatarEmoji, PIN: req.PIN, + }) + if err != nil { + switch { + case errors.Is(err, services.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) + case errors.Is(err, services.ErrInvalidPIN): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update member"}) + } + return + } + c.JSON(http.StatusOK, user) +} + +func (d *Deps) ClearMemberPIN(c *gin.Context) { + household := middleware.CurrentHousehold(c) + if err := d.Member.ClearPIN(household.ID, c.Param("id")); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (d *Deps) DeleteMember(c *gin.Context) { + household := middleware.CurrentHousehold(c) + err := d.Member.Delete(household.ID, c.Param("id")) + if err != nil { + switch { + case errors.Is(err, services.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) + case errors.Is(err, services.ErrMainAccountUndeletable): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete member"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/internal/api/handlers/misc.go b/backend/internal/api/handlers/misc.go new file mode 100644 index 0000000..e01c963 --- /dev/null +++ b/backend/internal/api/handlers/misc.go @@ -0,0 +1,18 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Health is a trivial liveness check. +func (d *Deps) Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// WS upgrades the connection to a WebSocket and registers it with the +// realtime hub. +func (d *Deps) WS(c *gin.Context) { + d.Hub.ServeWS(c.Writer, c.Request) +} diff --git a/backend/internal/api/handlers/reminders.go b/backend/internal/api/handlers/reminders.go new file mode 100644 index 0000000..e5d34b6 --- /dev/null +++ b/backend/internal/api/handlers/reminders.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "hestia/backend/internal/api/middleware" + "hestia/backend/internal/services" +) + +func (d *Deps) ListReminders(c *gin.Context) { + household := middleware.CurrentHousehold(c) + reminders, err := d.Reminder.List(household.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list reminders"}) + return + } + c.JSON(http.StatusOK, gin.H{"reminders": reminders}) +} + +type reminderRequest struct { + Title string `json:"title"` + Notes *string `json:"notes"` + DueAt string `json:"dueAt"` // YYYY-MM-DD, optional + AssignedToUserID *string `json:"assignedToUserId"` +} + +func (d *Deps) CreateReminder(c *gin.Context) { + household := middleware.CurrentHousehold(c) + user := middleware.CurrentUser(c) + + var req reminderRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) + return + } + + var dueAt *time.Time + if req.DueAt != "" { + parsed, err := time.ParseInLocation("2006-01-02", req.DueAt, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid due date"}) + return + } + dueAt = &parsed + } + + // Non-admins can only create reminders for themselves or the whole + // household — not assign work to someone else. + assignedToUserID := req.AssignedToUserID + if user.Role != "admin" && assignedToUserID != nil && *assignedToUserID != user.ID { + assignedToUserID = &user.ID + } + + reminder, err := d.Reminder.Create(household.ID, services.ReminderInput{ + Title: req.Title, Notes: req.Notes, DueAt: dueAt, AssignedToUserID: assignedToUserID, + }) + if err != nil { + if errors.Is(err, services.ErrNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": "assignee not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reminder"}) + return + } + c.JSON(http.StatusCreated, reminder) +} + +func (d *Deps) ToggleReminderDone(c *gin.Context) { + household := middleware.CurrentHousehold(c) + reminder, err := d.Reminder.ToggleDone(household.ID, c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "reminder not found"}) + return + } + c.JSON(http.StatusOK, reminder) +} + +func (d *Deps) DeleteReminder(c *gin.Context) { + household := middleware.CurrentHousehold(c) + user := middleware.CurrentUser(c) + + err := d.Reminder.Delete(household.ID, c.Param("id"), user.ID, user.Role) + if err != nil { + switch { + case errors.Is(err, services.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "reminder not found"}) + case errors.Is(err, services.ErrForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": "you can only delete your own reminders"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete reminder"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/internal/api/handlers/rewards.go b/backend/internal/api/handlers/rewards.go new file mode 100644 index 0000000..27b1c27 --- /dev/null +++ b/backend/internal/api/handlers/rewards.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "hestia/backend/internal/api/middleware" + "hestia/backend/internal/services" +) + +func (d *Deps) ListRewards(c *gin.Context) { + household := middleware.CurrentHousehold(c) + rewards, err := d.Reward.List(household.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rewards"}) + return + } + c.JSON(http.StatusOK, gin.H{"rewards": rewards}) +} + +type rewardRequest struct { + Title string `json:"title"` + Description *string `json:"description"` + PointCost int `json:"pointCost"` +} + +func (r *rewardRequest) toInput() (services.RewardInput, string) { + if r.Title == "" { + return services.RewardInput{}, "title is required" + } + if r.PointCost <= 0 { + return services.RewardInput{}, "point cost must be a positive number" + } + return services.RewardInput{Title: r.Title, Description: r.Description, PointCost: r.PointCost}, "" +} + +func (d *Deps) CreateReward(c *gin.Context) { + household := middleware.CurrentHousehold(c) + + var req rewardRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + input, errMsg := req.toInput() + if errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) + return + } + + reward, err := d.Reward.Create(household.ID, input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reward"}) + return + } + c.JSON(http.StatusCreated, reward) +} + +func (d *Deps) UpdateReward(c *gin.Context) { + household := middleware.CurrentHousehold(c) + + var req rewardRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + input, errMsg := req.toInput() + if errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) + return + } + + reward, err := d.Reward.Update(household.ID, c.Param("id"), input) + if err != nil { + if errors.Is(err, services.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "reward not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update reward"}) + return + } + c.JSON(http.StatusOK, reward) +} + +func (d *Deps) ToggleRewardActive(c *gin.Context) { + household := middleware.CurrentHousehold(c) + reward, err := d.Reward.ToggleActive(household.ID, c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "reward not found"}) + return + } + c.JSON(http.StatusOK, reward) +} + +func (d *Deps) DeleteReward(c *gin.Context) { + household := middleware.CurrentHousehold(c) + if err := d.Reward.Delete(household.ID, c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete reward"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (d *Deps) RedeemReward(c *gin.Context) { + household := middleware.CurrentHousehold(c) + user := middleware.CurrentUser(c) + rewardID := c.Param("id") + + redemption, err := d.Reward.Redeem(household.ID, rewardID, user.ID, user.Points) + if err != nil { + switch { + case errors.Is(err, services.ErrNotFound), errors.Is(err, services.ErrRewardUnavailable): + c.JSON(http.StatusBadRequest, gin.H{"error": "reward not available"}) + case errors.Is(err, services.ErrInsufficientPoints): + c.JSON(http.StatusBadRequest, gin.H{"error": "not enough points yet"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to redeem reward"}) + } + return + } + + d.broadcastEvent("reward.redeemed", map[string]any{"rewardId": rewardID, "userId": user.ID}) + c.JSON(http.StatusOK, redemption) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go new file mode 100644 index 0000000..61e57b0 --- /dev/null +++ b/backend/internal/api/middleware/auth.go @@ -0,0 +1,108 @@ +// Package middleware provides Gin middleware for the two-tier household +// and profile session model. +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "hestia/backend/internal/models" + "hestia/backend/internal/services" +) + +const ( + HouseholdKey = "household" + UserKey = "user" +) + +// RequireHousehold verifies the hestia_session cookie and loads the +// household into the Gin context. Aborts with 401 if missing/invalid. +func RequireHousehold(auth *services.AuthService, db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + token, err := c.Cookie(services.SessionCookie) + if err != nil || token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + claims, err := auth.VerifySession(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + + var household models.Household + if err := db.Where("id = ?", claims.HouseholdID).First(&household).Error; err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "household not found"}) + return + } + + c.Set(HouseholdKey, &household) + c.Next() + } +} + +// RequireProfile verifies the hestia_profile cookie (in addition to +// RequireHousehold, which must run first) and loads the active user +// profile into the Gin context. Aborts with 403 if missing/invalid. +func RequireProfile(auth *services.AuthService, db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + household := CurrentHousehold(c) + if household == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + + token, err := c.Cookie(services.ProfileCookie) + if err != nil || token == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "no active profile"}) + return + } + claims, err := auth.VerifyProfile(token) + if err != nil || claims.HouseholdID != household.ID { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid profile session"}) + return + } + + var user models.User + if err := db.Where("id = ?", claims.UserID).First(&user).Error; err != nil || user.HouseholdID != household.ID { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "profile not found"}) + return + } + + c.Set(UserKey, &user) + c.Next() + } +} + +// RequireAdmin aborts with 403 unless the active profile has the admin +// role. Must run after RequireProfile. +func RequireAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + user := CurrentUser(c) + if user == nil || user.Role != "admin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "this action requires an admin profile"}) + return + } + c.Next() + } +} + +func CurrentHousehold(c *gin.Context) *models.Household { + v, ok := c.Get(HouseholdKey) + if !ok { + return nil + } + h, _ := v.(*models.Household) + return h +} + +func CurrentUser(c *gin.Context) *models.User { + v, ok := c.Get(UserKey) + if !ok { + return nil + } + u, _ := v.(*models.User) + return u +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go new file mode 100644 index 0000000..64f93b7 --- /dev/null +++ b/backend/internal/api/routes/routes.go @@ -0,0 +1,90 @@ +// Package routes wires up the Gin router: middleware chains plus every +// resource's REST endpoints. +package routes + +import ( + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "hestia/backend/internal/api/handlers" + "hestia/backend/internal/api/middleware" + "hestia/backend/internal/services" +) + +// Register mounts every route onto router. +func Register(router *gin.Engine, d *handlers.Deps, db *gorm.DB, auth *services.AuthService) { + requireHousehold := middleware.RequireHousehold(auth, db) + requireProfile := middleware.RequireProfile(auth, db) + requireAdmin := middleware.RequireAdmin() + + router.GET("/api/health", d.Health) + router.GET("/api/ws", d.WS) + + api := router.Group("/api") + + authGroup := api.Group("/auth") + { + authGroup.POST("/signup", d.Signup) + authGroup.POST("/login", d.Login) + authGroup.POST("/logout", d.Logout) + authGroup.GET("/me", requireHousehold, d.Me) + } + + // Profiles: listing/switching only needs a household session, not an + // already-active profile (that's the point of the avatar picker). + profiles := api.Group("/profiles") + profiles.Use(requireHousehold) + { + profiles.GET("", d.ListProfiles) + profiles.POST("/:id/switch", d.SwitchProfile) + profiles.POST("/to-picker", d.SwitchToPicker) + } + + // Everything below requires both a household session and an active + // profile. + authed := api.Group("") + authed.Use(requireHousehold, requireProfile) + { + authed.GET("/household", d.GetHousehold) + authed.PATCH("/household", requireAdmin, d.UpdateHousehold) + + members := authed.Group("/members") + { + members.GET("", d.ListMembers) + members.GET("/:id", d.GetMember) + members.POST("", requireAdmin, d.CreateMember) + members.PATCH("/:id", requireAdmin, d.UpdateMember) + members.DELETE("/:id/pin", requireAdmin, d.ClearMemberPIN) + members.DELETE("/:id", requireAdmin, d.DeleteMember) + } + + chores := authed.Group("/chores") + { + chores.GET("", d.ListChores) + chores.GET("/:id", d.GetChore) + chores.POST("", requireAdmin, d.CreateChore) + chores.PATCH("/:id", requireAdmin, d.UpdateChore) + chores.DELETE("/:id", requireAdmin, d.DeleteChore) + chores.POST("/:id/complete", d.CompleteChore) + chores.POST("/:id/uncomplete", d.UncompleteChore) + } + + reminders := authed.Group("/reminders") + { + reminders.GET("", d.ListReminders) + reminders.POST("", d.CreateReminder) + reminders.PATCH("/:id/toggle", d.ToggleReminderDone) + reminders.DELETE("/:id", d.DeleteReminder) + } + + rewards := authed.Group("/rewards") + { + rewards.GET("", d.ListRewards) + rewards.POST("", requireAdmin, d.CreateReward) + rewards.PATCH("/:id", requireAdmin, d.UpdateReward) + rewards.PATCH("/:id/toggle", requireAdmin, d.ToggleRewardActive) + rewards.DELETE("/:id", requireAdmin, d.DeleteReward) + rewards.POST("/:id/redeem", d.RedeemReward) + } + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..9e55e95 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,52 @@ +// Package config loads Hestia's runtime configuration from environment +// variables. +package config + +import ( + "fmt" + "os" +) + +// Config holds all environment-derived settings for the API server. +type Config struct { + Port string + DBPath string + AuthSecret string + Production bool + StaticDir string +} + +// Load reads configuration from the environment. It fails fast if +// AUTH_SECRET is unset, matching the old Next.js app's behavior. +func Load() (*Config, error) { + authSecret := os.Getenv("AUTH_SECRET") + if authSecret == "" { + return nil, fmt.Errorf("AUTH_SECRET environment variable is not set") + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./data/hestia.db" + } + + production := os.Getenv("NODE_ENV") == "production" || os.Getenv("GIN_MODE") == "release" + + // StaticDir is optional and has no default: unset means "don't serve + // static files", which is what local dev wants when the frontend is + // served separately via `vite dev`. Set it (e.g. to the built frontend's + // dist directory) to have the Go server also serve the SPA. + staticDir := os.Getenv("STATIC_DIR") + + return &Config{ + Port: port, + DBPath: dbPath, + AuthSecret: authSecret, + Production: production, + StaticDir: staticDir, + }, nil +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..81afd14 --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,53 @@ +// Package database opens the GORM/SQLite connection and runs +// auto-migrations. Uses the pure-Go, cgo-free glebarez/sqlite driver so +// Docker builds stay easy on ARM (Raspberry Pi / NAS). +package database + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "hestia/backend/internal/models" +) + +// Open opens the SQLite database at path (creating parent directories as +// needed), applies WAL-mode pragmas for concurrent read/write safety, and +// runs GORM AutoMigrate against all known models. +func Open(path string) (*gorm.DB, error) { + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("creating db directory: %w", err) + } + } + + dsn := fmt.Sprintf( + "%s?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_cache_size=-64000", + path, + ) + + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + if err := db.AutoMigrate( + &models.Household{}, + &models.User{}, + &models.Chore{}, + &models.ChoreCompletion{}, + &models.Reminder{}, + &models.Reward{}, + &models.RewardRedemption{}, + ); err != nil { + return nil, fmt.Errorf("running auto-migration: %w", err) + } + + return db, nil +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..63c3656 --- /dev/null +++ b/backend/internal/models/models.go @@ -0,0 +1,122 @@ +// Package models defines the GORM data model for Hestia, ported from the +// original Drizzle/SQLite schema in src/db/schema.ts. +package models + +import ( + "encoding/json" + "time" +) + +// Household is the top-level tenant: one shared login, one shared kiosk +// screen, many member profiles. +type Household struct { + ID string `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + ThemePreference string `gorm:"not null;default:system" json:"themePreference"` // system | light | dark + CreatedAt time.Time `json:"createdAt"` + + Users []User `gorm:"foreignKey:HouseholdID;constraint:OnDelete:CASCADE" json:"-"` + Chores []Chore `gorm:"foreignKey:HouseholdID;constraint:OnDelete:CASCADE" json:"-"` + Reminders []Reminder `gorm:"foreignKey:HouseholdID;constraint:OnDelete:CASCADE" json:"-"` + Rewards []Reward `gorm:"foreignKey:HouseholdID;constraint:OnDelete:CASCADE" json:"-"` +} + +// User is a household member profile. Only the household's one main +// account (created at signup) has Email/PasswordHash set — that's the +// single shared login. Every other profile is switched into locally via +// the avatar picker; PinHash optionally gates switching into an "admin" +// role profile. PasswordHash/PinHash are never serialized to JSON. +type User struct { + ID string `gorm:"primaryKey" json:"id"` + HouseholdID string `gorm:"not null;index" json:"householdId"` + Name string `gorm:"not null" json:"name"` + AvatarEmoji string `gorm:"not null" json:"avatarEmoji"` + Role string `gorm:"not null;default:member" json:"role"` // admin | member + Email *string `gorm:"uniqueIndex" json:"email,omitempty"` + PasswordHash *string `json:"-"` + PinHash *string `json:"-"` + Points int `gorm:"not null;default:0" json:"points"` + CreatedAt time.Time `json:"createdAt"` +} + +// MarshalJSON adds a "hasPin" flag derived from PinHash (which itself +// stays excluded from the JSON output) so the avatar-picker UI can show a +// lock icon without ever seeing the hash. +func (u User) MarshalJSON() ([]byte, error) { + type alias User + return json.Marshal(struct { + alias + HasPIN bool `json:"hasPin"` + }{alias: alias(u), HasPIN: u.PinHash != nil}) +} + +// Chore is a task that can be one-time or recurring, optionally assigned +// to a specific member, and awards points on completion. +type Chore struct { + ID string `gorm:"primaryKey" json:"id"` + HouseholdID string `gorm:"not null;index" json:"householdId"` + Title string `gorm:"not null" json:"title"` + Description *string `json:"description,omitempty"` + Points int `gorm:"not null;default:0" json:"points"` + // For "none" (one-time chores), the date it's due. For recurring + // chores, the date recurrence starts from. + DueDate time.Time `gorm:"not null" json:"dueDate"` + Recurrence string `gorm:"not null;default:none" json:"recurrence"` // none | daily | weekly | weekdays | custom + // "custom" uses RecurrenceDays to hold a JSON array of weekday ints + // (0=Sun..6=Sat). + RecurrenceDays *string `json:"recurrenceDays,omitempty"` + AssignedToUserID *string `gorm:"index" json:"assignedToUserId,omitempty"` + IsActive bool `gorm:"not null;default:true" json:"isActive"` + CreatedAt time.Time `json:"createdAt"` + + // Computed, not persisted: whether this chore already has a completion + // recorded for today, and who completed it. Populated by ChoreService + // so the frontend can render "done today" state without a separate + // completions-list call. + CompletedToday bool `gorm:"-" json:"completedToday"` + CompletedByUserID *string `gorm:"-" json:"completedByUserId,omitempty"` +} + +// ChoreCompletion records one day's completion of a chore by a user, and +// how many points it awarded (frozen at completion time so later chore +// edits don't retroactively change history). +type ChoreCompletion struct { + ID string `gorm:"primaryKey" json:"id"` + ChoreID string `gorm:"not null;index" json:"choreId"` + UserID string `gorm:"not null;index" json:"userId"` + CompletedAt time.Time `gorm:"not null" json:"completedAt"` + PointsAwarded int `gorm:"not null;default:0" json:"pointsAwarded"` +} + +// Reminder is a simple to-do, optionally assigned to a member; unassigned +// reminders are visible to the whole household. +type Reminder struct { + ID string `gorm:"primaryKey" json:"id"` + HouseholdID string `gorm:"not null;index" json:"householdId"` + AssignedToUserID *string `gorm:"index" json:"assignedToUserId,omitempty"` + Title string `gorm:"not null" json:"title"` + Notes *string `json:"notes,omitempty"` + DueAt *time.Time `json:"dueAt,omitempty"` + IsDone bool `gorm:"not null;default:false" json:"isDone"` + CreatedAt time.Time `json:"createdAt"` +} + +// Reward is something a member can redeem points for. +type Reward struct { + ID string `gorm:"primaryKey" json:"id"` + HouseholdID string `gorm:"not null;index" json:"householdId"` + Title string `gorm:"not null" json:"title"` + Description *string `json:"description,omitempty"` + PointCost int `gorm:"not null" json:"pointCost"` + IsActive bool `gorm:"not null;default:true" json:"isActive"` + CreatedAt time.Time `json:"createdAt"` +} + +// RewardRedemption records one redemption of a reward by a user. +type RewardRedemption struct { + ID string `gorm:"primaryKey" json:"id"` + RewardID string `gorm:"not null;index" json:"rewardId"` + UserID string `gorm:"not null;index" json:"userId"` + PointsSpent int `gorm:"not null" json:"pointsSpent"` + RedeemedAt time.Time `gorm:"not null" json:"redeemedAt"` +} diff --git a/backend/internal/realtime/hub.go b/backend/internal/realtime/hub.go new file mode 100644 index 0000000..aca0a7c --- /dev/null +++ b/backend/internal/realtime/hub.go @@ -0,0 +1,154 @@ +// Package realtime provides a WebSocket hub/broadcast pattern so the +// frontend can invalidate its query cache when chores/rewards change. +package realtime + +import ( + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // Self-hosted app behind arbitrary reverse proxies — origin checking + // is left to the deployer's proxy config, not enforced here. + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Client is a single WebSocket connection registered with the Hub. +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte +} + +// Hub tracks connected clients and fans out broadcast messages to all of +// them. +type Hub struct { + mu sync.Mutex + clients map[*Client]bool + register chan *Client + unregister chan *Client + broadcast chan []byte +} + +// NewHub creates a Hub. Call Run in its own goroutine to start it. +func NewHub() *Hub { + return &Hub{ + clients: make(map[*Client]bool), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan []byte, 64), + } +} + +// Run is the hub's central event loop; run it in its own goroutine. +func (h *Hub) Run() { + for { + select { + case c := <-h.register: + h.mu.Lock() + h.clients[c] = true + h.mu.Unlock() + case c := <-h.unregister: + h.mu.Lock() + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + close(c.send) + } + h.mu.Unlock() + case msg := <-h.broadcast: + h.mu.Lock() + for c := range h.clients { + select { + case c.send <- msg: + default: + close(c.send) + delete(h.clients, c) + } + } + h.mu.Unlock() + } + } +} + +// Broadcast sends msg (typically JSON) to every connected client. +func (h *Hub) Broadcast(msg []byte) { + h.broadcast <- msg +} + +// ServeWS upgrades an HTTP request to a WebSocket connection and wires it +// into the hub. +func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("realtime: upgrade failed: %v", err) + return + } + + client := &Client{hub: h, conn: conn, send: make(chan []byte, 16)} + h.register <- client + + go client.writePump() + go client.readPump() +} + +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadLimit(4096) + _ = c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + _ = c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + // This is a broadcast-only channel from the server's + // perspective; we just need to drain incoming frames (pings, + // close) to detect disconnects. + if _, _, err := c.conn.ReadMessage(); err != nil { + break + } + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case msg, ok := <-c.send: + _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + _ = c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go new file mode 100644 index 0000000..16dd794 --- /dev/null +++ b/backend/internal/services/auth_service.go @@ -0,0 +1,108 @@ +// Package services holds Hestia's business logic, independent of the +// HTTP layer. +package services + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +const ( + SessionCookie = "hestia_session" + ProfileCookie = "hestia_profile" + MaxAgeSeconds = 60 * 60 * 24 * 30 // 30 days +) + +// AuthService signs and verifies the two-tier JWT session model: a +// household-level session (email/password login) and a separate +// profile-level session (avatar-picker selection). +type AuthService struct { + secret []byte +} + +func NewAuthService(secret string) *AuthService { + return &AuthService{secret: []byte(secret)} +} + +// SessionClaims is the household-level session payload. +type SessionClaims struct { + HouseholdID string `json:"householdId"` + jwt.RegisteredClaims +} + +// ProfileClaims is the profile-level session payload. +type ProfileClaims struct { + HouseholdID string `json:"householdId"` + UserID string `json:"userId"` + jwt.RegisteredClaims +} + +func (s *AuthService) expiry() *jwt.NumericDate { + return jwt.NewNumericDate(time.Now().Add(MaxAgeSeconds * time.Second)) +} + +// SignSession creates a household-session JWT. +func (s *AuthService) SignSession(householdID string) (string, error) { + claims := SessionClaims{ + HouseholdID: householdID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: s.expiry(), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret) +} + +// VerifySession parses and validates a household-session JWT. +func (s *AuthService) VerifySession(token string) (*SessionClaims, error) { + claims := &SessionClaims{} + parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { + return s.secret, nil + }) + if err != nil || !parsed.Valid { + return nil, errors.New("invalid session") + } + return claims, nil +} + +// SignProfile creates a profile-session JWT. +func (s *AuthService) SignProfile(householdID, userID string) (string, error) { + claims := ProfileClaims{ + HouseholdID: householdID, + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: s.expiry(), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret) +} + +// VerifyProfile parses and validates a profile-session JWT. +func (s *AuthService) VerifyProfile(token string) (*ProfileClaims, error) { + claims := &ProfileClaims{} + parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { + return s.secret, nil + }) + if err != nil || !parsed.Valid { + return nil, errors.New("invalid profile session") + } + return claims, nil +} + +// HashSecret bcrypt-hashes a password or PIN. +func HashSecret(secret string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(b), nil +} + +// VerifySecret checks a plaintext secret against a bcrypt hash. +func VerifySecret(secret, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) == nil +} diff --git a/backend/internal/services/chore_service.go b/backend/internal/services/chore_service.go new file mode 100644 index 0000000..80508a9 --- /dev/null +++ b/backend/internal/services/chore_service.go @@ -0,0 +1,258 @@ +package services + +import ( + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "hestia/backend/internal/models" +) + +var ( + ErrNotFound = errors.New("not found") + ErrForbidden = errors.New("forbidden") + ErrAlreadyDone = errors.New("already completed today") + ErrNotCompleted = errors.New("not completed today") + ErrUnassigned = errors.New("chore has no assignee") +) + +// ChoreService implements chore CRUD and completion/points logic, ported +// from src/lib/actions/chores.ts. +type ChoreService struct { + db *gorm.DB +} + +func NewChoreService(db *gorm.DB) *ChoreService { + return &ChoreService{db: db} +} + +type ChoreInput struct { + Title string + Description *string + Points int + AssignedToUserID string + Recurrence string + DueDate time.Time + RecurrenceDays *string +} + +// List returns all chores for a household, optionally filtered to only +// those due today. +func (s *ChoreService) List(householdID string, dueToday bool) ([]models.Chore, error) { + var chores []models.Chore + if err := s.db.Where("household_id = ?", householdID).Order("due_date asc").Find(&chores).Error; err != nil { + return nil, err + } + if dueToday { + out := make([]models.Chore, 0, len(chores)) + for _, c := range chores { + if !c.IsActive { + continue + } + if IsChoreDueOn(RecurrenceConfig{Recurrence: c.Recurrence, DueDate: c.DueDate, RecurrenceDays: c.RecurrenceDays}, time.Now()) { + out = append(out, c) + } + } + chores = out + } + if err := s.attachCompletionStatus(chores); err != nil { + return nil, err + } + return chores, nil +} + +func (s *ChoreService) Get(householdID, id string) (*models.Chore, error) { + var chore models.Chore + if err := s.db.Where("id = ? AND household_id = ?", id, householdID).First(&chore).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + withStatus := []models.Chore{chore} + if err := s.attachCompletionStatus(withStatus); err != nil { + return nil, err + } + return &withStatus[0], nil +} + +// attachCompletionStatus populates CompletedToday/CompletedByUserID on each +// chore by looking up today's ChoreCompletion rows in one query, so the +// frontend can render "done today" state without a separate endpoint. +func (s *ChoreService) attachCompletionStatus(chores []models.Chore) error { + if len(chores) == 0 { + return nil + } + ids := make([]string, len(chores)) + for i, c := range chores { + ids[i] = c.ID + } + start, end := todayRange() + var completions []models.ChoreCompletion + if err := s.db.Where("chore_id IN ? AND completed_at >= ? AND completed_at < ?", ids, start, end).Find(&completions).Error; err != nil { + return err + } + byChore := make(map[string]string, len(completions)) // choreID -> userID + for _, comp := range completions { + byChore[comp.ChoreID] = comp.UserID + } + for i := range chores { + if userID, ok := byChore[chores[i].ID]; ok { + chores[i].CompletedToday = true + u := userID + chores[i].CompletedByUserID = &u + } + } + return nil +} + +func (s *ChoreService) validateAssignee(householdID, userID string) error { + var count int64 + if err := s.db.Model(&models.User{}).Where("id = ? AND household_id = ?", userID, householdID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return ErrNotFound + } + return nil +} + +func (s *ChoreService) Create(householdID string, in ChoreInput) (*models.Chore, error) { + if err := s.validateAssignee(householdID, in.AssignedToUserID); err != nil { + return nil, err + } + assignee := in.AssignedToUserID + chore := models.Chore{ + ID: uuid.NewString(), + HouseholdID: householdID, + Title: in.Title, + Description: in.Description, + Points: in.Points, + DueDate: in.DueDate, + Recurrence: in.Recurrence, + RecurrenceDays: in.RecurrenceDays, + AssignedToUserID: &assignee, + IsActive: true, + } + if err := s.db.Create(&chore).Error; err != nil { + return nil, err + } + return &chore, nil +} + +func (s *ChoreService) Update(householdID, id string, in ChoreInput) (*models.Chore, error) { + existing, err := s.Get(householdID, id) + if err != nil { + return nil, err + } + if err := s.validateAssignee(householdID, in.AssignedToUserID); err != nil { + return nil, err + } + assignee := in.AssignedToUserID + existing.Title = in.Title + existing.Description = in.Description + existing.Points = in.Points + existing.DueDate = in.DueDate + existing.Recurrence = in.Recurrence + existing.RecurrenceDays = in.RecurrenceDays + existing.AssignedToUserID = &assignee + if err := s.db.Save(existing).Error; err != nil { + return nil, err + } + return existing, nil +} + +func (s *ChoreService) Delete(householdID, id string) error { + return s.db.Where("id = ? AND household_id = ?", id, householdID).Delete(&models.Chore{}).Error +} + +func todayRange() (time.Time, time.Time) { + now := time.Now() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 0, 1) + return start, end +} + +// Complete marks a chore done for today, awarding points to the assignee. +// Anyone can mark their own chore done; admins can mark on behalf of +// someone else too. Returns ErrAlreadyDone if already completed today +// (a no-op, not an error condition the caller needs to surface loudly). +func (s *ChoreService) Complete(householdID, choreID, actingUserID, actingRole string) (*models.ChoreCompletion, error) { + chore, err := s.Get(householdID, choreID) + if err != nil { + return nil, err + } + if chore.AssignedToUserID == nil { + return nil, ErrUnassigned + } + if *chore.AssignedToUserID != actingUserID && actingRole != "admin" { + return nil, ErrForbidden + } + + start, end := todayRange() + var existing models.ChoreCompletion + err = s.db.Where("chore_id = ? AND completed_at >= ? AND completed_at < ?", chore.ID, start, end).First(&existing).Error + if err == nil { + return nil, ErrAlreadyDone + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + assignedToUserID := *chore.AssignedToUserID + completion := models.ChoreCompletion{ + ID: uuid.NewString(), + ChoreID: chore.ID, + UserID: assignedToUserID, + CompletedAt: time.Now(), + PointsAwarded: chore.Points, + } + + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&completion).Error; err != nil { + return err + } + return tx.Model(&models.User{}).Where("id = ?", assignedToUserID). + Update("points", gorm.Expr("points + ?", chore.Points)).Error + }) + if err != nil { + return nil, err + } + return &completion, nil +} + +// Uncomplete reverts today's completion of a chore, deducting the points +// that were awarded. +func (s *ChoreService) Uncomplete(householdID, choreID, actingUserID, actingRole string) error { + chore, err := s.Get(householdID, choreID) + if err != nil { + return err + } + if chore.AssignedToUserID == nil { + return ErrUnassigned + } + if *chore.AssignedToUserID != actingUserID && actingRole != "admin" { + return ErrForbidden + } + + start, end := todayRange() + var completion models.ChoreCompletion + err = s.db.Where("chore_id = ? AND completed_at >= ? AND completed_at < ?", chore.ID, start, end).First(&completion).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotCompleted + } + if err != nil { + return err + } + + assignedToUserID := *chore.AssignedToUserID + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Delete(&completion).Error; err != nil { + return err + } + return tx.Model(&models.User{}).Where("id = ?", assignedToUserID). + Update("points", gorm.Expr("points - ?", completion.PointsAwarded)).Error + }) +} diff --git a/backend/internal/services/household_auth_service.go b/backend/internal/services/household_auth_service.go new file mode 100644 index 0000000..3b86741 --- /dev/null +++ b/backend/internal/services/household_auth_service.go @@ -0,0 +1,106 @@ +package services + +import ( + "errors" + "strings" + + "github.com/google/uuid" + "gorm.io/gorm" + + "hestia/backend/internal/models" +) + +var ( + ErrEmailTaken = errors.New("an account with that email already exists") + ErrInvalidCredentials = errors.New("invalid email or password") + ErrIncorrectPIN = errors.New("incorrect PIN") +) + +// HouseholdAuthService implements signup/login/profile-switch, ported +// from src/lib/actions/auth.ts and src/lib/actions/profile.ts. +type HouseholdAuthService struct { + db *gorm.DB +} + +func NewHouseholdAuthService(db *gorm.DB) *HouseholdAuthService { + return &HouseholdAuthService{db: db} +} + +// Signup creates a brand-new household plus its one admin/login profile. +func (s *HouseholdAuthService) Signup(householdName, name, email, password string) (*models.Household, *models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + + var count int64 + if err := s.db.Model(&models.User{}).Where("email = ?", email).Count(&count).Error; err != nil { + return nil, nil, err + } + if count > 0 { + return nil, nil, ErrEmailTaken + } + + passwordHash, err := HashSecret(password) + if err != nil { + return nil, nil, err + } + + household := models.Household{ID: uuid.NewString(), Name: householdName, ThemePreference: "system"} + user := models.User{ + ID: uuid.NewString(), + HouseholdID: household.ID, + Name: name, + Role: "admin", + AvatarEmoji: defaultAvatar, + Email: &email, + PasswordHash: &passwordHash, + } + + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&household).Error; err != nil { + return err + } + return tx.Create(&user).Error + }) + if err != nil { + return nil, nil, err + } + return &household, &user, nil +} + +// Login validates household-account credentials. +func (s *HouseholdAuthService) Login(email, password string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + + var user models.User + err := s.db.Where("email = ?", email).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrInvalidCredentials + } + if err != nil { + return nil, err + } + if user.PasswordHash == nil || !VerifySecret(password, *user.PasswordHash) { + return nil, ErrInvalidCredentials + } + return &user, nil +} + +// SwitchProfile validates a profile switch (with optional PIN check for +// PIN-gated profiles) within the given household. +func (s *HouseholdAuthService) SwitchProfile(householdID, userID, pin string) (*models.User, error) { + var user models.User + if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + if user.HouseholdID != householdID { + return nil, ErrNotFound + } + if user.PinHash != nil { + if pin == "" || !VerifySecret(pin, *user.PinHash) { + return nil, ErrIncorrectPIN + } + } + return &user, nil +} diff --git a/backend/internal/services/household_service.go b/backend/internal/services/household_service.go new file mode 100644 index 0000000..76b0b83 --- /dev/null +++ b/backend/internal/services/household_service.go @@ -0,0 +1,47 @@ +package services + +import ( + "errors" + + "gorm.io/gorm" + + "hestia/backend/internal/models" +) + +type HouseholdService struct { + db *gorm.DB +} + +func NewHouseholdService(db *gorm.DB) *HouseholdService { + return &HouseholdService{db: db} +} + +func (s *HouseholdService) Get(id string) (*models.Household, error) { + var h models.Household + if err := s.db.Where("id = ?", id).First(&h).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &h, nil +} + +func (s *HouseholdService) Rename(id, name string) (*models.Household, error) { + if err := s.db.Model(&models.Household{}).Where("id = ?", id).Update("name", name).Error; err != nil { + return nil, err + } + return s.Get(id) +} + +var validThemes = map[string]bool{"system": true, "light": true, "dark": true} + +func (s *HouseholdService) UpdateTheme(id, theme string) (*models.Household, error) { + if !validThemes[theme] { + return nil, errors.New("invalid theme preference") + } + if err := s.db.Model(&models.Household{}).Where("id = ?", id).Update("theme_preference", theme).Error; err != nil { + return nil, err + } + return s.Get(id) +} diff --git a/backend/internal/services/member_service.go b/backend/internal/services/member_service.go new file mode 100644 index 0000000..6c2c653 --- /dev/null +++ b/backend/internal/services/member_service.go @@ -0,0 +1,147 @@ +package services + +import ( + "errors" + "regexp" + + "github.com/google/uuid" + "gorm.io/gorm" + + "hestia/backend/internal/models" +) + +const defaultAvatar = "🙂" + +var pinPattern = regexp.MustCompile(`^\d{4,6}$`) + +var ErrInvalidPIN = errors.New("PIN must be 4-6 digits") +var ErrMainAccountUndeletable = errors.New("the main household account can't be deleted") + +// MemberService implements profile CRUD, ported from +// src/lib/actions/members.ts. +type MemberService struct { + db *gorm.DB +} + +func NewMemberService(db *gorm.DB) *MemberService { + return &MemberService{db: db} +} + +func (s *MemberService) List(householdID string) ([]models.User, error) { + var users []models.User + if err := s.db.Where("household_id = ?", householdID).Order("created_at asc").Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + +func (s *MemberService) Get(householdID, id string) (*models.User, error) { + var u models.User + if err := s.db.Where("id = ? AND household_id = ?", id, householdID).First(&u).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &u, nil +} + +type MemberInput struct { + Name string + Role string + AvatarEmoji string + PIN string // empty = leave unchanged on update, or no PIN on create +} + +func (s *MemberService) Create(householdID string, in MemberInput) (*models.User, error) { + if in.PIN != "" && !pinPattern.MatchString(in.PIN) { + return nil, ErrInvalidPIN + } + role := "member" + if in.Role == "admin" { + role = "admin" + } + avatar := in.AvatarEmoji + if avatar == "" { + avatar = defaultAvatar + } + + var pinHash *string + if in.PIN != "" { + h, err := HashSecret(in.PIN) + if err != nil { + return nil, err + } + pinHash = &h + } + + user := models.User{ + ID: uuid.NewString(), + HouseholdID: householdID, + Name: in.Name, + Role: role, + AvatarEmoji: avatar, + PinHash: pinHash, + } + if err := s.db.Create(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (s *MemberService) Update(householdID, id string, in MemberInput) (*models.User, error) { + target, err := s.Get(householdID, id) + if err != nil { + return nil, err + } + if in.PIN != "" && !pinPattern.MatchString(in.PIN) { + return nil, ErrInvalidPIN + } + + role := "member" + if in.Role == "admin" { + role = "admin" + } + avatar := in.AvatarEmoji + if avatar == "" { + avatar = defaultAvatar + } + + target.Name = in.Name + target.Role = role + target.AvatarEmoji = avatar + if in.PIN != "" { + h, err := HashSecret(in.PIN) + if err != nil { + return nil, err + } + target.PinHash = &h + } + + if err := s.db.Save(target).Error; err != nil { + return nil, err + } + return target, nil +} + +func (s *MemberService) ClearPIN(householdID, id string) error { + target, err := s.Get(householdID, id) + if err != nil { + return err + } + return s.db.Model(target).Update("pin_hash", nil).Error +} + +// Delete removes a profile. The main login account (email/passwordHash +// set) can't be deleted — it's how anyone gets into this household at +// all. +func (s *MemberService) Delete(householdID, id string) error { + target, err := s.Get(householdID, id) + if err != nil { + return err + } + if target.PasswordHash != nil { + return ErrMainAccountUndeletable + } + return s.db.Delete(target).Error +} diff --git a/backend/internal/services/recurrence.go b/backend/internal/services/recurrence.go new file mode 100644 index 0000000..c75fa8c --- /dev/null +++ b/backend/internal/services/recurrence.go @@ -0,0 +1,147 @@ +package services + +import ( + "encoding/json" + "sort" + "time" +) + +// RecurrenceConfig mirrors the fields needed to compute due-dates, ported +// from src/lib/chores/recurrence.ts. +type RecurrenceConfig struct { + Recurrence string // none | daily | weekly | weekdays | custom + DueDate time.Time + RecurrenceDays *string // JSON array of weekday ints (0=Sun..6=Sat) +} + +func startOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +// ParseRecurrenceDays decodes the JSON weekday-int array, silently +// dropping anything malformed or out of range (0-6), matching the +// permissive behavior of the original TS implementation. +func ParseRecurrenceDays(raw *string) []int { + if raw == nil || *raw == "" { + return []int{} + } + var parsed []int + if err := json.Unmarshal([]byte(*raw), &parsed); err != nil { + return []int{} + } + out := make([]int, 0, len(parsed)) + for _, d := range parsed { + if d >= 0 && d <= 6 { + out = append(out, d) + } + } + return out +} + +// SerializeRecurrenceDays encodes a de-duplicated, sorted weekday-int +// array as JSON. +func SerializeRecurrenceDays(days []int) string { + set := make(map[int]bool) + for _, d := range days { + set[d] = true + } + unique := make([]int, 0, len(set)) + for d := range set { + unique = append(unique, d) + } + sort.Ints(unique) + b, _ := json.Marshal(unique) + return string(b) +} + +// IsChoreDueOn reports whether chore is due on the given date. A one-time +// chore ("none") is due only on its exact due date. Recurring chores +// never come due before their anchor date (DueDate doubles as the +// recurrence start), and weekly recurrence repeats on the anchor's +// weekday. +func IsChoreDueOn(chore RecurrenceConfig, date time.Time) bool { + target := startOfDay(date) + anchor := startOfDay(chore.DueDate) + + if chore.Recurrence == "none" { + return target.Equal(anchor) + } + + if target.Before(anchor) { + return false + } + + switch chore.Recurrence { + case "daily": + return true + case "weekly": + return target.Weekday() == anchor.Weekday() + case "weekdays": + wd := target.Weekday() + return wd >= time.Monday && wd <= time.Friday + case "custom": + for _, d := range ParseRecurrenceDays(chore.RecurrenceDays) { + if time.Weekday(d) == target.Weekday() { + return true + } + } + return false + default: + return false + } +} + +// IsChoreDueToday reports whether chore is due today (local time). +func IsChoreDueToday(chore RecurrenceConfig) bool { + return IsChoreDueOn(chore, time.Now()) +} + +var weekdayLabels = []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + +// DescribeRecurrence returns a short human-readable description of a +// chore's recurrence, mirroring describeRecurrence from the TS source. +func DescribeRecurrence(chore RecurrenceConfig) string { + switch chore.Recurrence { + case "none": + return "One-time (" + chore.DueDate.Format("1/2/2006") + ")" + case "daily": + return "Daily" + case "weekly": + return "Weekly (" + weekdayLabels[int(chore.DueDate.Weekday())] + ")" + case "weekdays": + return "Weekdays" + case "custom": + days := ParseRecurrenceDays(chore.RecurrenceDays) + labels := make([]string, len(days)) + for i, d := range days { + labels[i] = weekdayLabels[d] + } + out := "" + for i, l := range labels { + if i > 0 { + out += ", " + } + out += l + } + return "Custom (" + out + ")" + default: + return chore.Recurrence + } +} + +// DueDatesInRange returns every date in [start, end] (inclusive) that the +// chore is due on — used to plot a chore across a calendar month. +func DueDatesInRange(chore RecurrenceConfig, start, end time.Time) []time.Time { + var dates []time.Time + cursor := startOfDay(start) + last := startOfDay(end) + + for !cursor.After(last) { + if IsChoreDueOn(chore, cursor) { + dates = append(dates, cursor) + } + cursor = cursor.AddDate(0, 0, 1) + } + + return dates +} diff --git a/backend/internal/services/reminder_service.go b/backend/internal/services/reminder_service.go new file mode 100644 index 0000000..5b15bb0 --- /dev/null +++ b/backend/internal/services/reminder_service.go @@ -0,0 +1,103 @@ +package services + +import ( + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "hestia/backend/internal/models" +) + +// ReminderService implements reminder CRUD, ported from +// src/lib/actions/reminders.ts. +type ReminderService struct { + db *gorm.DB +} + +func NewReminderService(db *gorm.DB) *ReminderService { + return &ReminderService{db: db} +} + +func (s *ReminderService) List(householdID string) ([]models.Reminder, error) { + var reminders []models.Reminder + if err := s.db.Where("household_id = ?", householdID).Order("created_at desc").Find(&reminders).Error; err != nil { + return nil, err + } + return reminders, nil +} + +func (s *ReminderService) Get(householdID, id string) (*models.Reminder, error) { + var r models.Reminder + if err := s.db.Where("id = ? AND household_id = ?", id, householdID).First(&r).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &r, nil +} + +type ReminderInput struct { + Title string + Notes *string + DueAt *time.Time + AssignedToUserID *string +} + +// Create inserts a reminder. Non-admins can only create reminders for +// themselves or the whole household (assignedToUserID == nil) — not +// assign work to someone else; the caller is expected to have already +// coerced AssignedToUserID accordingly for non-admin actors (mirroring +// the TS action's silent-downgrade behavior), but we re-validate the +// assignee exists regardless. +func (s *ReminderService) Create(householdID string, in ReminderInput) (*models.Reminder, error) { + if in.AssignedToUserID != nil { + var count int64 + if err := s.db.Model(&models.User{}).Where("id = ? AND household_id = ?", *in.AssignedToUserID, householdID).Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, ErrNotFound + } + } + + reminder := models.Reminder{ + ID: uuid.NewString(), + HouseholdID: householdID, + Title: in.Title, + Notes: in.Notes, + DueAt: in.DueAt, + AssignedToUserID: in.AssignedToUserID, + } + if err := s.db.Create(&reminder).Error; err != nil { + return nil, err + } + return &reminder, nil +} + +func (s *ReminderService) ToggleDone(householdID, id string) (*models.Reminder, error) { + r, err := s.Get(householdID, id) + if err != nil { + return nil, err + } + r.IsDone = !r.IsDone + if err := s.db.Save(r).Error; err != nil { + return nil, err + } + return r, nil +} + +// Delete removes a reminder. Only an admin or the reminder's own assignee +// may delete it. +func (s *ReminderService) Delete(householdID, id, actingUserID, actingRole string) error { + r, err := s.Get(householdID, id) + if err != nil { + return err + } + if actingRole != "admin" && (r.AssignedToUserID == nil || *r.AssignedToUserID != actingUserID) { + return ErrForbidden + } + return s.db.Delete(r).Error +} diff --git a/backend/internal/services/reward_service.go b/backend/internal/services/reward_service.go new file mode 100644 index 0000000..3487116 --- /dev/null +++ b/backend/internal/services/reward_service.go @@ -0,0 +1,126 @@ +package services + +import ( + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" + + "hestia/backend/internal/models" +) + +var ErrRewardUnavailable = errors.New("reward not available") +var ErrInsufficientPoints = errors.New("not enough points yet") + +// RewardService implements reward CRUD and redemption, ported from +// src/lib/actions/rewards.ts. +type RewardService struct { + db *gorm.DB +} + +func NewRewardService(db *gorm.DB) *RewardService { + return &RewardService{db: db} +} + +func (s *RewardService) List(householdID string) ([]models.Reward, error) { + var rewards []models.Reward + if err := s.db.Where("household_id = ?", householdID).Order("created_at asc").Find(&rewards).Error; err != nil { + return nil, err + } + return rewards, nil +} + +func (s *RewardService) Get(householdID, id string) (*models.Reward, error) { + var r models.Reward + if err := s.db.Where("id = ? AND household_id = ?", id, householdID).First(&r).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &r, nil +} + +type RewardInput struct { + Title string + Description *string + PointCost int +} + +func (s *RewardService) Create(householdID string, in RewardInput) (*models.Reward, error) { + reward := models.Reward{ + ID: uuid.NewString(), + HouseholdID: householdID, + Title: in.Title, + Description: in.Description, + PointCost: in.PointCost, + IsActive: true, + } + if err := s.db.Create(&reward).Error; err != nil { + return nil, err + } + return &reward, nil +} + +func (s *RewardService) Update(householdID, id string, in RewardInput) (*models.Reward, error) { + existing, err := s.Get(householdID, id) + if err != nil { + return nil, err + } + existing.Title = in.Title + existing.Description = in.Description + existing.PointCost = in.PointCost + if err := s.db.Save(existing).Error; err != nil { + return nil, err + } + return existing, nil +} + +func (s *RewardService) ToggleActive(householdID, id string) (*models.Reward, error) { + existing, err := s.Get(householdID, id) + if err != nil { + return nil, err + } + existing.IsActive = !existing.IsActive + if err := s.db.Save(existing).Error; err != nil { + return nil, err + } + return existing, nil +} + +func (s *RewardService) Delete(householdID, id string) error { + return s.db.Where("id = ? AND household_id = ?", id, householdID).Delete(&models.Reward{}).Error +} + +// Redeem spends a user's points on a reward, recording the redemption. +func (s *RewardService) Redeem(householdID, rewardID, userID string, userPoints int) (*models.RewardRedemption, error) { + reward, err := s.Get(householdID, rewardID) + if err != nil { + return nil, err + } + if !reward.IsActive { + return nil, ErrRewardUnavailable + } + if userPoints < reward.PointCost { + return nil, ErrInsufficientPoints + } + + redemption := models.RewardRedemption{ + ID: uuid.NewString(), + RewardID: reward.ID, + UserID: userID, + PointsSpent: reward.PointCost, + } + + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&redemption).Error; err != nil { + return err + } + return tx.Model(&models.User{}).Where("id = ?", userID). + Update("points", gorm.Expr("points - ?", reward.PointCost)).Error + }) + if err != nil { + return nil, err + } + return &redemption, nil +} diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 6643ca8..0000000 --- a/codecov.yml +++ /dev/null @@ -1,71 +0,0 @@ -# ============================================================================= -# Codecov Configuration -# Require 85% coverage (project + patch) per the project's coverage mandate. -# Keep this ignore list in sync with vitest.config.ts's coverage.exclude. -# ============================================================================= - -coverage: - status: - project: - default: - target: 85% - threshold: 1% - patch: - default: - # New/changed code must hit 85% coverage — this is the merge-blocking - # gate; see .claude/commands/fix-patch-coverage.md for closing gaps - # before pushing. - target: 85% - threshold: 1% - -require_ci_to_pass: yes - -comment: - require_changes: false - require_base: false - require_head: true - layout: "reach, diff, flags, files" - behavior: default - -ignore: - # Test files and harness - - "**/*.test.ts" - - "**/*.test.tsx" - - "src/test/**" - - "vitest.config.ts" - - "playwright.config.ts" - - "e2e/**" - - # Route entrypoints (Server Component pages/layouts): thin composition + - # data-fetching wired straight into requireActiveUser() and the db — - # exercised by Playwright e2e (e2e/household-flow.spec.ts) rather than - # unit tests. - - "src/app/**/page.tsx" - - "src/app/**/layout.tsx" - - # Declarative schema (no branching logic) and process bootstrap. - - "src/db/schema.ts" - - "src/db/index.ts" - - "src/instrumentation.ts" - - # Documentation, CI/CD & config - - "docs/**" - - "*.md" - - ".github/**" - - "scripts/**" - - "*.yml" - - "*.yaml" - - "*.json" - - "**/tailwind.config.*" - - "**/postcss.config.*" - - "**/eslint.config.*" - - "**/tsconfig*.json" - - "**/*.d.ts" - - # Build artifacts & dependencies - - "node_modules/**" - - ".next/**" - - "coverage/**" - - "playwright-report/**" - - "test-results/**" - - "data/**" diff --git a/docker-compose.yml b/docker-compose.yml index 1df9a4d..e791961 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,18 +5,15 @@ services: container_name: hestia restart: unless-stopped ports: - - "3000:3000" + - "8080:8080" volumes: - ./data:/data environment: - DATABASE_PATH: /data/hestia.db + DB_PATH: /data/hestia.db AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET in .env — see .env.example} # Chore due-dates and "due today" are computed from the container's - # local clock (see src/lib/chores/recurrence.ts) — without this, - # everything runs on UTC regardless of where the household actually - # is, and chores flip to the next day at UTC midnight instead of - # local midnight. + # local clock (Go's time.Local, backed by the tzdata package in the + # image) — without this, everything runs on UTC regardless of where + # the household actually is, and chores flip to the next day at UTC + # midnight instead of local midnight. TZ: ${TZ:-UTC} - # Only needed behind a reverse proxy serving Hestia from a subpath — - # see BASE_PATH in .env.example. - BASE_PATH: ${BASE_PATH:-} diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index fbc5a12..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Config } from "drizzle-kit"; - -export default { - schema: "./src/db/schema.ts", - out: "./drizzle", - dialect: "sqlite", - dbCredentials: { - url: process.env.DATABASE_PATH ?? "./data/hestia.db", - }, -} satisfies Config; diff --git a/drizzle/0000_curved_storm.sql b/drizzle/0000_curved_storm.sql deleted file mode 100644 index 023126d..0000000 --- a/drizzle/0000_curved_storm.sql +++ /dev/null @@ -1,48 +0,0 @@ -CREATE TABLE `chore_completions` ( - `id` text PRIMARY KEY NOT NULL, - `chore_id` text NOT NULL, - `user_id` text NOT NULL, - `completed_at` integer DEFAULT (unixepoch()) NOT NULL, - `points_awarded` integer DEFAULT 0 NOT NULL, - FOREIGN KEY (`chore_id`) REFERENCES `chores`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `chores` ( - `id` text PRIMARY KEY NOT NULL, - `household_id` text NOT NULL, - `title` text NOT NULL, - `description` text, - `points` integer DEFAULT 0 NOT NULL, - `recurrence` text DEFAULT 'none' NOT NULL, - `recurrence_days` text, - `assigned_to_user_id` text, - `is_active` integer DEFAULT true NOT NULL, - `created_at` integer DEFAULT (unixepoch()) NOT NULL, - FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`assigned_to_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null -); ---> statement-breakpoint -CREATE TABLE `households` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `invite_code` text NOT NULL, - `created_at` integer DEFAULT (unixepoch()) NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `households_invite_code_unique` ON `households` (`invite_code`);--> statement-breakpoint -CREATE TABLE `users` ( - `id` text PRIMARY KEY NOT NULL, - `household_id` text NOT NULL, - `name` text NOT NULL, - `avatar_emoji` text DEFAULT '🙂' NOT NULL, - `role` text DEFAULT 'member' NOT NULL, - `email` text, - `password_hash` text, - `pin_hash` text, - `points` integer DEFAULT 0 NOT NULL, - `created_at` integer DEFAULT (unixepoch()) NOT NULL, - FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`); \ No newline at end of file diff --git a/drizzle/0001_bumpy_big_bertha.sql b/drizzle/0001_bumpy_big_bertha.sql deleted file mode 100644 index f17c8cc..0000000 --- a/drizzle/0001_bumpy_big_bertha.sql +++ /dev/null @@ -1,37 +0,0 @@ -CREATE TABLE `reminders` ( - `id` text PRIMARY KEY NOT NULL, - `household_id` text NOT NULL, - `assigned_to_user_id` text, - `title` text NOT NULL, - `notes` text, - `due_at` integer, - `is_done` integer DEFAULT false NOT NULL, - `created_at` integer DEFAULT (unixepoch()) NOT NULL, - FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`assigned_to_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null -); ---> statement-breakpoint -CREATE TABLE `reward_redemptions` ( - `id` text PRIMARY KEY NOT NULL, - `reward_id` text NOT NULL, - `user_id` text NOT NULL, - `points_spent` integer NOT NULL, - `redeemed_at` integer DEFAULT (unixepoch()) NOT NULL, - FOREIGN KEY (`reward_id`) REFERENCES `rewards`(`id`) ON UPDATE no action ON DELETE cascade, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `rewards` ( - `id` text PRIMARY KEY NOT NULL, - `household_id` text NOT NULL, - `title` text NOT NULL, - `description` text, - `point_cost` integer NOT NULL, - `is_active` integer DEFAULT true NOT NULL, - `created_at` integer DEFAULT (unixepoch()) NOT NULL, - FOREIGN KEY (`household_id`) REFERENCES `households`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -DROP INDEX `households_invite_code_unique`;--> statement-breakpoint -ALTER TABLE `households` DROP COLUMN `invite_code`;--> statement-breakpoint -ALTER TABLE `chores` ADD `due_date` integer DEFAULT (unixepoch()) NOT NULL; \ No newline at end of file diff --git a/drizzle/0002_wide_blizzard.sql b/drizzle/0002_wide_blizzard.sql deleted file mode 100644 index 264519e..0000000 --- a/drizzle/0002_wide_blizzard.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `households` ADD `theme_preference` text DEFAULT 'system' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json deleted file mode 100644 index b565e10..0000000 --- a/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,356 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "6e12944d-a854-4b8b-aeb2-a0c2d849b7b8", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "chore_completions": { - "name": "chore_completions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "chore_id": { - "name": "chore_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "completed_at": { - "name": "completed_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - }, - "points_awarded": { - "name": "points_awarded", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "chore_completions_chore_id_chores_id_fk": { - "name": "chore_completions_chore_id_chores_id_fk", - "tableFrom": "chore_completions", - "tableTo": "chores", - "columnsFrom": [ - "chore_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "chore_completions_user_id_users_id_fk": { - "name": "chore_completions_user_id_users_id_fk", - "tableFrom": "chore_completions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "chores": { - "name": "chores", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "points": { - "name": "points", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "recurrence": { - "name": "recurrence", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "recurrence_days": { - "name": "recurrence_days", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assigned_to_user_id": { - "name": "assigned_to_user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_active": { - "name": "is_active", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "chores_household_id_households_id_fk": { - "name": "chores_household_id_households_id_fk", - "tableFrom": "chores", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "chores_assigned_to_user_id_users_id_fk": { - "name": "chores_assigned_to_user_id_users_id_fk", - "tableFrom": "chores", - "tableTo": "users", - "columnsFrom": [ - "assigned_to_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "households": { - "name": "households", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "invite_code": { - "name": "invite_code", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": { - "households_invite_code_unique": { - "name": "households_invite_code_unique", - "columns": [ - "invite_code" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "avatar_emoji": { - "name": "avatar_emoji", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'🙂'" - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'member'" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "password_hash": { - "name": "password_hash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pin_hash": { - "name": "pin_hash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "points": { - "name": "points", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": { - "users_email_unique": { - "name": "users_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": { - "users_household_id_households_id_fk": { - "name": "users_household_id_households_id_fk", - "tableFrom": "users", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 2e491e7..0000000 --- a/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,592 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "b912dc36-ee13-4db6-96f4-d12f28612c36", - "prevId": "6e12944d-a854-4b8b-aeb2-a0c2d849b7b8", - "tables": { - "chore_completions": { - "name": "chore_completions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "chore_id": { - "name": "chore_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "completed_at": { - "name": "completed_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - }, - "points_awarded": { - "name": "points_awarded", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "chore_completions_chore_id_chores_id_fk": { - "name": "chore_completions_chore_id_chores_id_fk", - "tableFrom": "chore_completions", - "tableTo": "chores", - "columnsFrom": [ - "chore_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "chore_completions_user_id_users_id_fk": { - "name": "chore_completions_user_id_users_id_fk", - "tableFrom": "chore_completions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "chores": { - "name": "chores", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "points": { - "name": "points", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "due_date": { - "name": "due_date", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - }, - "recurrence": { - "name": "recurrence", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "recurrence_days": { - "name": "recurrence_days", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assigned_to_user_id": { - "name": "assigned_to_user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_active": { - "name": "is_active", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "chores_household_id_households_id_fk": { - "name": "chores_household_id_households_id_fk", - "tableFrom": "chores", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "chores_assigned_to_user_id_users_id_fk": { - "name": "chores_assigned_to_user_id_users_id_fk", - "tableFrom": "chores", - "tableTo": "users", - "columnsFrom": [ - "assigned_to_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "households": { - "name": "households", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "reminders": { - "name": "reminders", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "assigned_to_user_id": { - "name": "assigned_to_user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "notes": { - "name": "notes", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "due_at": { - "name": "due_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_done": { - "name": "is_done", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "reminders_household_id_households_id_fk": { - "name": "reminders_household_id_households_id_fk", - "tableFrom": "reminders", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "reminders_assigned_to_user_id_users_id_fk": { - "name": "reminders_assigned_to_user_id_users_id_fk", - "tableFrom": "reminders", - "tableTo": "users", - "columnsFrom": [ - "assigned_to_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "reward_redemptions": { - "name": "reward_redemptions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "reward_id": { - "name": "reward_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "points_spent": { - "name": "points_spent", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "redeemed_at": { - "name": "redeemed_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "reward_redemptions_reward_id_rewards_id_fk": { - "name": "reward_redemptions_reward_id_rewards_id_fk", - "tableFrom": "reward_redemptions", - "tableTo": "rewards", - "columnsFrom": [ - "reward_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "reward_redemptions_user_id_users_id_fk": { - "name": "reward_redemptions_user_id_users_id_fk", - "tableFrom": "reward_redemptions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "rewards": { - "name": "rewards", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "point_cost": { - "name": "point_cost", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_active": { - "name": "is_active", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "rewards_household_id_households_id_fk": { - "name": "rewards_household_id_households_id_fk", - "tableFrom": "rewards", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "avatar_emoji": { - "name": "avatar_emoji", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'🙂'" - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'member'" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "password_hash": { - "name": "password_hash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pin_hash": { - "name": "pin_hash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "points": { - "name": "points", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": { - "users_email_unique": { - "name": "users_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": { - "users_household_id_households_id_fk": { - "name": "users_household_id_households_id_fk", - "tableFrom": "users", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 6b0214b..0000000 --- a/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,600 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "41f6c196-4f5d-4ec0-ad74-35bf9f9259ce", - "prevId": "b912dc36-ee13-4db6-96f4-d12f28612c36", - "tables": { - "chore_completions": { - "name": "chore_completions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "chore_id": { - "name": "chore_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "completed_at": { - "name": "completed_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - }, - "points_awarded": { - "name": "points_awarded", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "chore_completions_chore_id_chores_id_fk": { - "name": "chore_completions_chore_id_chores_id_fk", - "tableFrom": "chore_completions", - "tableTo": "chores", - "columnsFrom": [ - "chore_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "chore_completions_user_id_users_id_fk": { - "name": "chore_completions_user_id_users_id_fk", - "tableFrom": "chore_completions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "chores": { - "name": "chores", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "points": { - "name": "points", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "due_date": { - "name": "due_date", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - }, - "recurrence": { - "name": "recurrence", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "recurrence_days": { - "name": "recurrence_days", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assigned_to_user_id": { - "name": "assigned_to_user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_active": { - "name": "is_active", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "chores_household_id_households_id_fk": { - "name": "chores_household_id_households_id_fk", - "tableFrom": "chores", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "chores_assigned_to_user_id_users_id_fk": { - "name": "chores_assigned_to_user_id_users_id_fk", - "tableFrom": "chores", - "tableTo": "users", - "columnsFrom": [ - "assigned_to_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "households": { - "name": "households", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "theme_preference": { - "name": "theme_preference", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'system'" - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "reminders": { - "name": "reminders", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "assigned_to_user_id": { - "name": "assigned_to_user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "notes": { - "name": "notes", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "due_at": { - "name": "due_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_done": { - "name": "is_done", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "reminders_household_id_households_id_fk": { - "name": "reminders_household_id_households_id_fk", - "tableFrom": "reminders", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "reminders_assigned_to_user_id_users_id_fk": { - "name": "reminders_assigned_to_user_id_users_id_fk", - "tableFrom": "reminders", - "tableTo": "users", - "columnsFrom": [ - "assigned_to_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "reward_redemptions": { - "name": "reward_redemptions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "reward_id": { - "name": "reward_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "points_spent": { - "name": "points_spent", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "redeemed_at": { - "name": "redeemed_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "reward_redemptions_reward_id_rewards_id_fk": { - "name": "reward_redemptions_reward_id_rewards_id_fk", - "tableFrom": "reward_redemptions", - "tableTo": "rewards", - "columnsFrom": [ - "reward_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "reward_redemptions_user_id_users_id_fk": { - "name": "reward_redemptions_user_id_users_id_fk", - "tableFrom": "reward_redemptions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "rewards": { - "name": "rewards", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "point_cost": { - "name": "point_cost", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_active": { - "name": "is_active", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": {}, - "foreignKeys": { - "rewards_household_id_households_id_fk": { - "name": "rewards_household_id_households_id_fk", - "tableFrom": "rewards", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "household_id": { - "name": "household_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "avatar_emoji": { - "name": "avatar_emoji", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'🙂'" - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'member'" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "password_hash": { - "name": "password_hash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pin_hash": { - "name": "pin_hash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "points": { - "name": "points", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(unixepoch())" - } - }, - "indexes": { - "users_email_unique": { - "name": "users_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": { - "users_household_id_households_id_fk": { - "name": "users_household_id_households_id_fk", - "tableFrom": "users", - "tableTo": "households", - "columnsFrom": [ - "household_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json deleted file mode 100644 index cb7404c..0000000 --- a/drizzle/meta/_journal.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1786220930093, - "tag": "0000_curved_storm", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1786225285383, - "tag": "0001_bumpy_big_bertha", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1786326364660, - "tag": "0002_wide_blizzard", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/e2e/household-flow.spec.ts b/e2e/household-flow.spec.ts deleted file mode 100644 index bbe24f4..0000000 --- a/e2e/household-flow.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { expect, test } from "@playwright/test"; - -// One long journey through the whole app rather than many small isolated -// tests — each phase depends on state the previous phase created (a -// household, a member, a chore), so splitting it up would mean re-deriving -// that state repeatedly for little isolation benefit. Split further once -// there's enough surface area that failure localization actually matters. -test("household signup through chores, rewards, and reminders", async ({ - page, -}) => { - const email = `admin-${randomUUID()}@example.com`; - - await test.step("sign up a new household", async () => { - await page.goto("/signup"); - await page.getByLabel("Household name").fill("The Testfields"); - await page.getByLabel("Your name").fill("Admin Parent"); - await page.getByLabel("Email").fill(email); - await page.getByLabel("Password").fill("supersecret123"); - await page.getByRole("button", { name: "Create household" }).click(); - await page.waitForURL("/"); - await expect(page.getByRole("heading", { name: /Hi, .*Admin Parent/ })).toBeVisible(); - }); - - await test.step("rename the household", async () => { - await page.goto("/household"); - await page.getByRole("button", { name: "Edit" }).first().click(); - const renameForm = page.locator("form", { hasText: "Save" }); - await renameForm.locator('input[name="name"]').fill("The Testfields (fixed typo)"); - await renameForm.getByRole("button", { name: "Save" }).click(); - await expect( - page.getByRole("heading", { name: "The Testfields (fixed typo)" }), - ).toBeVisible(); - // The header nav also shows the household name — confirms - // revalidatePath("/") actually propagated the rename. - await expect(page.locator("header")).toContainText( - "The Testfields (fixed typo)", - ); - }); - - await test.step("add a kid profile and a PIN-protected admin profile", async () => { - await page.goto("/household"); - const addForm = page.locator("form", { hasText: "Add member" }); - - await addForm.getByPlaceholder("Name").fill("Junior"); - await addForm.getByRole("button", { name: "Add member" }).click(); - await expect(page.getByText("Junior")).toBeVisible(); - - await addForm.getByPlaceholder("Name").fill("Co-Parent"); - await addForm.locator('select[name="role"]').selectOption("admin"); - await addForm.getByPlaceholder("PIN (optional)").fill("4321"); - await addForm.getByRole("button", { name: "Add member" }).click(); - await expect(page.getByText("Co-Parent")).toBeVisible(); - }); - - await test.step("create a daily chore assigned to Junior", async () => { - await page.goto("/chores"); - const choreForm = page.locator("form", { hasText: "Add chore" }); - - await choreForm.getByPlaceholder("Title").fill("Clean room"); - const assigneeOptions = await choreForm - .locator('select[name="assignedToUserId"] option') - .allTextContents(); - const juniorOption = assigneeOptions.find((o) => o.includes("Junior")); - expect(juniorOption).toBeTruthy(); - await choreForm - .locator('select[name="assignedToUserId"]') - .selectOption({ label: juniorOption! }); - await choreForm.getByPlaceholder("Points").fill("10"); - await choreForm.locator('select[name="recurrence"]').selectOption("daily"); - await choreForm.getByRole("button", { name: "Add chore" }).click(); - await expect(page.getByText("Clean room").first()).toBeVisible(); - }); - - await test.step("switch to Junior (no PIN) and complete the chore", async () => { - await page.goto("/switch-profile"); - await page.getByRole("button", { name: "Junior" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); - await page.waitForURL("/"); - - await page.getByRole("button", { name: "Done" }).click(); - await expect(page.getByRole("button", { name: "Undo" })).toBeVisible(); - await expect(page.locator("header")).toContainText("10 pts"); - }); - - await test.step("switch to Co-Parent: wrong PIN rejected, right PIN accepted", async () => { - await page.goto("/switch-profile"); - await page.getByRole("button", { name: "Co-Parent" }).click(); - await page.getByPlaceholder("PIN").fill("0000"); - await page.getByRole("button", { name: "Continue" }).click(); - await expect(page.getByText("Incorrect PIN")).toBeVisible(); - - await page.getByPlaceholder("PIN").fill("4321"); - await page.getByRole("button", { name: "Continue" }).click(); - await page.waitForURL("/"); - await expect(page.getByRole("heading", { name: /Hi, .*Co-Parent/ })).toBeVisible(); - }); - - await test.step("create a reward as Co-Parent", async () => { - await page.goto("/rewards"); - const rewardForm = page.locator("form", { hasText: "Add reward" }); - await rewardForm.getByPlaceholder("Title").fill("Extra screen time"); - await rewardForm.getByPlaceholder("Point cost").fill("5"); - await rewardForm.getByRole("button", { name: "Add reward" }).click(); - await expect(page.getByText("Extra screen time").first()).toBeVisible(); - }); - - await test.step("switch back to Junior and redeem the reward", async () => { - await page.goto("/switch-profile"); - await page.getByRole("button", { name: "Junior" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); - await page.waitForURL("/"); - - await page.goto("/rewards"); - await expect(page.getByText("You have 10 pts")).toBeVisible(); - await page.getByRole("button", { name: "Redeem" }).click(); - await expect(page.getByText("You have 5 pts")).toBeVisible(); - }); - - await test.step("calendar shows the chore on today's date", async () => { - await page.goto("/calendar"); - await expect(page.getByText("Clean room").first()).toBeVisible(); - }); - - await test.step("reminders: create and toggle done", async () => { - await page.goto("/reminders"); - const reminderForm = page.locator("form", { hasText: "Add reminder" }); - await reminderForm.getByPlaceholder("Title").fill("Take out trash"); - await reminderForm.getByRole("button", { name: "Add reminder" }).click(); - await expect(page.getByText("Take out trash")).toBeVisible(); - - await page.getByRole("button", { name: "Mark done" }).click(); - await expect(page.getByText("Take out trash")).toHaveClass(/line-through/); - }); -}); diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index c8da353..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; - -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - "coverage/**", - // Claude Code worktree scratch space (full repo copies, incl. their - // own .next build output) — not part of this project's source. - ".claude/**", - ]), -]); - -export default eslintConfig; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..40cd782 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +dist-ssr +*.local diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..cd303ba --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..12521ed --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Hestia + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f02cee1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3383 @@ +{ + "name": "hestia-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hestia-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/postcss": "^4.3.3", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", + "globals": "^17.11.0", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.407", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", + "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz", + "integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..75eae89 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "hestia-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/postcss": "^4.3.3", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", + "globals": "^17.11.0", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.1" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..78ac186 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,130 @@ +import { useEffect } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router"; +import { AuthProvider, useAuth } from "@/context/AuthContext"; +import { useRealtime } from "@/hooks/useRealtime"; +import AppShell from "@/components/AppShell"; +import LoginPage from "@/pages/LoginPage"; +import SignupPage from "@/pages/SignupPage"; +import SwitchProfilePage from "@/pages/SwitchProfilePage"; +import HubPage from "@/pages/HubPage"; +import ChoresPage from "@/pages/ChoresPage"; +import CalendarPage from "@/pages/CalendarPage"; +import HouseholdPage from "@/pages/HouseholdPage"; +import RemindersPage from "@/pages/RemindersPage"; +import RewardsPage from "@/pages/RewardsPage"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10_000, + retry: 1, + }, + }, +}); + +function ThemeEffect() { + const { household } = useAuth(); + useEffect(() => { + const pref = household?.themePreference ?? "system"; + if (pref === "system") { + document.documentElement.removeAttribute("data-theme"); + } else { + document.documentElement.setAttribute("data-theme", pref); + } + }, [household?.themePreference]); + return null; +} + +function FullScreenMessage({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function PublicRoute({ children }: { children: React.ReactNode }) { + const { status } = useAuth(); + if (status === "loading") return Loading…; + if (status === "authed") return ; + if (status === "need-profile") return ; + return <>{children}; +} + +function NeedProfileRoute({ children }: { children: React.ReactNode }) { + const { status } = useAuth(); + if (status === "loading") return Loading…; + if (status === "unauthenticated") return ; + if (status === "authed") return ; + return <>{children}; +} + +function RequireAuth({ children }: { children: React.ReactNode }) { + const { status } = useAuth(); + useRealtime(status === "authed"); + + if (status === "loading") return Loading…; + if (status === "unauthenticated") return ; + if (status === "need-profile") return ; + return <>{children}; +} + +function AppRoutes() { + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} + +export default function App() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..1392e91 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,32 @@ +import { api } from "./client"; +import type { Household, Profile } from "@/types"; + +export type SignupInput = { + householdName: string; + name: string; + email: string; + password: string; +}; + +export type SignupResponse = { household: Household; user: Profile }; + +export function signup(input: SignupInput) { + return api.post("/auth/signup", input); +} + +export type LoginInput = { email: string; password: string }; +export type LoginResponse = { user: Profile }; + +export function login(input: LoginInput) { + return api.post("/auth/login", input); +} + +export function logout() { + return api.post<{ ok: boolean }>("/auth/logout"); +} + +export type MeResponse = { household: Household; user: Profile | null }; + +export function me() { + return api.get("/auth/me"); +} diff --git a/frontend/src/api/chores.ts b/frontend/src/api/chores.ts new file mode 100644 index 0000000..e5d475a --- /dev/null +++ b/frontend/src/api/chores.ts @@ -0,0 +1,40 @@ +import { api } from "./client"; +import type { Chore, ChoreCompletion, Recurrence } from "@/types"; + +export function listChores(dueToday?: boolean) { + return api.get<{ chores: Chore[] }>(`/chores${dueToday ? "?due=today" : ""}`); +} + +export function getChore(id: string) { + return api.get(`/chores/${id}`); +} + +export type ChoreInput = { + title: string; + description?: string | null; + points: number; + assignedToUserId: string; + recurrence: Recurrence; + dueDate: string; // YYYY-MM-DD + recurrenceDays?: number[]; +}; + +export function createChore(input: ChoreInput) { + return api.post("/chores", input); +} + +export function updateChore(id: string, input: ChoreInput) { + return api.patch(`/chores/${id}`, input); +} + +export function deleteChore(id: string) { + return api.delete<{ ok: boolean }>(`/chores/${id}`); +} + +export function completeChore(id: string) { + return api.post(`/chores/${id}/complete`); +} + +export function uncompleteChore(id: string) { + return api.post<{ ok: boolean }>(`/chores/${id}/uncomplete`); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..606818f --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,43 @@ +// Shared fetch wrapper for the Hestia API. All requests go through the +// Vite dev proxy (or the same origin in production) with cookie auth. +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api${path}`, { + credentials: "include", + headers: init?.body + ? { "Content-Type": "application/json", ...init.headers } + : init?.headers, + ...init, + }); + + if (!res.ok) { + let message = res.statusText; + try { + const body = await res.json(); + if (body?.error) message = body.error; + } catch { + // ignore non-JSON error bodies + } + throw new ApiError(res.status, message); + } + + if (res.status === 204) return undefined as T; + const text = await res.text(); + return text ? (JSON.parse(text) as T) : (undefined as T); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: "POST", body: body ? JSON.stringify(body) : undefined }), + patch: (path: string, body?: unknown) => + request(path, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }), + delete: (path: string) => request(path, { method: "DELETE" }), +}; diff --git a/frontend/src/api/household.ts b/frontend/src/api/household.ts new file mode 100644 index 0000000..e2f04b3 --- /dev/null +++ b/frontend/src/api/household.ts @@ -0,0 +1,10 @@ +import { api } from "./client"; +import type { Household, ThemePreference } from "@/types"; + +export function getHousehold() { + return api.get("/household"); +} + +export function updateHousehold(input: { name?: string; themePreference?: ThemePreference }) { + return api.patch("/household", input); +} diff --git a/frontend/src/api/members.ts b/frontend/src/api/members.ts new file mode 100644 index 0000000..0d3941c --- /dev/null +++ b/frontend/src/api/members.ts @@ -0,0 +1,33 @@ +import { api } from "./client"; +import type { Profile, Role } from "@/types"; + +export function listMembers() { + return api.get<{ members: Profile[] }>("/members"); +} + +export function getMember(id: string) { + return api.get(`/members/${id}`); +} + +export type MemberInput = { + name: string; + role: Role; + avatarEmoji: string; + pin?: string; +}; + +export function createMember(input: MemberInput) { + return api.post("/members", input); +} + +export function updateMember(id: string, input: MemberInput) { + return api.patch(`/members/${id}`, input); +} + +export function clearMemberPin(id: string) { + return api.delete<{ ok: boolean }>(`/members/${id}/pin`); +} + +export function deleteMember(id: string) { + return api.delete<{ ok: boolean }>(`/members/${id}`); +} diff --git a/frontend/src/api/profiles.ts b/frontend/src/api/profiles.ts new file mode 100644 index 0000000..e0f0863 --- /dev/null +++ b/frontend/src/api/profiles.ts @@ -0,0 +1,14 @@ +import { api } from "./client"; +import type { Profile } from "@/types"; + +export function listProfiles() { + return api.get<{ profiles: Profile[] }>("/profiles"); +} + +export function switchProfile(userId: string, pin?: string) { + return api.post<{ user: Profile }>(`/profiles/${userId}/switch`, { pin: pin ?? "" }); +} + +export function switchToPicker() { + return api.post<{ ok: boolean }>("/profiles/to-picker"); +} diff --git a/frontend/src/api/reminders.ts b/frontend/src/api/reminders.ts new file mode 100644 index 0000000..0fd5e2d --- /dev/null +++ b/frontend/src/api/reminders.ts @@ -0,0 +1,25 @@ +import { api } from "./client"; +import type { Reminder } from "@/types"; + +export function listReminders() { + return api.get<{ reminders: Reminder[] }>("/reminders"); +} + +export type ReminderInput = { + title: string; + notes?: string | null; + dueAt?: string; // YYYY-MM-DD + assignedToUserId?: string | null; +}; + +export function createReminder(input: ReminderInput) { + return api.post("/reminders", input); +} + +export function toggleReminderDone(id: string) { + return api.patch(`/reminders/${id}/toggle`); +} + +export function deleteReminder(id: string) { + return api.delete<{ ok: boolean }>(`/reminders/${id}`); +} diff --git a/frontend/src/api/rewards.ts b/frontend/src/api/rewards.ts new file mode 100644 index 0000000..88460a7 --- /dev/null +++ b/frontend/src/api/rewards.ts @@ -0,0 +1,32 @@ +import { api } from "./client"; +import type { Reward, RewardRedemption } from "@/types"; + +export function listRewards() { + return api.get<{ rewards: Reward[] }>("/rewards"); +} + +export type RewardInput = { + title: string; + description?: string | null; + pointCost: number; +}; + +export function createReward(input: RewardInput) { + return api.post("/rewards", input); +} + +export function updateReward(id: string, input: RewardInput) { + return api.patch(`/rewards/${id}`, input); +} + +export function toggleRewardActive(id: string) { + return api.patch(`/rewards/${id}/toggle`); +} + +export function deleteReward(id: string) { + return api.delete<{ ok: boolean }>(`/rewards/${id}`); +} + +export function redeemReward(id: string) { + return api.post(`/rewards/${id}/redeem`); +} diff --git a/frontend/src/components/AddMemberForm.tsx b/frontend/src/components/AddMemberForm.tsx new file mode 100644 index 0000000..6844895 --- /dev/null +++ b/frontend/src/components/AddMemberForm.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createMember } from "@/api/members"; +import { ApiError } from "@/api/client"; +import { AVATAR_OPTIONS } from "@/utils/avatarOptions"; +import type { Role } from "@/types"; + +export function AddMemberForm() { + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + const [avatarEmoji, setAvatarEmoji] = useState(AVATAR_OPTIONS[0]); + const [role, setRole] = useState("member"); + const [pin, setPin] = useState(""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: () => createMember({ name, avatarEmoji, role, pin: pin || undefined }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["members"] }); + setName(""); + setAvatarEmoji(AVATAR_OPTIONS[0]); + setRole("member"); + setPin(""); + setError(null); + }, + onError: (err) => setError(err instanceof ApiError ? err.message : "Something went wrong"), + }); + + return ( +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="space-y-3" + > +
+ setName(e.target.value)} + className="flex-1 rounded-md border border-border px-3 py-2 text-sm outline-none focus:border-primary" + /> + +
+ +
+ + setPin(e.target.value)} + className="flex-1 rounded-md border border-border px-3 py-2 text-sm outline-none focus:border-primary" + /> +
+

+ Admin profiles should have a PIN — it's what stops a kid from tapping into edit powers on a shared + screen. +

+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..9afd725 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,65 @@ +import { NavLink, Outlet } from "react-router"; +import { useAuth } from "@/context/AuthContext"; + +const NAV_LINKS = [ + { to: "/", label: "Hub", end: true }, + { to: "/calendar", label: "Calendar" }, + { to: "/chores", label: "Chores" }, + { to: "/rewards", label: "Rewards" }, + { to: "/reminders", label: "Reminders" }, +]; + +function navClass({ isActive }: { isActive: boolean }) { + return isActive + ? "text-foreground font-medium" + : "text-muted-foreground hover:text-foreground"; +} + +export default function AppShell() { + const { household, profile, logout, switchToPicker } = useAuth(); + + return ( +
+
+
+
+ {household?.name} + +
+ +
+ + {profile?.avatarEmoji} + {profile?.name} + · {profile?.points} pts + + + +
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/ChoreFields.tsx b/frontend/src/components/ChoreFields.tsx new file mode 100644 index 0000000..fe8a4c2 --- /dev/null +++ b/frontend/src/components/ChoreFields.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import type { Chore, Profile, Recurrence } from "@/types"; +import { parseDueDate, parseRecurrenceDays, toDateInputValue } from "@/utils/recurrence"; + +const WEEKDAYS = [ + { value: 0, label: "Sun" }, + { value: 1, label: "Mon" }, + { value: 2, label: "Tue" }, + { value: 3, label: "Wed" }, + { value: 4, label: "Thu" }, + { value: 5, label: "Fri" }, + { value: 6, label: "Sat" }, +]; + +export type ChoreFieldsValue = { + title: string; + description: string; + points: number; + assignedToUserId: string; + recurrence: Recurrence; + dueDate: string; + recurrenceDays: number[]; +}; + +export function defaultChoreFieldsValue(chore?: Chore): ChoreFieldsValue { + if (!chore) { + return { + title: "", + description: "", + points: 0, + assignedToUserId: "", + recurrence: "none", + dueDate: toDateInputValue(new Date()), + recurrenceDays: [], + }; + } + return { + title: chore.title, + description: chore.description ?? "", + points: chore.points, + assignedToUserId: chore.assignedToUserId ?? "", + recurrence: chore.recurrence, + dueDate: toDateInputValue(parseDueDate(chore.dueDate)), + recurrenceDays: parseRecurrenceDays(chore.recurrenceDays), + }; +} + +export function ChoreFields({ + members, + value, + onChange, +}: { + members: Profile[]; + value: ChoreFieldsValue; + onChange: (value: ChoreFieldsValue) => void; +}) { + const [selectedDays, setSelectedDays] = useState>(new Set(value.recurrenceDays)); + + function toggleDay(day: number) { + const next = new Set(selectedDays); + if (next.has(day)) next.delete(day); + else next.add(day); + setSelectedDays(next); + onChange({ ...value, recurrenceDays: [...next] }); + } + + return ( + <> + onChange({ ...value, title: e.target.value })} + className="w-full rounded-md border border-border px-3 py-2 text-sm" + /> +