Compare commits
87 Commits
full-roadm
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cbf8227dc4 | |||
| d1f6d3a5d6 | |||
| d3b4187119 | |||
| 878402e579 | |||
| e9c29dcf38 | |||
| 39ec7996c5 | |||
| ee13e7bc9d | |||
| 9068ac4586 | |||
| 2f01eb9cb0 | |||
| a075a792d6 | |||
| ae6ceef34f | |||
| f7abba4888 | |||
| c0db382ae2 | |||
| 4ce24a4145 | |||
| 3c4551acd9 | |||
| bd9a0a6d3f | |||
| 4832981e25 | |||
| 489fc3ec87 | |||
| f549dc53a2 | |||
| d11cf6ccef | |||
| cb223a0c29 | |||
| 427fe33c9c | |||
| 1638d2f6e8 | |||
| abfb64b177 | |||
| ae4fd45b99 | |||
| c0e5b5ed7f | |||
| 40a59ef581 | |||
| 666b4802a9 | |||
| fef186c1cb | |||
| f01dc21e74 | |||
| 51e62ecba3 | |||
| 3c60e5a026 | |||
| e814f3b5b3 | |||
| 6a02d6d5ab | |||
| a3fb423596 | |||
| 834034bada | |||
| 3f96e6cdb3 | |||
| 5563afd04c | |||
| b994fc7c95 | |||
| d9d5f780c1 | |||
| 7bf9135f33 | |||
| 38b2be61ef | |||
| 45cca5072b | |||
| c8c7f9f8c9 | |||
| a6e6915b0b | |||
| d34c578ad8 | |||
| 0485ccc75e | |||
| 27afda74f1 | |||
| 7556d77bf8 | |||
| b1858542d0 | |||
| 99096f9f4a | |||
| 09cd10692e | |||
| a59e7e15ea | |||
| 286f59225f | |||
| 59c425f6f6 | |||
| 59a32f80f7 | |||
| c1d0dfaee9 | |||
| 2b04edc511 | |||
| 1a6aae2874 | |||
| f28577e165 | |||
| 8fa629ad74 | |||
| c1152fac90 | |||
| 4c3552fb3c | |||
| a868b5a314 | |||
| 378fcbe5f4 | |||
| 2924b94329 | |||
| 10415bfca8 | |||
| 92ad29696d | |||
| 5b4ba9aaef | |||
| 83f613ec1e | |||
| 5e2ba3a6d6 | |||
| 2f0847a5a3 | |||
| 6cb13146f5 | |||
| cda7321d37 | |||
| ed556ce245 | |||
| a0e00b8044 | |||
| 9592879ad7 | |||
| 5873c2fa9f | |||
| 3efe0b9be0 | |||
| 93153cd750 | |||
| 6e0814169d | |||
| d4a781ddcf | |||
| dee9ee8014 | |||
| 33b32cae4a | |||
| 0e4ada0083 | |||
| 42509cc7c7 | |||
| b660ead40d |
76
.claude/skills/deploy/SKILL.md
Normal file
76
.claude/skills/deploy/SKILL.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: deploy
|
||||
description: Ship the colectivo stack to ambrosio (https://erosi.limonia.net) with pre-deploy DB backup, or roll back code + DB to a previous deploy. Trigger when the user asks to deploy, ship, push to prod, roll back, or undo a deploy.
|
||||
---
|
||||
|
||||
# Deploy + rollback skill
|
||||
|
||||
Deploys and rollbacks for the production stack on ambrosio. **Every deploy takes a paired DB backup first**, every successful deploy is logged with `(timestamp, git-sha, backup-filename)`, and rollback uses that log to restore both code and DB atomically.
|
||||
|
||||
## Trigger
|
||||
|
||||
Use this skill when the user says any of:
|
||||
- "deploy", "deploy to ambrosio", "deploy to prod", "ship it"
|
||||
- "roll back", "rollback", "undo the deploy", "revert prod"
|
||||
- "list deploys", "what was the last deploy"
|
||||
|
||||
## What the skill does
|
||||
|
||||
### Forward (deploy)
|
||||
|
||||
1. **`just deploy`** runs `infra/scripts/deploy-erosi.sh`:
|
||||
- Captures the local short SHA (`git rev-parse --short HEAD`).
|
||||
- rsyncs the working tree to `ambrosio:/opt/colectivo/` (excludes `.git`, `node_modules`, `**/.env`, etc.).
|
||||
- On a fresh `.env`, generates secrets and bails — operator fills `PUBLIC_KEYCLOAK_URL` + `KEYCLOAK_CLIENT_SECRET` and re-runs.
|
||||
- **Pre-deploy DB backup**: `pg_dumpall` from the prod `db` container → `backups/predeploy-<ts>-<sha>.sql.gz` on ambrosio. Aborts the deploy if the dump is < 1 KiB. Keeps the newest 10 backups.
|
||||
- Rebuilds the `app` image with `GIT_SHA` build-arg (so `__APP_COMMIT__` lands in the bundle, Fase 14.3).
|
||||
- `docker compose up -d` brings the stack to the new image.
|
||||
- Applies any pending DB migrations.
|
||||
- On success, appends `<iso-ts>\t<sha>\t<backup-file>` to `/opt/colectivo/.deploys.log`.
|
||||
2. Site smoke: `curl -I https://erosi.limonia.net/` should return 200.
|
||||
|
||||
### Reverse (rollback)
|
||||
|
||||
1. **`just rollback-list`** prints recent rows from `.deploys.log` so the user can pick a target.
|
||||
2. **`just rollback`** rolls back code + DB to the previous deploy (last but one line of `.deploys.log`).
|
||||
3. **`just rollback-to <sha>`** rolls back to a specific deploy.
|
||||
4. **`just rollback-code`** rolls back code only, leaves the DB at current state — useful when the regression is UI-only and the DB schema is compatible.
|
||||
|
||||
`infra/scripts/rollback-erosi.sh` enforces:
|
||||
- Confirms with a 5-second abort window before doing anything destructive.
|
||||
- Verifies the target SHA is reachable locally (`git cat-file -e`) and that the backup file still exists on ambrosio.
|
||||
- Materialises the target commit into a temporary `git worktree`, rsyncs from there, never disturbs the user's working tree.
|
||||
- Stops `app / auth / rest / realtime / storage` before the DB restore so they don't observe inconsistent state.
|
||||
- `gunzip | psql -v ON_ERROR_STOP=1` restores the dump.
|
||||
- Rebuilds the app image at the rolled-back SHA and brings the stack up.
|
||||
- **Does not** write a new `.deploys.log` line — rollback is intentionally not a deploy event.
|
||||
|
||||
## Safety boundaries
|
||||
|
||||
- The stack lives on ambrosio. The external proxy + TLS terminator are off-stack (`docs/deployment.md`). Neither this skill nor the scripts ever touch host TLS or the external proxy.
|
||||
- `infra/scripts/deploy-erosi.sh` never runs `sudo`.
|
||||
- `--no-verify` / `--amend` are never used. Pre-commit hooks must pass.
|
||||
- Always confirm with the user before invoking `just rollback*` — DB restore is destructive and irreversible.
|
||||
- Storage volume (`/var/lib/storage`, user uploads) is **not** part of the rollback. Treated as append-only.
|
||||
|
||||
## What to tell the user
|
||||
|
||||
Before running anything:
|
||||
- Show the local short SHA + branch + remote.
|
||||
- For deploy: confirm with the user. Mention "this will take a fresh DB backup before deploying."
|
||||
- For rollback: show `just rollback-list` output, ask which SHA, confirm destructive nature.
|
||||
|
||||
After running:
|
||||
- Print the new `.deploys.log` tail (or the rollback target).
|
||||
- Curl-probe the site and report.
|
||||
|
||||
## Files this skill touches
|
||||
|
||||
- `infra/scripts/deploy-erosi.sh` — deploy, with pre-deploy backup + .deploys.log writing.
|
||||
- `infra/scripts/rollback-erosi.sh` — rollback, paired code + DB.
|
||||
- `Justfile` — `deploy`, `rollback`, `rollback-list`, `rollback-to`, `rollback-code` recipes.
|
||||
- `docs/deployment.md` — runbook for both flows.
|
||||
|
||||
## Anti-trigger
|
||||
|
||||
If the user is just asking *what would happen* on deploy (status, dry-run-style questions), DO NOT run anything. Answer from the script content.
|
||||
@@ -1,15 +1,31 @@
|
||||
# Production env for ambrosio — copy to .env on the server and fill in secrets.
|
||||
# Generate JWT triplet: bash infra/scripts/rotate-jwt.sh
|
||||
|
||||
# ── Domains (change if you migrate to new hostnames) ────────────────────────
|
||||
PUBLIC_APP_URL=https://erosi.oier.ovh
|
||||
PUBLIC_SUPABASE_URL=https://erosi.oier.ovh
|
||||
PUBLIC_KEYCLOAK_URL=https://auth.oier.ovh
|
||||
PUBLIC_KEYCLOAK_REALM=colectivo
|
||||
PUBLIC_KEYCLOAK_CLIENT_ID=colectivo-web
|
||||
# ── App + Supabase API (single domain, routed by Traefik on ambrosio) ──────
|
||||
PUBLIC_APP_URL=https://erosi.limonia.net
|
||||
PUBLIC_SUPABASE_URL=https://erosi.limonia.net
|
||||
|
||||
# Keycloak sees this as its canonical hostname (KC_HOSTNAME).
|
||||
KEYCLOAK_HOSTNAME=auth.oier.ovh
|
||||
# ── External Keycloak ──────────────────────────────────────────────────────
|
||||
# The client must be confidential, with `openid` as a default client scope
|
||||
# (GoTrue v2.158.1 doesn't send `openid` in the OAuth scope param — Keycloak
|
||||
# must inject it), redirectUris including https://erosi.limonia.net/* and
|
||||
# https://erosi.limonia.net/auth/v1/callback, and webOrigins https://erosi.limonia.net.
|
||||
#
|
||||
# NOTE: if your Keycloak uses the legacy `/auth/` base path (Keycloak ≤ 16 and
|
||||
# some later distributions), PUBLIC_KEYCLOAK_URL must include it — e.g.
|
||||
# https://auth.example.com/auth. Verify before deploying: a GET on
|
||||
# ${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/.well-known/openid-configuration
|
||||
# must return 200 JSON. 404 = wrong base path.
|
||||
PUBLIC_KEYCLOAK_URL=https://your-keycloak.example.com
|
||||
PUBLIC_KEYCLOAK_REALM=your-realm
|
||||
PUBLIC_KEYCLOAK_CLIENT_ID=your-client-id
|
||||
KEYCLOAK_CLIENT_SECRET=CHANGEME-paste-from-external-keycloak
|
||||
|
||||
# ── Reverse proxy ──────────────────────────────────────────────────────────
|
||||
# TLS + outermost routing live outside this repo. The stack exposes a single
|
||||
# host port (3000) where an internal Caddy fans out kong (Supabase API) vs
|
||||
# app (SvelteKit). Configure whatever external proxy you use to terminate
|
||||
# HTTPS for PUBLIC_APP_URL and forward plain HTTP to ambrosio:3000.
|
||||
|
||||
# ── Postgres (single password for all internal roles) ───────────────────────
|
||||
POSTGRES_PASSWORD=CHANGEME-strong-random-password
|
||||
@@ -20,13 +36,23 @@ SUPABASE_JWT_SECRET=CHANGEME-openssl-rand-base64-48
|
||||
PUBLIC_SUPABASE_ANON_KEY=CHANGEME-sign-with-above
|
||||
SUPABASE_SERVICE_ROLE_KEY=CHANGEME-sign-with-above
|
||||
|
||||
# ── Keycloak admin + client secret ──────────────────────────────────────────
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=CHANGEME-strong-random-password
|
||||
# Must match the `secret` field of the colectivo-web client in realm-export.erosi.json.
|
||||
KEYCLOAK_CLIENT_SECRET=CHANGEME-client-secret-32-chars
|
||||
|
||||
# ── Realtime encryption ─────────────────────────────────────────────────────
|
||||
# DB_ENC_KEY: exactly 16 characters. SECRET_KEY_BASE: ≥ 64 characters.
|
||||
REALTIME_ENC_KEY=CHANGEME16charkey
|
||||
REALTIME_SECRET_KEY_BASE=CHANGEME-openssl-rand-base64-64-at-minimum-so-phoenix-is-happy
|
||||
|
||||
# ── Server admin bootstrap (Fase 13) ────────────────────────────────────────
|
||||
# Email of the user who should be promoted to `server_admin` on first volume
|
||||
# init. The bootstrap script (infra/db-init/10-server-admin-seed.sh) reads
|
||||
# this and, if `public.server_admins` is empty AND a matching public.users row
|
||||
# exists, inserts that user as the first admin. Idempotent — re-running with a
|
||||
# different email after the first admin is created does NOTHING.
|
||||
#
|
||||
# IMPORTANT: this fires from `docker-entrypoint-initdb.d`, which only runs on
|
||||
# a FRESH volume. On prod first deploy:
|
||||
# 1. Spin up the stack (this will likely log "no user matches" — the user
|
||||
# hasn't completed their first Keycloak login yet).
|
||||
# 2. Have the operator sign in once via Keycloak so public.users picks them up.
|
||||
# 3. Manually re-run: `docker compose exec -T db bash /docker-entrypoint-initdb.d/10-server-admin-seed.sh </dev/null`
|
||||
# After that the table is non-empty and the script is a no-op forever.
|
||||
SERVER_ADMIN_EMAIL=operator@example.com
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -42,5 +42,9 @@ apps/web/tests/.auth/
|
||||
# PWA dev-mode service worker output
|
||||
apps/web/dev-dist/
|
||||
|
||||
# Claude Code per-project runtime state (schedulers etc.)
|
||||
.claude/
|
||||
# Claude Code per-project runtime state (schedulers etc.). Project-scoped
|
||||
# skills under .claude/skills/ ARE tracked — they document workflows for
|
||||
# anyone working on the repo.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
graphify-out/cache/
|
||||
|
||||
105
CHANGELOG.md
Normal file
105
CHANGELOG.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
Each bullet is prefixed with `[new]` (added capability), `[fix]` (resolved
|
||||
defect) or `[tweak]` (behaviour or implementation change with no new
|
||||
surface). Areas group related entries; section bullets ending in `:` are
|
||||
labels for the area, not log entries.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.0.0] — 2026-05-19
|
||||
|
||||
First tagged release. Closes the v1.0 cycle (Fases 17–20): versioning + changelog tooling, the shopping-list creation flow rework, the emoji picker expansion, and Basque (Euskera) as a supported locale.
|
||||
|
||||
- [new] Semver git tags + Keep-a-Changelog `CHANGELOG.md` in repo root, About modal in `/settings` renders the bundled changelog via `?raw` import, deploy script HEAD-tag check + tag column in `.deploys.log`, `scripts/check-changelog.mjs` linter wired into `just check` (Fase 17).
|
||||
- [new] Required list title + per-collective per-prefix auto-numbered `#N` parsed from the typed value, `collectives.default_list_title`, admin-curated `collective_list_titles` catalog with `add_list_title` / `remove_list_title` SECURITY DEFINER RPCs, autocomplete union (catalog + last-10) (Fase 18).
|
||||
- [new] Emoji picker expanded with full food + animals categories (~280 codepoints, Unicode 14 baseline for iOS<17 compat), new `EmojiPicker.svelte` with tabbed lazy-rendered grids replacing the 8-emoji hardcoded selector at 4 sites (Fase 19).
|
||||
- [new] Euskera (Basque) locale — `messages/eu.json`, `users.language` check constraint extended to `('en','es','eu')`, `accept-language` parser recognises `eu` / `eu-ES` / `eu-FR`, "Euskera" option in the `/settings` selector. Machine-translated seed flagged with `[needs review]` pending native review (Fase 20).
|
||||
|
||||
## [v0.0.0-beta] — 2026-05-19 — MVP + MVP2 (Fases 0–16)
|
||||
|
||||
Single rollup for everything shipped before tagging started. Covers the
|
||||
entire MVP cycle (Fases 0–7) plus the prod auth hardening pass (Fase 8)
|
||||
and the eight MVP2 fases (9–16). Reads as a feature catalogue, not a
|
||||
day-by-day diff — the per-fase docs in `docs/history/` and the per-fase
|
||||
plans in `plan/` are the source of record.
|
||||
|
||||
- Auth + identity:
|
||||
- [new] Keycloak → GoTrue → Supabase OIDC integration (PKCE), with `auth.users` pre-seed for the 5 dev users (Fase 1).
|
||||
- [new] Self-registration via Keycloak; invitations are link-only (no email), generated on `/collective/manage` (Fase 1).
|
||||
- [new] Multi-collective per user with sidebar switcher; active collective persisted to `localStorage` (Fase 1).
|
||||
- [new] Atomic `create_collective(name, emoji)` RPC so onboarding works in a single round-trip without tripping `is_admin` / `is_member` policies (migration 012, Fase 7).
|
||||
- [new] Public `/logged-out` route + RP-initiated Keycloak end-session on logout, so the SSO cookie is actually cleared and the next login re-prompts (Fase 8).
|
||||
- [new] Account hard-delete (`delete_account()` RPC) with sole-admin auto-promote and a Danger-zone modal (migration 021, Fase 10.5).
|
||||
- [fix] `auth.users.role`/`aud` default-fill trigger so PostgREST routing survives the Keycloak provisioning path; INSERT + UPDATE both covered to defeat GoTrue's pop-ORM re-clobber (migrations 013–014, Fases 7–8).
|
||||
- [fix] `isLoggingOut` guard in `(app)/+layout.svelte` to suppress the auth-gate `$effect` during the logout navigation, closing a "PKCE code verifier not found in storage" race on prod (Fase 8).
|
||||
- [fix] `pendingInvitationToken` restored from `sessionStorage` after login so the user lands on `/invitation/<token>` instead of `/onboarding` or `/lists` (Fase 7).
|
||||
|
||||
- Shopping lists + items:
|
||||
- [new] Lists overview (featured + grid + completed + trash) and detail view with checkbox toggle, qty stepper, inline edit, drag-reorder (Fase 2a).
|
||||
- [new] Frequency-driven suggestion chips on the add-item form, backed by the `item_frequency` table + trigger (Fase 2a; migration 006).
|
||||
- [new] Realtime "Modo Compra" full-screen session view with 56 px toggles, flip animation, complete-list → redirect; postgres_changes subscription per list with echo-deduplication (Fase 2b; migration 007).
|
||||
- [new] Offline-first sync queue (`SyncQueue` IDB-backed, FIFO flush, PK-conflict-as-success) with `online`-event auto-flush; offline/syncing banner mounted in detail + session views (Fase 2b).
|
||||
- [new] Selection mode with bulk delete / move / mark-checked, long-press entry on mobile + desktop toggle button (Fase 5.11).
|
||||
- [new] Reset-from-detail button on completed lists; "Crear nuevo colectivo" entry in sidebar + drawer (Fase 10).
|
||||
- [new] Trash drawer restore + hard-delete via `restore_list()` / `hard_delete_list()` RPCs (migration 020, Fase 10.4).
|
||||
- [new] Collective-scoped item tags (chips + picker + filter bar + realtime resort) backed by `item_tags` + `shopping_item_tags` (migration 022, Fase 11).
|
||||
- [new] Common-items management on `/collective/manage` — 3-way Hide / Normal / Boost segmented control writing `weight` via admin-only RPCs; suggestion strip reorders by weight + use_count + last_used_at (migration 026, Fase 15).
|
||||
- [new] Suggestion dropdown excludes names already on the active list (`excludeNames` capped at 200 to respect Kong URI limits) (Fase 15).
|
||||
- [tweak] Item row redesigned to `drag-handle | name-button | qty stepper` with swipe-to-(un)check; double-tap opens an edit overlay; `unit` column dropped because it became unreachable (migration 011, Fase 5.10).
|
||||
- [tweak] Add-form moved inline as the last child of the unchecked section; sticky bottom bars retired (Fase 5.14).
|
||||
- [tweak] List rename inline at the top of the detail view with 500 ms debounced autosave; "New list" button moved to the masthead (Fase 5.8).
|
||||
|
||||
- Tasks + notes:
|
||||
- [new] Task lists + tasks with completion-consistency CHECK (`is_completed ⇔ completed_by ⇔ completed_at`) and drag-reorder (migration 008, Fase 3).
|
||||
- [new] Notes with 8-colour palette, pinned + archived (mutually exclusive), 7-day trash window enforced via RLS + `pg_cron` purge; editor with 500 ms debounced autosave (migration 009, Fase 3).
|
||||
- [new] Sync-conflicts review route at `/settings/sync-conflicts`, paginated, side-by-side local vs remote payloads, per-device dismissal via `localStorage` (Fase 14.2).
|
||||
|
||||
- Search:
|
||||
- [new] Cross-section search via `search_in_collective()` SECURITY INVOKER RPC over generated `tsvector` columns on items/tasks/notes; debounced input, filter chips, deep-link per result type (migration 010, Fase 4).
|
||||
|
||||
- Mobile UX + accessibility:
|
||||
- [new] Mobile shell — top bar with hamburger + collective name + avatar, slide-in drawer, bottom tab bar with safe-area inset, sticky contextual headers on detail routes (Fase 5).
|
||||
- [new] `UndoToast` for destructive actions (4 s TTL, auto-commit, mounted globally), with `scheduleUndoable()` queue (Fase 5).
|
||||
- [new] Spinner component (`sm/md/lg`, `currentColor`, `motion-reduce:animate-none`, sr-only label) integrated at 7 loading sites (Fase 16).
|
||||
- [tweak] Desktop reading width capped at `max-w-2xl` for content detail routes; mobile and `/lists/[id]/session` excluded (Fase 9.2).
|
||||
|
||||
- PWA + offline:
|
||||
- [new] Installable PWA — Workbox precaching via `@vite-pwa/sveltekit`, manifest, iOS meta tags + apple-touch icons, placeholder slate-and-"C" icons (Fase 6.2).
|
||||
- [new] Service-worker auto-registration via `useRegisterSW` + 15 min `reg.update` poll; `UpdateToast` with "Reload" action when a new build is available; "Ready to use offline" auto-dismiss toast (Fase 14.1).
|
||||
- [new] `OfflineChip` in `MobileTopBar` + `DesktopSidebar` with a pending-ops badge when the queue is non-empty (Fase 14.2).
|
||||
- [new] Build-time version constants (`__APP_VERSION__` / `__APP_COMMIT__` / `__BUILD_TIME__`) exposed via `$lib/version`; "Acerca de" chip on `/settings`; `GIT_SHA` baked through the Dockerfile build arg by the deploy script (Fase 14.3).
|
||||
- [tweak] PWA strategy on prod switched to `generateSW` because `@vite-pwa/sveltekit` 0.6.8 hardcodes the source SW filename under `injectManifest` (Fase 6 → revisit in Fase 14).
|
||||
- [fix] `<link rel="manifest">` added to `app.html` so Android Chrome surfaces the install prompt (Fase 14 follow-up).
|
||||
|
||||
- Collective management:
|
||||
- [new] Rename collective + change emoji (CU-H07) on `/collective/manage` (Fase 10.2).
|
||||
- [new] Leave-collective flow (CU-H06) with sole-admin auto-promote via `leave_collective()` RPC; sole-member rejected with P0001 (migration 017, Fase 10.1).
|
||||
- [new] Dissolve-collective flow (CU-H08) with type-the-name confirmation, via `dissolve_collective()` RPC (migration 018, Fase 10.3).
|
||||
- [new] Section visibility — per-user + per-collective `feature_flags jsonb` + `section_enabled()` STABLE SQL helper using COALESCE precedence (migration 023, Fase 12).
|
||||
- [new] Auto-detect language on first sign-in from `navigator.languages`, gated to fresh accounts (`<60 s old` + `language = 'en'`) (Fase 10.6).
|
||||
- [tweak] Every `created_by`/`updated_by` FK relaxed from `NOT NULL RESTRICT` to `NULL SET NULL` so a user delete cascades cleanly (migration 019, Fase 10.5).
|
||||
|
||||
- Server admin:
|
||||
- [new] Global `server_admin` role (`server_admins` table) orthogonal to `collective_members`; `is_server_admin()` SECURITY DEFINER gate; bootstrap from `SERVER_ADMIN_EMAIL` on first DB init (migration 024, Fase 13).
|
||||
- [new] `/admin` route group with 9 SECURITY DEFINER RPCs (collective lifecycle, member removal, server-level section defaults) + append-only `admin_actions` audit log + last-admin guard (migration 025, Fase 13).
|
||||
- [new] `section_enabled()` extended with a server layer prepended to the user/collective precedence chain (Fase 13.2).
|
||||
|
||||
- i18n:
|
||||
- [new] Paraglide JS messages for `en` + `es`, compiled at build time; `setLanguageTag()` flips the active locale without reload; selector on `/settings` writes to `public.users.language` (Fase 0).
|
||||
- [new] Search + collective-management + PWA copy fully localised in both languages across every fase that added user-facing strings (Fases 4, 10, 14).
|
||||
|
||||
- Infra + deploy:
|
||||
- [new] Local dev stack — Docker compose with Postgres / GoTrue / PostgREST / Realtime / Storage / Kong / Keycloak; `setup.sh` for idempotent first-time bring-up; `Justfile` task runner (Fase 0).
|
||||
- [new] Production stack on ambrosio (OVH VPS) at https://erosi.limonia.net — `infra/scripts/deploy-erosi.sh` (rsync + first-deploy secret generation + every-deploy migration apply + pre-deploy backup + `.deploys.log` append) and `infra/scripts/rollback-erosi.sh` (paired code + DB rollback) (Fase 6 + later iterations).
|
||||
- [new] Kong rate-limit on `/rest/v1/collective_invitations` (20/hour per Authorization header) with `request-transformer` URI rewrite (Fase 6.3).
|
||||
- [new] JWT rotation script (`infra/scripts/rotate-jwt.sh`) + production env template + `rls-audit.sh` curl sweep + `just lighthouse` build-and-score recipe (Fase 6.4 + 6.5).
|
||||
- [new] Internal Caddy on `:3000` fans `/rest|/auth|/realtime|/storage|/graphql|/pg` → Kong, everything else → app; external TLS terminator (off-stack) forwards plain HTTP to ambrosio:3000 (Fase 6 → revision after prod cutover).
|
||||
- [new] Theme tokens stored as RGB triplets so `dark:` variants resolve consistently; three-mode preference (`light` / `dark` / `system`) persisted to `users.theme` + mirrored to `localStorage` with inline anti-FOUC script (migration 015, Fase 9.1).
|
||||
- [new] `sync_conflicts` append-only log + queue-side divergence detection so last-write-wins conflicts are auditable (migration 016, Fase 9.3).
|
||||
- [new] Pre-deploy `pg_dumpall` snapshot kept in `backups/predeploy-*.sql.gz` (newest 10) with paired code/DB rollback via `rollback-erosi.sh --previous` / `--to=<sha>` / `--code-only` (Fase 14 deploy work).
|
||||
- [fix] `docker compose exec -T` calls inside SSH heredoc deploys now redirect stdin per call so the surrounding heredoc isn't drained — silently skipped migrations no longer happen (Fase 8 deploy hardening).
|
||||
- [fix] PostgREST schema cache reload (`NOTIFY pgrst, 'reload schema'`) appended after every deploy and rollback so freshly-added RPCs are visible without a container restart (Fase 15 deploy work).
|
||||
- [fix] WebKit Playwright project gated behind `RUN_WEBKIT=1` so hosts without the GTK libs (libicu74, libwoff1, …) keep `just test-all` green; `*.webkit.test.ts` files excluded from the chromium project (Fase 9.4).
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -4,15 +4,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Status
|
||||
|
||||
**Fase 0–5 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.oier.ovh (app + Supabase) + https://auth.oier.ovh (Keycloak); Let's Encrypt certs via host Caddy; PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
|
||||
**Fase 0–5 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.limonia.net (app + Supabase, external Keycloak); edge proxy now off-stack — internal Caddy fans kong/app on host port 3000, whatever external proxy fronts the host terminates TLS and forwards plain HTTP to ambrosio:3000 (originally NetBird's self-hosted Traefik, now proxy-agnostic); PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
|
||||
|
||||
**MVP2 — COMPLETE (8/8 ✅, 2026-05-18).** Branch `mvp2`. Fases 9–15 shipped 2026-05-18; **Fase 16 (loading spinner)** shipped same day after reopening the cycle. **Fase 9: dark mode + polish** (migrations 015–016 + pgTAP 009–010 + dark-mode plumbing + max-w-2xl reading column + sync_conflicts table/queue logging + WebKit Playwright project; see `docs/history/fase-9-polish-dark-mode.md`). **Fase 10: spec completion (H06/H07/H08 + trash restore + account deletion + sidebar-create + reset-from-detail + Accept-Language)** — migrations 017–021 + 4 new pgTAP suites + 6 new Playwright specs; see `docs/history/fase-10-spec-completion.md`. **Fase 11: item tags + drag-reorder** — migration 022 + pgTAP 014 + tags store + chips/picker + filter bar + realtime re-sort; see `docs/history/fase-11-item-tags-importance.md`. **Fase 12: section visibility (feature flags)** — migration 023 + pgTAP 015 + JSONB `feature_flags` on `users`+`collectives` + `public.section_enabled()` SQL helper with COALESCE precedence; see `docs/history/fase-12-section-visibility.md`. **Fase 13: server administration** — migrations 024+025 (server_admins + admin_actions + server_settings + 9 SECURITY DEFINER admin RPCs) + pgTAP 016+017 + `/admin` route group + last-admin guard + audit-append-only via missing-policies pattern + `section_enabled()` extended with server layer + `SERVER_ADMIN_EMAIL` bootstrap (prod needs manual re-run after the operator's first Keycloak login); see `docs/history/fase-13-server-admin.md`. **Fase 14: PWA hardening** — `useRegisterSW` + `UpdateToast` (auto-update toast) + offline chip + queued-op badge + `/settings/sync-conflicts` review route + `__APP_VERSION__`/`__APP_COMMIT__`/`__BUILD_TIME__` defines baked at build via Dockerfile `GIT_SHA` build-arg (host-side `git rev-parse --short HEAD` in `deploy-erosi.sh`) + "Acerca de" version chip in `/settings`; no migration; see `docs/history/fase-14-pwa-hardening.md`. **Fase 15: common items management** — migration 026 + pgTAP 018 + `item_frequency.weight integer` column, written only via admin-only SECURITY DEFINER RPCs (`set_item_frequency_weight` + `purge_item_frequency`) — existing all-deny client policies on `item_frequency` stay in place. Suggestion query reorders by `weight desc, use_count desc, last_used_at desc`. UI 3-way segmented control (Hide / Normal / Boost → -50 / 0 / 50) in `/collective/manage` (admin write, member read-only, guest hidden). `fetchSuggestions` gains `excludeNames` (capped at 200 names to bound URL length on PostgREST `not.in.()` filter) to drop items already on the active list from the dropdown; see `docs/history/fase-15-common-items.md`. **Final test totals (`just test-all`): 177 pgTAP + 179 Vitest integration + 47 Vitest unit + 93 Playwright + 1 gated rate-limit = 497 tests green; 3 skipped (2 Realtime presence — upstream `handle_out/3` bug `supabase/realtime#1617`; 1 rate-limit — gated). Delta vs MVP close: +132 pgTAP + +39 integration + +32 unit + +34 e2e = +237 tests across MVP2 fases 9–15.** Fase 7+8 already shipped as MVP cleanup, not MVP2.
|
||||
|
||||
**Fase 16: loading spinner** — `Spinner.svelte` (sizes sm/md/lg = 16/24/40px, `currentColor` for theme, `motion-reduce:animate-none`, `role="status"` + sr-only `m.loading()`) integrated at 7 loading sites (auth splash + search + notes archive + create-collective modal + manage page: members-list / invite generator / add-common-item button). No DB migration; reuses existing `m.loading()`. Added 5 Vitest unit (SP-U-01..05) + 3 Playwright e2e (SP-E-01..03). Vitest config gained `resolve.conditions: ['browser']` + `server.deps.inline: [/^svelte/]` so Svelte 5's `mount()` resolves the browser entry; see `docs/history/fase-16-spinner.md`. **Final cumulative MVP2 (fases 9–16): 177 pgTAP + 179 Vitest integration + 52 Vitest unit + 96 Playwright + 1 gated rate-limit = 505 tests green; 3 skipped.**
|
||||
|
||||
**v1.0 — COMPLETE (4/4 ✅, 2026-05-19, tag `v1.0.0`).** Four fases on `mvp2` branch produced the first tagged release. **Fase 17: versioning + CHANGELOG + about modal** — root `CHANGELOG.md` with `[v0.0.0-beta]` backfill of Fases 0–16 (58 bullets, `[new]/[fix]/[tweak]` prefixes), `scripts/check-changelog.mjs` linter wired into `just check`, `ChangelogModal.svelte` rendering the CHANGELOG via `?raw` build-time import, `/settings` › About "Ver historial" trigger, deploy-script HEAD tag warn (5s) + 4th `<tag>` column appended to `.deploys.log` (compat-safe; rollback `--list` surfaces it); see `docs/history/fase-17-versioning-changelog.md`. **Fase 18: shopping list flow** — required title (client + DB tightening), `#N` auto-numbering parsed per-collective per-prefix from typed value, migration 027 (`collectives.default_list_title` + `collective_list_titles` table + `add_list_title` / `remove_list_title` SECURITY DEFINER RPCs, P0002 on non-admin), `CreateListModal.svelte` replaces the empty-create-then-rename flow with prefilled default + suggestion chip + auto-suffix on submit, `/collective/manage` "Títulos sugeridos" subsection (admin write, member read-only). pgTAP 019; see `docs/history/fase-18-shopping-list-flow.md`. **Fase 19: emoji avatars** — `EMOJI_CATALOG` (~280 codepoints, Unicode 14 baseline for iOS<17 compat) across faces/food/animals/objects, new `EmojiPicker.svelte` with tabbed lazy-rendered grids + arrow-key nav replacing the 8-emoji hardcoded selector at 4 sites (settings / onboarding / create-collective modal / manage); no migration; see `docs/history/fase-19-emoji-avatars.md`. **Fase 20: Euskera locale** — `messages/eu.json` machine-translated seed flagged with `[needs review]`, migration 028 (`users.language` check extended to `('en','es','eu')`), `accept-language` recognises `eu`/`eu-ES`/`eu-FR`, "Euskera" option in `/settings`; pgTAP 020; see `docs/history/fase-20-euskera-locale.md`. **Release v1.0.0 cut from commit `d1f6d3a`** (CHANGELOG `[Unreleased]` flushed to `## [v1.0.0]`, `package.json` bumped to `1.0.0`, local tag `v1.0.0`, no push). **Final cumulative v1.0 tests (`just test-all`): 196 pgTAP + 187 Vitest integration + 89 Vitest unit + 108 Playwright + 1 gated rate-limit = 581 tests green; 3 skipped (2 Realtime presence — upstream `handle_out/3` bug `supabase/realtime#1617`; 1 rate-limit — gated). Delta vs MVP2 close: +19 pgTAP + +8 integration + +37 unit + +12 e2e = +76 tests across v1.0 fases 17–20.**
|
||||
|
||||
## Documentation Map
|
||||
|
||||
- `README.md` — full development plan, confirmed tech stack, Justfile reference, technical warnings
|
||||
- `docs/development.md` — local dev stack, repo layout, Justfile + test commands, endpoints, credentials, secrets
|
||||
- `docs/deployment.md` — production deploy to ambrosio (erosi.oier.ovh) + prod-specific fixes
|
||||
- `docs/deployment.md` — production deploy to ambrosio (erosi.limonia.net) + prod-specific fixes
|
||||
- `docs/history/fase-{1,2a,2b,3,4,5,6,7}.md` — per-phase record of what was built
|
||||
- `docs/history/fase-8-auth-logout.md` — post-fase-7 prod auth hardening (migration 014 + RP-initiated logout)
|
||||
- `docs/history/fase-9-polish-dark-mode.md` — MVP2 dark mode + reading width + sync-conflicts debug panel
|
||||
- `docs/history/fase-10-spec-completion.md` — MVP2 CU-H06/H07/H08 + trash RPCs + account delete + sidebar create + reset from detail + auto language
|
||||
- `docs/history/fase-11-item-tags-importance.md` — MVP2 item tags + drag-reorder
|
||||
- `docs/history/fase-12-section-visibility.md` — MVP2 section visibility / feature flags
|
||||
- `docs/history/fase-13-server-admin.md` — MVP2 server administration
|
||||
- `docs/history/fase-14-pwa-hardening.md` — MVP2 PWA hardening (auto-update + version chip)
|
||||
- `docs/history/fase-15-common-items.md` — MVP2 common items management
|
||||
- `docs/history/fase-16-spinner.md` — MVP2 loading spinner
|
||||
- `docs/history/fase-17-versioning-changelog.md` — v1.0 versioning + CHANGELOG + about modal
|
||||
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
|
||||
- `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 7)
|
||||
- `design/slate_collective/DESIGN.md` + `design/*/screen.png` — "Monolith Editorial" direction + per-screen Stitch mockups. Consult before changing UI.
|
||||
@@ -69,6 +84,14 @@ The central organizing unit is the **Collective** (household group), not the ind
|
||||
- Trash retains deleted items/notes for 7 days before permanent deletion.
|
||||
- If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion.
|
||||
|
||||
**Section visibility (feature flags) — required steps for any new top-level section.** A "section" here is one of the four current navigable areas (lists, tasks, notes, search) — adding a fifth means EVERY one of:
|
||||
1. SQL migration that updates `public.known_sections()` to include the new key + (if it adds a new RPC) maps onto the planned Fase 13 server-layer extension of `public.section_enabled()`.
|
||||
2. `SectionKey` union + `SECTION_KEYS` array entry in `packages/types/src/domain.ts`.
|
||||
3. Default-checked toggle row in `/settings` (per-user) and `/collective/manage` (admin) — both already iterate `SECTION_KEYS`, so this is automatic as long as you add the `section_label_<key>` Paraglide message + the route prefix in `(app)/+layout.svelte`'s `SECTION_BY_PREFIX` array (drives the redirect guard).
|
||||
4. pgTAP assertion in `supabase/tests/015_section_visibility.sql` that `section_enabled('<newkey>', user, collective)` defaults to true.
|
||||
|
||||
Without all four, the section either shows up but can't be hidden, or can be hidden but isn't toggled from the UI — both are confusing failure modes.
|
||||
|
||||
## Internationalisation
|
||||
|
||||
Library: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, zero runtime overhead, tree-shaken per language.
|
||||
@@ -142,6 +165,8 @@ Defined in `keycloak/realm-export.json` and `supabase/seed.sql`. All use passwor
|
||||
|
||||
- **Realtime `waitFor` timeout is 10s, not 5s.** Under full `just test-all` load the Phoenix socket occasionally takes several seconds to propagate a fresh subscription's filter before the test mutation fires. Set in `packages/test-utils/src/realtime-helpers.ts`. Don't drop below 10s — passing runs resolve on event arrival, only the flake window widened.
|
||||
|
||||
- **WebKit (Mobile Safari) Playwright project is gated on `RUN_WEBKIT=1`.** Fase 9.4 added a `webkit` project (`devices['iPhone 13']`) for touch-gesture specs (the swipe-to-check/uncheck handlers use unified pointer events, but Chromium's touch emulation does not fire them in the right order). The project is conditionally included in `playwright.config.ts` based on `process.env.RUN_WEBKIT` so `just test-all` stays green on hosts that lack the GTK/WebKit system deps (libicu74, libxml2, libmanette-0.2-0, libwoff1 — install via `sudo pnpm exec playwright install-deps`). `just test-webkit` runs the gated suite. Specs named `*.webkit.test.ts` are also excluded from the chromium project via `testIgnore`, so a file never runs in both browsers.
|
||||
|
||||
### Backend / RLS / Kong
|
||||
|
||||
- **`strip_path: true` on a full-table Kong route strips too much.** With `paths: [/rest/v1/collective_invitations]` and `strip_path: true`, Kong forwards `/` upstream — PostgREST returns `PGRST117: Unsupported HTTP method: POST` because it tries to route root, not the table. Fix: add a `request-transformer` plugin that `replace.uri: /collective_invitations`. Applies identically to any route where the match path equals the full upstream resource path (as opposed to a prefix match like `/rest/v1/`).
|
||||
@@ -165,13 +190,17 @@ Defined in `keycloak/realm-export.json` and `supabase/seed.sql`. All use passwor
|
||||
- Push notifications require iOS 16.4+ and the PWA must be installed to the home screen.
|
||||
- Test the shopping session mode on a real iPhone during development, not only in DevTools.
|
||||
|
||||
**nginx (prod) — WebSocket for Realtime:**
|
||||
- The `/realtime/` location block **must come before** the `/` block.
|
||||
- `proxy_read_timeout 3600s` is required on the Realtime block — without it, nginx closes WebSocket connections after 60 seconds, forcing continuous reconnects during active shopping sessions.
|
||||
- CSP `connect-src` must include both `https://` and `wss://` for the API domain.
|
||||
**Edge proxy (prod):**
|
||||
- Two layers. Internal: a Caddy container on the `colectivo` Docker network listens on `:3000` (host port `3000:3000`) and does the path split — `/rest/v1`, `/auth/v1`, `/realtime/v1`, `/storage/v1`, `/graphql/v1`, `/pg` → `kong:8000`; everything else → `app:3000`. Config in `infra/Caddyfile.erosi`, mounted into `caddy:2-alpine`. Plain HTTP, no TLS, no Host matching. Global block sets `auto_https off` + `admin off` so the only socket is `:3000`.
|
||||
- External: TLS termination + outermost routing live outside this repo (originally NetBird's self-hosted Traefik on ambrosio, but the stack is proxy-agnostic now). Whatever fronts the host terminates `https://erosi.limonia.net` and forwards plain HTTP to `ambrosio:3000`. One upstream is enough — Caddy fans out internally.
|
||||
- External proxy must allow long-lived idle WebSockets for Realtime (≥3600s, or unlimited). NetBird's default is `0` (unlimited). Symptom of too-short: client reconnects continuously mid-shopping-session.
|
||||
- Bind-mounted files (Caddyfile, etc.) require `docker compose restart caddy` after editing. The deploy script's `up -d` only recreates services whose compose definition changed — a Caddyfile-only edit will silently keep the old config loaded.
|
||||
- CSP `connect-src` must include both `https://erosi.limonia.net` and `wss://erosi.limonia.net`.
|
||||
|
||||
**Keycloak behind nginx:**
|
||||
- `X-Forwarded-Host` and `X-Forwarded-Port` headers are mandatory. Without them, Keycloak builds redirect URIs with the internal port (8080) instead of 443, breaking the OIDC flow.
|
||||
**External Keycloak:**
|
||||
- Keycloak runs outside this stack; `PUBLIC_KEYCLOAK_URL` in `.env` points at it. Whatever proxy sits in front of it must forward `X-Forwarded-{Host,Port,Proto}` or Keycloak builds redirect URIs with internal ports and the OIDC flow breaks — that's the external operator's concern, not ours.
|
||||
- The realm + client configuration required on the external Keycloak is documented in `docs/deployment.md` and mirrored in `keycloak/realm-export.erosi.json` (kept as reference; not imported by this stack). Realm name, client id, and client secret are operator-chosen and wired in via `.env` — the file's literal `colectivo-web` / `colectivo` values are illustrative, not required.
|
||||
- **Legacy Keycloak `/auth/` base path**: Keycloak ≤ 16 (and some later distributions) serve realms under `/auth/realms/...` instead of `/realms/...`. In that case `PUBLIC_KEYCLOAK_URL` must include the suffix, e.g. `https://auth.example.com/auth`. Symptom of a missing `/auth` suffix: `GOTRUE_EXTERNAL_KEYCLOAK_URL/...well-known/openid-configuration` returns 404; the Supabase login redirect lands on a Keycloak error page. Verify before deploying: `curl -I ${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/.well-known/openid-configuration` must return 200.
|
||||
|
||||
**Supabase Realtime self-hosted:**
|
||||
- Check `MAX_REPLICATION_SLOTS` in PostgreSQL and `REALTIME_MAX_CONNECTIONS` before going to production.
|
||||
|
||||
57
Justfile
57
Justfile
@@ -47,9 +47,20 @@ db-migrate:
|
||||
db-seed:
|
||||
docker exec -i colectivo-dev-db-1 psql -U postgres < supabase/seed.sql
|
||||
|
||||
# Regenerate TypeScript types from Supabase schema
|
||||
# Regenerate TypeScript types from Supabase schema.
|
||||
# Writes to a tempfile first so a missing/failing `supabase` CLI does not
|
||||
# truncate the existing hand-curated database.ts (which would silently
|
||||
# break every TS consumer in the monorepo).
|
||||
db-types:
|
||||
supabase gen types typescript --local > packages/types/src/database.ts
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if ! command -v supabase >/dev/null 2>&1; then
|
||||
echo "supabase CLI not installed — database.ts is hand-curated, edit it manually" >&2
|
||||
exit 1
|
||||
fi
|
||||
tmp=$(mktemp)
|
||||
supabase gen types typescript --local > "$tmp"
|
||||
mv "$tmp" packages/types/src/database.ts
|
||||
echo "Types written to packages/types/src/database.ts"
|
||||
|
||||
# Open Supabase Studio
|
||||
@@ -83,8 +94,9 @@ serve: build
|
||||
{{dc_dev}} up -d
|
||||
PORT=3000 node apps/web/build/index.js
|
||||
|
||||
# Type-check all packages
|
||||
# Type-check all packages (also lints CHANGELOG.md — Fase 17.4.3)
|
||||
check:
|
||||
node scripts/check-changelog.mjs
|
||||
pnpm turbo run check
|
||||
|
||||
# Lint all packages
|
||||
@@ -121,7 +133,14 @@ test-db:
|
||||
test-integration:
|
||||
pnpm --filter @colectivo/test-utils test
|
||||
|
||||
# Run Playwright E2E tests (headless)
|
||||
# Install Playwright browsers (chromium + webkit). WebKit is required for
|
||||
# touch-gesture specs (Fase 9.4 swipe-toggle) — the *.webkit.test.ts files
|
||||
# are gated to that project in playwright.config.ts.
|
||||
playwright-install:
|
||||
pnpm --filter @colectivo/web exec playwright install chromium webkit
|
||||
|
||||
# Run Playwright E2E tests (headless). Includes the WebKit project — make
|
||||
# sure `just playwright-install` has run at least once.
|
||||
test-e2e:
|
||||
pnpm --filter @colectivo/web exec playwright test
|
||||
|
||||
@@ -129,6 +148,13 @@ test-e2e:
|
||||
test-e2e-headed:
|
||||
pnpm --filter @colectivo/web exec playwright test --headed
|
||||
|
||||
# Run only the WebKit (Mobile Safari) touch-gesture specs (Fase 9.4).
|
||||
# Gated behind RUN_WEBKIT=1 in playwright.config so it stays out of the
|
||||
# default test-all (WebKit needs system libs not available on every host
|
||||
# — see `just playwright-install` and `sudo pnpm exec playwright install-deps`).
|
||||
test-webkit:
|
||||
RUN_WEBKIT=1 pnpm --filter @colectivo/web exec playwright test --project=webkit
|
||||
|
||||
# Run Kong rate-limit verification tests. Restarts Kong first to ensure a
|
||||
# clean counter state (tests burn through the minute + hour quotas). Not
|
||||
# part of test-all because the tests share the rate-limit counter with the
|
||||
@@ -178,9 +204,28 @@ restore service file:
|
||||
|
||||
# ── Production ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Deploy to production via SSH
|
||||
# Deploy to production via SSH (ambrosio / erosi.limonia.net).
|
||||
# Pre-deploy: dumps the prod DB to /opt/colectivo/backups/predeploy-<ts>-<sha>.sql.gz
|
||||
# Post-deploy: appends (timestamp, sha, backup) to /opt/colectivo/.deploys.log
|
||||
deploy:
|
||||
infra/scripts/deploy.sh
|
||||
infra/scripts/deploy-erosi.sh
|
||||
|
||||
# List recent prod deploys (timestamp / sha / backup-file).
|
||||
rollback-list:
|
||||
infra/scripts/rollback-erosi.sh --list
|
||||
|
||||
# Roll back code AND database to the previous deploy.
|
||||
# For a specific deploy: `just rollback-to <sha>`.
|
||||
rollback:
|
||||
infra/scripts/rollback-erosi.sh --previous
|
||||
|
||||
# Roll back to a specific deploy by sha (must exist in .deploys.log on prod).
|
||||
rollback-to sha:
|
||||
infra/scripts/rollback-erosi.sh --to={{sha}}
|
||||
|
||||
# Roll back only code (skip DB restore). Useful if the regression is UI-only.
|
||||
rollback-code:
|
||||
infra/scripts/rollback-erosi.sh --previous --code-only
|
||||
|
||||
# Stream production Docker logs
|
||||
logs:
|
||||
|
||||
35
README.md
35
README.md
@@ -16,8 +16,24 @@
|
||||
| Fase 3 — Tareas y Notas | ✅ Completa |
|
||||
| Fase 4 — Búsqueda y Pulido | ✅ Búsqueda + RLS audit completos; PWA install/rate-limit/JWT rotation cerrados en Fase 6 |
|
||||
| Fase 5 — Rediseño UX Mobile | ✅ Completa en scope automatizable (shell + masthead + big-button create + row redesign + selection mode + sticky detail chrome); presence avatars en cards + M-swipe/SEL-long-press touch E2E diferidos a WebKit install |
|
||||
| Fase 6 — Deploy readiness | ✅ Completa (PWA injectManifest + manifest + iconos placeholder + Kong rate-limit en invitaciones + rotación JWT dev + rotate-jwt.sh + `.env.production.example` + rls-audit.sh + `just lighthouse`). Rate-limit auth retirado — Keycloak brute-force nativa cubre el caso. Push notifications y iconos finales fuera de scope. |
|
||||
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ 236 tests verdes (34 pgTAP + 140 integración + 15 unit + 46 E2E) + 1 rate-limit gated (`just test-rate-limit`). 3 skipped en `just test-all`. |
|
||||
| Fase 6 — Deploy readiness | ✅ Completa (PWA generateSW + manifest + iconos placeholder + Kong rate-limit en invitaciones + rotación JWT dev + rotate-jwt.sh + `.env.erosi.example` + rls-audit.sh + `just lighthouse`). Rate-limit auth retirado — Keycloak brute-force nativa cubre el caso. Push notifications y iconos finales fuera de scope. |
|
||||
| Fase 7 — Collective-flow E2E tests | ✅ Completa (12 Playwright nuevos O-01..MC-05; 3 bugs latentes corregidos: RPC atómica `create_collective`, restore de `pendingInvitationToken` tras login, hidratación tardía de `$currentCollective` en manage; migración 013 trigger `role/aud` default). |
|
||||
| Fase 8 — Prod auth hardening | ✅ Completa (migración 014 extiende `role/aud` guard a UPDATE; `logout()` RP-initiated Keycloak end-session + ruta pública `/logged-out`). |
|
||||
| **MVP2** — Fase 9 → 16 | ✅ Completa (rama `mvp2`, 2026-05-18) |
|
||||
| ↳ Fase 9 — Polish + Dark Mode | ✅ |
|
||||
| ↳ Fase 10 — Spec completion | ✅ |
|
||||
| ↳ Fase 11 — Item tags + importance UX | ✅ |
|
||||
| ↳ Fase 12 — Section visibility | ✅ |
|
||||
| ↳ Fase 13 — Server administration | ✅ |
|
||||
| ↳ Fase 14 — PWA hardening | ✅ |
|
||||
| ↳ Fase 15 — Common items management | ✅ |
|
||||
| ↳ Fase 16 — Loading spinner | ✅ |
|
||||
| **v1.0** — Fase 17 → 20 | ✅ Completa (rama `mvp2`, tag `v1.0.0`, 2026-05-19) |
|
||||
| ↳ Fase 17 — Versioning + CHANGELOG | ✅ |
|
||||
| ↳ Fase 18 — Shopping list flow | ✅ |
|
||||
| ↳ Fase 19 — Emoji avatars | ✅ |
|
||||
| ↳ Fase 20 — Euskera locale | ✅ (`v1.0.0`) |
|
||||
| Suite de tests (pgTAP + Vitest + Playwright) | ✅ **581 tests verdes** (196 pgTAP + 187 integración + 89 unit + 108 E2E) + 1 rate-limit gated (`just test-rate-limit`). 3 skipped en `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). |
|
||||
|
||||
---
|
||||
|
||||
@@ -43,8 +59,21 @@ Las fases ya cerradas (0, 1, 2a) reflejan este patrón retroactivamente: la secc
|
||||
| Fase 4 | [fase-4-busqueda-pulido.md](plan/fase-4-busqueda-pulido.md) | 1 semana |
|
||||
| Fase 5 | [fase-5-mobile-ux.md](plan/fase-5-mobile-ux.md) | 1–2 semanas |
|
||||
| Fase 6 | [fase-6-deploy-prep.md](plan/fase-6-deploy-prep.md) | 3–5 días |
|
||||
| Fase 7 | [fase-7-collective-flow-tests.md](plan/fase-7-collective-flow-tests.md) | 1 semana |
|
||||
| **MVP2 — Fase 9** | [fase-9-polish-dark-mode.md](plan/fase-9-polish-dark-mode.md) | 1–2 semanas |
|
||||
| **MVP2 — Fase 10** | [fase-10-spec-completion.md](plan/fase-10-spec-completion.md) | 2–3 semanas |
|
||||
| **MVP2 — Fase 11** | [fase-11-item-tags-importance.md](plan/fase-11-item-tags-importance.md) | 1–2 semanas |
|
||||
| **MVP2 — Fase 12** | [fase-12-section-visibility.md](plan/fase-12-section-visibility.md) | 1 semana |
|
||||
| **MVP2 — Fase 13** | [fase-13-server-admin.md](plan/fase-13-server-admin.md) | 2–3 semanas |
|
||||
| **MVP2 — Fase 14** | [fase-14-pwa-hardening.md](plan/fase-14-pwa-hardening.md) | 3–5 días |
|
||||
| **MVP2 — Fase 15** | [fase-15-common-items.md](plan/fase-15-common-items.md) | 3–5 días |
|
||||
| **MVP2 — Fase 16** | [fase-16-spinner.md](plan/fase-16-spinner.md) | 1 día |
|
||||
| **v1.0 — Fase 17** | [fase-17-versioning-changelog.md](plan/fase-17-versioning-changelog.md) | 2–3 días |
|
||||
| **v1.0 — Fase 18** | [fase-18-shopping-list-flow.md](plan/fase-18-shopping-list-flow.md) | 3–5 días |
|
||||
| **v1.0 — Fase 19** | [fase-19-emoji-avatars.md](plan/fase-19-emoji-avatars.md) | 1–2 días |
|
||||
| **v1.0 — Fase 20** | [fase-20-euskera-locale.md](plan/fase-20-euskera-locale.md) | 1–2 días (+ review humana) |
|
||||
|
||||
**Total estimado: 11–16 semanas (con Fase 6 deploy prep)**
|
||||
**Total estimado MVP (Fase 0–6): 11–16 semanas. MVP2 (Fase 9–14, rama `mvp2`): 10–14 semanas. Fase 7+8 entre MVP y MVP2 como cleanup post-launch.**
|
||||
|
||||
Los mockups de referencia viven en [`design/`](design/) (organizados por pantalla — ver `design/slate_collective/DESIGN.md` para la dirección "Monolith Editorial").
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ ARG PUBLIC_KEYCLOAK_URL
|
||||
ARG PUBLIC_KEYCLOAK_REALM
|
||||
ARG PUBLIC_KEYCLOAK_CLIENT_ID
|
||||
ARG PUBLIC_APP_URL
|
||||
# Fase 14.3.5 — short git sha of the deploy. Surfaced in /settings via
|
||||
# `$lib/version`. Optional: when unset the build falls back to `git
|
||||
# rev-parse` (only works if .git is in the build context) and then to
|
||||
# the literal 'dev'.
|
||||
ARG GIT_SHA
|
||||
|
||||
ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL
|
||||
ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY
|
||||
@@ -29,6 +34,7 @@ ENV PUBLIC_KEYCLOAK_URL=$PUBLIC_KEYCLOAK_URL
|
||||
ENV PUBLIC_KEYCLOAK_REALM=$PUBLIC_KEYCLOAK_REALM
|
||||
ENV PUBLIC_KEYCLOAK_CLIENT_ID=$PUBLIC_KEYCLOAK_CLIENT_ID
|
||||
ENV PUBLIC_APP_URL=$PUBLIC_APP_URL
|
||||
ENV GIT_SHA=$GIT_SHA
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
|
||||
@@ -164,6 +164,25 @@
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"done": "Done",
|
||||
"pwa_update_available": "New version available",
|
||||
"pwa_update_reload": "Reload",
|
||||
"pwa_offline_ready": "Ready to use offline",
|
||||
"pwa_offline_chip": "Offline",
|
||||
"pwa_offline_tooltip": "No connection — changes will sync when you reconnect",
|
||||
"pwa_pending_ops": "{count} pending change",
|
||||
"pwa_pending_ops_plural": "{count} pending changes",
|
||||
"pwa_sync_conflicts_banner": "Sync conflicts need attention",
|
||||
"pwa_sync_conflicts_review": "Review",
|
||||
"sync_conflicts_title": "Sync conflicts",
|
||||
"sync_conflicts_empty": "Nothing pending — local and server agree.",
|
||||
"sync_conflicts_discard_local": "Discard local",
|
||||
"sync_conflicts_discard_remote": "Discard remote",
|
||||
"sync_conflicts_back": "Back to settings",
|
||||
"settings_about": "About",
|
||||
"settings_about_version": "v{version} · {commit} · {date}",
|
||||
"settings_about_view_changelog": "View changelog",
|
||||
"changelog_modal_title": "Changelog",
|
||||
"changelog_modal_close": "Close",
|
||||
"sync_offline": "You're offline — changes will sync when you reconnect",
|
||||
"sync_syncing": "Syncing…",
|
||||
"undo": "Undo",
|
||||
@@ -183,5 +202,164 @@
|
||||
"list_bulk_mark_checked": "Mark checked",
|
||||
"list_undo_bulk_delete": "Deleted {n} items",
|
||||
"list_move_title": "Move to list",
|
||||
"list_move_create_new": "Create new list"
|
||||
"list_move_create_new": "Create new list",
|
||||
"settings_appearance": "Appearance",
|
||||
"settings_theme_light": "Light",
|
||||
"settings_theme_dark": "Dark",
|
||||
"settings_theme_system": "System",
|
||||
"settings_sync_conflicts": "Sync conflicts",
|
||||
"settings_sync_conflicts_empty": "No conflicts logged.",
|
||||
"settings_sync_conflicts_export": "Export JSON",
|
||||
"settings_collectives": "Your collectives",
|
||||
"settings_collectives_empty": "You don't belong to any collective.",
|
||||
"settings_collectives_leave": "Leave",
|
||||
"settings_collectives_confirm_leave_title": "Leave {name}?",
|
||||
"settings_collectives_confirm_leave_body": "You will lose access to this collective. The content stays for the other members.",
|
||||
"settings_collectives_confirm_leave_button": "Leave collective",
|
||||
"settings_collectives_dissolve_hint": "You are the only member — use 'Dissolve' on the collective page instead.",
|
||||
"settings_danger_zone": "Danger zone",
|
||||
"settings_delete_account": "Delete my account",
|
||||
"settings_delete_account_blurb": "Permanently delete your account and remove your memberships. Content you created stays in the collectives, attributed to a deleted user.",
|
||||
"settings_delete_account_modal_title": "Delete your account?",
|
||||
"settings_delete_account_modal_body": "This is permanent. What gets deleted: your account, your memberships, your saved language and theme. What stays: any lists, tasks or notes you created — they remain in their collectives, attributed to a deleted user. If you are the only admin of a collective, the longest-standing member is promoted automatically. Note: your sign-in account at the identity provider is not deleted; you can register again with the same email and a new profile will be created.",
|
||||
"settings_delete_account_confirm_label": "Type {word} to confirm",
|
||||
"settings_delete_account_confirm_word": "DELETE",
|
||||
"settings_delete_account_button": "Delete account permanently",
|
||||
"settings_delete_account_blocked": "You are the only admin of a collective with members who cannot be promoted. Promote someone first, or remove the other members.",
|
||||
"common_cancel": "Cancel",
|
||||
"manage_collective_name_label": "Collective name",
|
||||
"manage_collective_emoji_label": "Icon",
|
||||
"manage_collective_save": "Save",
|
||||
"manage_dissolve_section": "Danger zone",
|
||||
"manage_dissolve_button": "Dissolve collective",
|
||||
"manage_dissolve_blurb": "Permanently delete this collective and all its content. This cannot be undone.",
|
||||
"manage_dissolve_title": "Dissolve {name}?",
|
||||
"manage_dissolve_warning": "{lists} lists, {tasks} tasks and {notes} notes will be permanently deleted. This is irreversible.",
|
||||
"manage_dissolve_confirm_label": "Type {name} to confirm",
|
||||
"manage_dissolve_confirm_button": "Dissolve permanently",
|
||||
"sidebar_create_collective": "+ New collective",
|
||||
"create_collective_modal_title": "New collective",
|
||||
"create_collective_modal_button": "Create",
|
||||
"tag_picker_placeholder": "Search or create tag",
|
||||
"tag_picker_create": "Create \"{name}\"",
|
||||
"tag_picker_empty": "No tags yet.",
|
||||
"tag_picker_label": "Tags",
|
||||
"list_filter_by_tag": "Filter by tag",
|
||||
"list_filter_clear": "Clear",
|
||||
"settings_tags_section": "Tags",
|
||||
"settings_tags_empty": "No tags yet — create one from any list.",
|
||||
"settings_tags_delete": "Delete",
|
||||
"settings_tags_color": "Color",
|
||||
"section_disabled_for_collective": "That section is hidden for this collective.",
|
||||
"settings_section_visibility_title": "Visible sections",
|
||||
"settings_section_visibility_blurb": "Hide top-level sections you don't use. Your admin can also hide them for the whole collective.",
|
||||
"settings_section_visibility_overridden": "Hidden by the collective.",
|
||||
"collective_section_visibility_title": "Collective sections",
|
||||
"collective_section_visibility_blurb": "Hide sections for every member of this collective. Members can also hide them individually for themselves.",
|
||||
"section_label_lists": "Lists",
|
||||
"section_label_tasks": "Tasks",
|
||||
"section_label_notes": "Notes",
|
||||
"section_label_search": "Search",
|
||||
"admin_banner": "Admin mode — actions are logged",
|
||||
"admin_nav_collectives": "Collectives",
|
||||
"admin_nav_admins": "Admins",
|
||||
"admin_nav_audit": "Audit log",
|
||||
"admin_nav_server": "Server",
|
||||
"admin_menu_entry": "Server admin",
|
||||
"admin_collectives_title": "Collectives",
|
||||
"admin_collectives_search_placeholder": "Search by name…",
|
||||
"admin_collectives_empty": "No collectives match.",
|
||||
"admin_collectives_col_name": "Name",
|
||||
"admin_collectives_col_members": "Members",
|
||||
"admin_collectives_col_created": "Created",
|
||||
"admin_collectives_col_status": "Status",
|
||||
"admin_collectives_status_active": "Active",
|
||||
"admin_collectives_status_deleted": "Soft-deleted",
|
||||
"admin_action_view": "View",
|
||||
"admin_action_soft_delete": "Soft-delete",
|
||||
"admin_action_restore": "Restore",
|
||||
"admin_action_hard_delete": "Hard-delete",
|
||||
"admin_action_remove": "Remove",
|
||||
"admin_modal_soft_delete_title": "Soft-delete collective",
|
||||
"admin_modal_soft_delete_help": "The collective is hidden from members but recoverable for 30 days. Reason is logged.",
|
||||
"admin_modal_reason_label": "Reason",
|
||||
"admin_modal_reason_placeholder": "Why?",
|
||||
"admin_modal_hard_delete_title": "Hard-delete collective",
|
||||
"admin_modal_hard_delete_help": "This is irreversible. All lists, items, tasks, and notes will be deleted.",
|
||||
"admin_modal_hard_delete_confirm": "I understand this is irreversible",
|
||||
"admin_modal_hard_delete_force_label": "Force (bypass 30-day wait)",
|
||||
"admin_modal_cancel": "Cancel",
|
||||
"admin_modal_confirm": "Confirm",
|
||||
"admin_collective_detail_back": "Back to collectives",
|
||||
"admin_collective_detail_members": "Members",
|
||||
"admin_collective_detail_recent_actions": "Recent actions",
|
||||
"admin_modal_remove_member_title": "Remove member",
|
||||
"admin_modal_remove_member_help": "Membership is revoked immediately. Reason is logged.",
|
||||
"admin_admins_title": "Server admins",
|
||||
"admin_admins_promote": "Promote user",
|
||||
"admin_admins_promote_help": "Enter the email of the user to promote.",
|
||||
"admin_admins_email_label": "Email",
|
||||
"admin_admins_email_placeholder": "user@example.com",
|
||||
"admin_admins_email_not_found": "No user with that email.",
|
||||
"admin_admins_revoke_self": "you (cannot revoke yourself while sole admin)",
|
||||
"admin_admins_revoke": "Revoke",
|
||||
"admin_audit_title": "Audit log",
|
||||
"admin_audit_empty": "No actions logged yet.",
|
||||
"admin_audit_col_when": "When",
|
||||
"admin_audit_col_actor": "Actor",
|
||||
"admin_audit_col_action": "Action",
|
||||
"admin_audit_col_target": "Target",
|
||||
"admin_audit_col_payload": "Payload",
|
||||
"admin_server_title": "Server settings",
|
||||
"admin_server_default_sections": "Default section visibility",
|
||||
"admin_server_default_sections_help": "Server-level overrides. ON or OFF here wins over every collective and user override.",
|
||||
"admin_server_section_state_off": "OFF",
|
||||
"admin_server_section_state_on": "ON",
|
||||
"admin_server_section_state_unset": "No opinion",
|
||||
"admin_server_info": "Server info",
|
||||
"admin_error_forbidden": "You don't have access to this area.",
|
||||
"admin_error_unknown": "Something went wrong. Please try again.",
|
||||
"common_items_title": "Common items",
|
||||
"common_items_blurb": "Promote items that always appear first in suggestions, or hide noise. Members see the catalogue but cannot edit it.",
|
||||
"common_items_search_placeholder": "Search items…",
|
||||
"common_items_empty": "No common items yet. Items added to lists will appear here.",
|
||||
"common_items_empty_filtered": "No items match your search.",
|
||||
"common_items_col_name": "Name",
|
||||
"common_items_col_weight": "Visibility",
|
||||
"common_items_col_uses": "Uses",
|
||||
"common_items_col_last_used": "Last used",
|
||||
"common_items_col_actions": "Actions",
|
||||
"common_items_state_hide": "Hide",
|
||||
"common_items_state_normal": "Normal",
|
||||
"common_items_state_boost": "Boost",
|
||||
"common_items_state_locked_tooltip": "Only admins can manage common items.",
|
||||
"common_items_purge_button": "Remove",
|
||||
"common_items_purge_modal_title": "Remove {name}?",
|
||||
"common_items_purge_modal_body": "This only removes the suggestion entry. Items already in lists are not affected.",
|
||||
"common_items_purge_confirm": "Remove",
|
||||
"common_items_add_button": "Add common item",
|
||||
"common_items_add_modal_title": "Add common item",
|
||||
"common_items_add_name_label": "Item name",
|
||||
"common_items_add_name_placeholder": "e.g. olive oil",
|
||||
"common_items_add_visibility_label": "Visibility",
|
||||
"common_items_add_save": "Add",
|
||||
"create_list_modal_title": "New shopping list",
|
||||
"create_list_input_label": "List name",
|
||||
"create_list_input_placeholder": "e.g. Weekly shop",
|
||||
"create_list_submit": "Create",
|
||||
"create_list_suggestion_chip": "Suggestion: {title}",
|
||||
"create_list_auto_numbered_toast": "Created as “{title}”",
|
||||
"manage_list_titles_section_title": "Suggested titles",
|
||||
"manage_list_titles_blurb": "Pre-set a default title for new lists and curate suggestions admins want to surface.",
|
||||
"manage_default_list_title_label": "Default new-list title",
|
||||
"manage_default_list_title_placeholder": "e.g. Weekly shop",
|
||||
"manage_default_list_title_clear": "Clear",
|
||||
"manage_list_titles_empty": "No curated titles yet.",
|
||||
"manage_list_titles_add_button": "Add title",
|
||||
"manage_list_titles_add_modal_title": "Add suggested title",
|
||||
"manage_list_titles_add_name_label": "Title",
|
||||
"manage_list_titles_add_name_placeholder": "e.g. Compra",
|
||||
"manage_list_titles_add_save": "Add",
|
||||
"manage_list_titles_remove": "Remove",
|
||||
"manage_list_titles_readonly": "Only admins can curate these titles."
|
||||
}
|
||||
|
||||
@@ -164,6 +164,25 @@
|
||||
"edit": "Editar",
|
||||
"add": "Añadir",
|
||||
"done": "Listo",
|
||||
"pwa_update_available": "Nueva versión disponible",
|
||||
"pwa_update_reload": "Recargar",
|
||||
"pwa_offline_ready": "Lista para usar offline",
|
||||
"pwa_offline_chip": "Sin conexión",
|
||||
"pwa_offline_tooltip": "Sin conexión — los cambios se sincronizarán al volver",
|
||||
"pwa_pending_ops": "{count} cambio pendiente",
|
||||
"pwa_pending_ops_plural": "{count} cambios pendientes",
|
||||
"pwa_sync_conflicts_banner": "Hay conflictos de sincronización",
|
||||
"pwa_sync_conflicts_review": "Revisar",
|
||||
"sync_conflicts_title": "Conflictos de sincronización",
|
||||
"sync_conflicts_empty": "Nada pendiente — local y servidor coinciden.",
|
||||
"sync_conflicts_discard_local": "Descartar local",
|
||||
"sync_conflicts_discard_remote": "Descartar remoto",
|
||||
"sync_conflicts_back": "Volver a ajustes",
|
||||
"settings_about": "Acerca de",
|
||||
"settings_about_version": "v{version} · {commit} · {date}",
|
||||
"settings_about_view_changelog": "Ver historial",
|
||||
"changelog_modal_title": "Historial de cambios",
|
||||
"changelog_modal_close": "Cerrar",
|
||||
"sync_offline": "Sin conexión — los cambios se sincronizarán al reconectar",
|
||||
"sync_syncing": "Sincronizando…",
|
||||
"undo": "Deshacer",
|
||||
@@ -183,5 +202,164 @@
|
||||
"list_bulk_mark_checked": "Marcar",
|
||||
"list_undo_bulk_delete": "{n} eliminados",
|
||||
"list_move_title": "Mover a lista",
|
||||
"list_move_create_new": "Crear lista nueva"
|
||||
"list_move_create_new": "Crear lista nueva",
|
||||
"settings_appearance": "Apariencia",
|
||||
"settings_theme_light": "Claro",
|
||||
"settings_theme_dark": "Oscuro",
|
||||
"settings_theme_system": "Sistema",
|
||||
"settings_sync_conflicts": "Conflictos de sincronización",
|
||||
"settings_sync_conflicts_empty": "Sin conflictos registrados.",
|
||||
"settings_sync_conflicts_export": "Exportar JSON",
|
||||
"settings_collectives": "Tus colectivos",
|
||||
"settings_collectives_empty": "No perteneces a ningún colectivo.",
|
||||
"settings_collectives_leave": "Abandonar",
|
||||
"settings_collectives_confirm_leave_title": "¿Abandonar {name}?",
|
||||
"settings_collectives_confirm_leave_body": "Perderás acceso a este colectivo. El contenido se queda para los demás miembros.",
|
||||
"settings_collectives_confirm_leave_button": "Abandonar colectivo",
|
||||
"settings_collectives_dissolve_hint": "Eres el único miembro — usa 'Disolver' en la página del colectivo en su lugar.",
|
||||
"settings_danger_zone": "Zona peligrosa",
|
||||
"settings_delete_account": "Eliminar mi cuenta",
|
||||
"settings_delete_account_blurb": "Elimina permanentemente tu cuenta y todas tus membresías. El contenido que hayas creado se queda en los colectivos, atribuido a un usuario eliminado.",
|
||||
"settings_delete_account_modal_title": "¿Eliminar tu cuenta?",
|
||||
"settings_delete_account_modal_body": "Esto es permanente. Lo que se borra: tu cuenta, tus membresías, tu idioma y tema guardados. Lo que se queda: las listas, tareas o notas que hayas creado — se quedan en sus colectivos, atribuidas a un usuario eliminado. Si eres el único admin de algún colectivo, el miembro más antiguo será promovido automáticamente. Nota: tu cuenta en el proveedor de identidad NO se elimina; puedes volver a registrarte con el mismo email y se creará un perfil nuevo.",
|
||||
"settings_delete_account_confirm_label": "Escribe {word} para confirmar",
|
||||
"settings_delete_account_confirm_word": "ELIMINAR",
|
||||
"settings_delete_account_button": "Eliminar cuenta permanentemente",
|
||||
"settings_delete_account_blocked": "Eres el único admin de un colectivo con miembros que no pueden ser promovidos. Promueve a alguien primero o elimina a los demás miembros.",
|
||||
"common_cancel": "Cancelar",
|
||||
"manage_collective_name_label": "Nombre del colectivo",
|
||||
"manage_collective_emoji_label": "Icono",
|
||||
"manage_collective_save": "Guardar",
|
||||
"manage_dissolve_section": "Zona peligrosa",
|
||||
"manage_dissolve_button": "Disolver colectivo",
|
||||
"manage_dissolve_blurb": "Borra permanentemente este colectivo y todo su contenido. No se puede deshacer.",
|
||||
"manage_dissolve_title": "¿Disolver {name}?",
|
||||
"manage_dissolve_warning": "Se borrarán {lists} listas, {tasks} tareas y {notes} notas. Esta acción es irreversible.",
|
||||
"manage_dissolve_confirm_label": "Escribe {name} para confirmar",
|
||||
"manage_dissolve_confirm_button": "Disolver permanentemente",
|
||||
"sidebar_create_collective": "+ Nuevo colectivo",
|
||||
"create_collective_modal_title": "Nuevo colectivo",
|
||||
"create_collective_modal_button": "Crear",
|
||||
"tag_picker_placeholder": "Buscar o crear etiqueta",
|
||||
"tag_picker_create": "Crear «{name}»",
|
||||
"tag_picker_empty": "Aún no hay etiquetas.",
|
||||
"tag_picker_label": "Etiquetas",
|
||||
"list_filter_by_tag": "Filtrar por etiqueta",
|
||||
"list_filter_clear": "Limpiar",
|
||||
"settings_tags_section": "Etiquetas",
|
||||
"settings_tags_empty": "Aún no hay etiquetas — créalas desde cualquier lista.",
|
||||
"settings_tags_delete": "Eliminar",
|
||||
"settings_tags_color": "Color",
|
||||
"section_disabled_for_collective": "Esta sección está oculta para este colectivo.",
|
||||
"settings_section_visibility_title": "Secciones visibles",
|
||||
"settings_section_visibility_blurb": "Oculta las secciones de nivel superior que no usas. Tu admin también puede ocultarlas para todo el colectivo.",
|
||||
"settings_section_visibility_overridden": "Oculta por el colectivo.",
|
||||
"collective_section_visibility_title": "Secciones del colectivo",
|
||||
"collective_section_visibility_blurb": "Oculta secciones para todos los miembros de este colectivo. Cada miembro también puede ocultarlas para sí.",
|
||||
"section_label_lists": "Listas",
|
||||
"section_label_tasks": "Tareas",
|
||||
"section_label_notes": "Notas",
|
||||
"section_label_search": "Buscar",
|
||||
"admin_banner": "Modo admin — las acciones se registran",
|
||||
"admin_nav_collectives": "Colectivos",
|
||||
"admin_nav_admins": "Admins",
|
||||
"admin_nav_audit": "Registro",
|
||||
"admin_nav_server": "Servidor",
|
||||
"admin_menu_entry": "Admin del servidor",
|
||||
"admin_collectives_title": "Colectivos",
|
||||
"admin_collectives_search_placeholder": "Buscar por nombre…",
|
||||
"admin_collectives_empty": "Ningún colectivo coincide.",
|
||||
"admin_collectives_col_name": "Nombre",
|
||||
"admin_collectives_col_members": "Miembros",
|
||||
"admin_collectives_col_created": "Creado",
|
||||
"admin_collectives_col_status": "Estado",
|
||||
"admin_collectives_status_active": "Activo",
|
||||
"admin_collectives_status_deleted": "Borrado",
|
||||
"admin_action_view": "Ver",
|
||||
"admin_action_soft_delete": "Soft-delete",
|
||||
"admin_action_restore": "Restaurar",
|
||||
"admin_action_hard_delete": "Eliminar",
|
||||
"admin_action_remove": "Expulsar",
|
||||
"admin_modal_soft_delete_title": "Soft-delete colectivo",
|
||||
"admin_modal_soft_delete_help": "El colectivo queda oculto para los miembros pero recuperable durante 30 días. Se registra el motivo.",
|
||||
"admin_modal_reason_label": "Motivo",
|
||||
"admin_modal_reason_placeholder": "¿Por qué?",
|
||||
"admin_modal_hard_delete_title": "Eliminar colectivo",
|
||||
"admin_modal_hard_delete_help": "Esto es irreversible. Se eliminarán todas las listas, ítems, tareas y notas.",
|
||||
"admin_modal_hard_delete_confirm": "Entiendo que es irreversible",
|
||||
"admin_modal_hard_delete_force_label": "Forzar (saltar espera de 30 días)",
|
||||
"admin_modal_cancel": "Cancelar",
|
||||
"admin_modal_confirm": "Confirmar",
|
||||
"admin_collective_detail_back": "Volver a colectivos",
|
||||
"admin_collective_detail_members": "Miembros",
|
||||
"admin_collective_detail_recent_actions": "Acciones recientes",
|
||||
"admin_modal_remove_member_title": "Expulsar miembro",
|
||||
"admin_modal_remove_member_help": "La membresía se revoca al momento. Se registra el motivo.",
|
||||
"admin_admins_title": "Admins del servidor",
|
||||
"admin_admins_promote": "Promover usuario",
|
||||
"admin_admins_promote_help": "Introduce el email del usuario a promover.",
|
||||
"admin_admins_email_label": "Email",
|
||||
"admin_admins_email_placeholder": "usuario@ejemplo.com",
|
||||
"admin_admins_email_not_found": "No hay ningún usuario con ese email.",
|
||||
"admin_admins_revoke_self": "tú (no puedes auto-revocarte si eres el único admin)",
|
||||
"admin_admins_revoke": "Revocar",
|
||||
"admin_audit_title": "Registro de acciones",
|
||||
"admin_audit_empty": "Aún no hay acciones registradas.",
|
||||
"admin_audit_col_when": "Cuándo",
|
||||
"admin_audit_col_actor": "Actor",
|
||||
"admin_audit_col_action": "Acción",
|
||||
"admin_audit_col_target": "Objetivo",
|
||||
"admin_audit_col_payload": "Payload",
|
||||
"admin_server_title": "Configuración del servidor",
|
||||
"admin_server_default_sections": "Visibilidad por defecto de secciones",
|
||||
"admin_server_default_sections_help": "Override a nivel servidor. ON u OFF aquí prevalece sobre cualquier colectivo o usuario.",
|
||||
"admin_server_section_state_off": "OFF",
|
||||
"admin_server_section_state_on": "ON",
|
||||
"admin_server_section_state_unset": "Sin opinión",
|
||||
"admin_server_info": "Información del servidor",
|
||||
"admin_error_forbidden": "No tienes acceso a esta zona.",
|
||||
"admin_error_unknown": "Algo ha ido mal. Inténtalo de nuevo.",
|
||||
"common_items_title": "Items frecuentes",
|
||||
"common_items_blurb": "Promueve items que siempre aparezcan primero en las sugerencias, u oculta los que no quieras ver. Los miembros ven el catálogo pero no pueden editarlo.",
|
||||
"common_items_search_placeholder": "Buscar items…",
|
||||
"common_items_empty": "Aún no hay items frecuentes. Los items que se añadan a las listas aparecerán aquí.",
|
||||
"common_items_empty_filtered": "Ningún item coincide con tu búsqueda.",
|
||||
"common_items_col_name": "Nombre",
|
||||
"common_items_col_weight": "Visibilidad",
|
||||
"common_items_col_uses": "Usos",
|
||||
"common_items_col_last_used": "Último uso",
|
||||
"common_items_col_actions": "Acciones",
|
||||
"common_items_state_hide": "Ocultar",
|
||||
"common_items_state_normal": "Normal",
|
||||
"common_items_state_boost": "Destacar",
|
||||
"common_items_state_locked_tooltip": "Solo los admins pueden gestionar items frecuentes.",
|
||||
"common_items_purge_button": "Eliminar",
|
||||
"common_items_purge_modal_title": "¿Eliminar {name}?",
|
||||
"common_items_purge_modal_body": "Esto solo elimina la entrada de sugerencias. Los items que ya están en listas no se ven afectados.",
|
||||
"common_items_purge_confirm": "Eliminar",
|
||||
"common_items_add_button": "Añadir item común",
|
||||
"common_items_add_modal_title": "Añadir item común",
|
||||
"common_items_add_name_label": "Nombre del item",
|
||||
"common_items_add_name_placeholder": "p. ej. aceite de oliva",
|
||||
"common_items_add_visibility_label": "Visibilidad",
|
||||
"common_items_add_save": "Añadir",
|
||||
"create_list_modal_title": "Nueva lista de la compra",
|
||||
"create_list_input_label": "Nombre de la lista",
|
||||
"create_list_input_placeholder": "p. ej. Compra semanal",
|
||||
"create_list_submit": "Crear",
|
||||
"create_list_suggestion_chip": "Sugerencia: {title}",
|
||||
"create_list_auto_numbered_toast": "Creada como «{title}»",
|
||||
"manage_list_titles_section_title": "Títulos sugeridos",
|
||||
"manage_list_titles_blurb": "Preconfigura un título por defecto para las nuevas listas y mantén una lista de sugerencias.",
|
||||
"manage_default_list_title_label": "Título por defecto",
|
||||
"manage_default_list_title_placeholder": "p. ej. Compra semanal",
|
||||
"manage_default_list_title_clear": "Borrar",
|
||||
"manage_list_titles_empty": "No hay títulos sugeridos todavía.",
|
||||
"manage_list_titles_add_button": "Añadir título",
|
||||
"manage_list_titles_add_modal_title": "Añadir título sugerido",
|
||||
"manage_list_titles_add_name_label": "Título",
|
||||
"manage_list_titles_add_name_placeholder": "p. ej. Compra",
|
||||
"manage_list_titles_add_save": "Añadir",
|
||||
"manage_list_titles_remove": "Borrar",
|
||||
"manage_list_titles_readonly": "Solo los admins pueden gestionar estos títulos."
|
||||
}
|
||||
|
||||
365
apps/web/messages/eu.json
Normal file
365
apps/web/messages/eu.json
Normal file
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_lists": "Zerrendak [needs review]",
|
||||
"nav_tasks": "Zereginak [needs review]",
|
||||
"nav_notes": "Oharrak [needs review]",
|
||||
"nav_search": "Bilatu [needs review]",
|
||||
"nav_settings": "Ezarpenak [needs review]",
|
||||
"nav_collective": "Kolektiboa [needs review]",
|
||||
"app_name": "Colectivo [needs review]",
|
||||
"loading": "Kargatzen… [needs review]",
|
||||
"error_generic": "Zerbait gaizki joan da. Saiatu berriro, mesedez. [needs review]",
|
||||
"login_title": "Sartu Colectivo-n [needs review]",
|
||||
"login_button": "Saioa hasi [needs review]",
|
||||
"logout_button": "Saioa itxi [needs review]",
|
||||
"auth_back_to_home": "Hasierara itzuli [needs review]",
|
||||
"logged_out_title": "Saioa itxita [needs review]",
|
||||
"logged_out_subtitle": "Hasi saioa berriro jarraitzeko. [needs review]",
|
||||
"logged_out_sign_in": "Saioa hasi [needs review]",
|
||||
"onboarding_title": "Ongi etorri Colectivo-ra [needs review]",
|
||||
"onboarding_subtitle": "Sortu kolektibo berri bat edo elkartu dagoen batekin. [needs review]",
|
||||
"onboarding_create_tab": "Sortu [needs review]",
|
||||
"onboarding_join_tab": "Elkartu [needs review]",
|
||||
"onboarding_create_collective": "Sortu kolektibo bat [needs review]",
|
||||
"onboarding_join_collective": "Elkartu gonbidapen esteka batekin [needs review]",
|
||||
"onboarding_collective_name": "Kolektiboaren izena [needs review]",
|
||||
"onboarding_collective_name_placeholder": "adib. Gure Etxea [needs review]",
|
||||
"onboarding_collective_emoji": "Ikonoa [needs review]",
|
||||
"onboarding_invite_link_label": "Gonbidapen esteka [needs review]",
|
||||
"onboarding_invite_link_placeholder": "Itsatsi hemen gonbidapen esteka… [needs review]",
|
||||
"onboarding_create_btn": "Sortu kolektiboa [needs review]",
|
||||
"onboarding_join_btn": "Elkartu kolektiboarekin [needs review]",
|
||||
"collective_name_label": "Kolektiboaren izena [needs review]",
|
||||
"collective_name_placeholder": "adib. Gure Etxea [needs review]",
|
||||
"invitation_title": "Gonbidapen bat duzu [needs review]",
|
||||
"invitation_subtitle": "Kolektibo batera elkartzeko gonbidatu zaituzte. [needs review]",
|
||||
"invitation_accept_btn": "Gonbidapena onartu [needs review]",
|
||||
"invitation_success": "Kolektiboari elkartu zara! [needs review]",
|
||||
"invitation_invalid_link": "Honek ez dirudi baliozko gonbidapen esteka. [needs review]",
|
||||
"invitation_error_not_found": "Gonbidapen esteka hau ez da baliozkoa. [needs review]",
|
||||
"invitation_error_already_used": "Gonbidapen hau dagoeneko erabili da. [needs review]",
|
||||
"invitation_error_expired": "Gonbidapen hau iraungita dago. [needs review]",
|
||||
"invitation_error_already_member": "Dagoeneko kolektibo honetako kide zara. [needs review]",
|
||||
"invitation_error_unauthenticated": "Gonbidapena onartzeko, saioa hasi behar duzu. [needs review]",
|
||||
"invitation_error_unknown": "Zerbait gaizki joan da. Saiatu berriro, mesedez. [needs review]",
|
||||
"manage_title": "Kolektiboa kudeatu [needs review]",
|
||||
"manage_members": "Kideak [needs review]",
|
||||
"manage_you": "zu [needs review]",
|
||||
"manage_remove": "Kidea ezabatu [needs review]",
|
||||
"manage_invite_title": "Inor gonbidatu [needs review]",
|
||||
"manage_invite_role": "Rola [needs review]",
|
||||
"manage_generate_link": "Gonbidapen esteka sortu [needs review]",
|
||||
"manage_copy": "Kopiatu [needs review]",
|
||||
"manage_copied": "Kopiatuta! [needs review]",
|
||||
"manage_link_expires": "Esteka 7 egunetan iraungiko da. [needs review]",
|
||||
"role_admin": "Administratzailea [needs review]",
|
||||
"role_member": "Kidea [needs review]",
|
||||
"role_guest": "Gonbidatua [needs review]",
|
||||
"settings_title": "Ezarpenak [needs review]",
|
||||
"settings_display_name": "Ikusgai dagoen izena [needs review]",
|
||||
"settings_saving": "Gordetzen… [needs review]",
|
||||
"settings_language": "Hizkuntza [needs review]",
|
||||
"settings_avatar": "Abatarra [needs review]",
|
||||
"settings_avatar_initials": "Inizialak [needs review]",
|
||||
"settings_avatar_emoji": "Emoji [needs review]",
|
||||
"settings_avatar_upload": "Argazkia [needs review]",
|
||||
"settings_avatar_upload_btn": "Argazkia igo… [needs review]",
|
||||
"settings_account": "Kontua [needs review]",
|
||||
"settings_keycloak_link": "Helbide elektronikoa edo pasahitza aldatu [needs review]",
|
||||
"settings_logout": "Saioa itxi [needs review]",
|
||||
"settings_save": "Aldaketak gorde [needs review]",
|
||||
"settings_saved": "Aldaketak gordeta [needs review]",
|
||||
"invite_member": "Kidea gonbidatu [needs review]",
|
||||
"invite_email_label": "Helbide elektronikoa [needs review]",
|
||||
"invite_send": "Gonbidapena bidali [needs review]",
|
||||
"masthead_lists_label": "Espazioa [needs review]",
|
||||
"masthead_tasks_label": "Zereginak [needs review]",
|
||||
"masthead_notes_label": "Biltegia [needs review]",
|
||||
"masthead_search_label": "Bilatu [needs review]",
|
||||
"masthead_search_title": "Aurkitu dena. [needs review]",
|
||||
"lists_title": "Erosketa zerrendak [needs review]",
|
||||
"lists_new_list": "Zerrenda berria [needs review]",
|
||||
"lists_no_lists": "Zerrendarik ez [needs review]",
|
||||
"lists_create_first": "Sortu zure lehen erosketa zerrenda [needs review]",
|
||||
"lists_trash": "Zakarrontzia [needs review]",
|
||||
"list_status_active": "Zerrenda aktiboa [needs review]",
|
||||
"list_status_completed": "Osatua [needs review]",
|
||||
"list_status_archived": "Artxibatua [needs review]",
|
||||
"list_name_placeholder": "adib. Asteko erosketa [needs review]",
|
||||
"list_items_empty": "Zerrenda hutsa [needs review]",
|
||||
"list_items_empty_hint": "Gehitu zure lehen produktua behean [needs review]",
|
||||
"list_reset": "Zerrenda berrabiarazi [needs review]",
|
||||
"list_archive": "Artxibatu [needs review]",
|
||||
"list_delete": "Zakarrontzira eraman [needs review]",
|
||||
"list_actions": "Zerrendaren ekintzak [needs review]",
|
||||
"list_item_placeholder": "Produktua gehitu… [needs review]",
|
||||
"list_qty_label": "Kop. [needs review]",
|
||||
"list_add_item": "Produktua gehitu… [needs review]",
|
||||
"list_to_buy": "Erosteko [needs review]",
|
||||
"list_checked": "Markatuta [needs review]",
|
||||
"list_start_session": "Erosi [needs review]",
|
||||
"list_finish_shopping": "Erosketa amaitu [needs review]",
|
||||
"list_finish_confirm": "Zerrenda hau osatutzat eman? [needs review]",
|
||||
"list_finish_confirm_yes": "Bai, amaitu [needs review]",
|
||||
"list_finish_confirm_no": "Erosten jarraitu [needs review]",
|
||||
"tasks_title": "Zereginak [needs review]",
|
||||
"tasks_new_list": "Zerrenda berria [needs review]",
|
||||
"tasks_no_lists": "Zeregin zerrendarik ez [needs review]",
|
||||
"tasks_create_first": "Sortu zure lehen zeregin zerrenda [needs review]",
|
||||
"tasks_new_list_placeholder": "Zeregin zerrenda berria [needs review]",
|
||||
"tasks_new_task_placeholder": "Zeregina gehitu [needs review]",
|
||||
"tasks_empty": "Zereginik ez [needs review]",
|
||||
"tasks_empty_hint": "Gehitu zure lehen zeregina behean [needs review]",
|
||||
"tasks_completed_section": "Osatuak [needs review]",
|
||||
"tasks_pending_section": "Egiteke [needs review]",
|
||||
"tasks_completed_by": "{name}-(e)k osatua [needs review]",
|
||||
"tasks_toggle": "Zeregina txandakatu [needs review]",
|
||||
"tasks_uncheck": "Zeregina desmarkatu [needs review]",
|
||||
"tasks_delete": "Zeregina ezabatu [needs review]",
|
||||
"tasks_delete_list": "Zerrenda ezabatu [needs review]",
|
||||
"tasks_delete_list_confirm": "Zerrenda hau eta bere zeregin guztiak ezabatu? [needs review]",
|
||||
"notes_title": "Oharrak [needs review]",
|
||||
"notes_new": "Ohar berria [needs review]",
|
||||
"notes_pinned_section": "Finkatuak [needs review]",
|
||||
"notes_active_section": "Oharrak [needs review]",
|
||||
"notes_archive": "Artxibatu [needs review]",
|
||||
"notes_archive_view": "Artxibatutako oharrak [needs review]",
|
||||
"notes_archived_empty": "Artxibatutako oharrik ez [needs review]",
|
||||
"notes_trash_view": "Oharren zakarrontzia [needs review]",
|
||||
"notes_trash_empty": "Zakarrontzia hutsik dago [needs review]",
|
||||
"notes_pin": "Oharra finkatu [needs review]",
|
||||
"notes_unpin": "Oharra desfinkatu [needs review]",
|
||||
"notes_unarchive": "Oharra desartxibatu [needs review]",
|
||||
"notes_send_to_trash": "Zakarrontzira bidali [needs review]",
|
||||
"notes_restore": "Berreskuratu [needs review]",
|
||||
"notes_duplicate": "Bikoiztu [needs review]",
|
||||
"notes_color": "Kolorea [needs review]",
|
||||
"notes_title_placeholder": "Izenburua [needs review]",
|
||||
"notes_content_placeholder": "Hasi idazten… [needs review]",
|
||||
"notes_content_aria": "Edukia [needs review]",
|
||||
"notes_back_to_board": "Taulara itzuli [needs review]",
|
||||
"notes_saved": "Gordeta [needs review]",
|
||||
"notes_saving": "Gordetzen… [needs review]",
|
||||
"notes_empty_board": "Oharrik ez [needs review]",
|
||||
"notes_empty_board_hint": "Sakatu \"Ohar berria\" hasteko [needs review]",
|
||||
"search_title": "Bilatu [needs review]",
|
||||
"search_placeholder": "Bilatu zerrendak, zereginak, oharrak… [needs review]",
|
||||
"search_filter_all": "Dena [needs review]",
|
||||
"search_filter_lists": "Zerrendak [needs review]",
|
||||
"search_filter_tasks": "Zereginak [needs review]",
|
||||
"search_filter_notes": "Oharrak [needs review]",
|
||||
"search_group_shopping_items": "Produktuak [needs review]",
|
||||
"search_group_tasks": "Zereginak [needs review]",
|
||||
"search_group_notes": "Oharrak [needs review]",
|
||||
"search_empty": "Emaitzarik ez [needs review]",
|
||||
"search_hint": "Idatzi gutxienez bi karaktere [needs review]",
|
||||
"trash_restore": "Berreskuratu [needs review]",
|
||||
"trash_delete_permanent": "Behin betiko ezabatu [needs review]",
|
||||
"trash_empty": "Zakarrontzia hutsik dago [needs review]",
|
||||
"trash_expires_in": "{days} egunetan iraungiko da [needs review]",
|
||||
"confirm_delete": "Ezabatu? [needs review]",
|
||||
"cancel": "Utzi [needs review]",
|
||||
"save": "Gorde [needs review]",
|
||||
"action_delete": "Ezabatu [needs review]",
|
||||
"edit": "Editatu [needs review]",
|
||||
"add": "Gehitu [needs review]",
|
||||
"done": "Eginda [needs review]",
|
||||
"pwa_update_available": "Bertsio berria eskuragarri [needs review]",
|
||||
"pwa_update_reload": "Birkargatu [needs review]",
|
||||
"pwa_offline_ready": "Lineaz kanpo erabiltzeko prest [needs review]",
|
||||
"pwa_offline_chip": "Konexiorik gabe [needs review]",
|
||||
"pwa_offline_tooltip": "Konexiorik gabe — aldaketak itzultzean sinkronizatuko dira [needs review]",
|
||||
"pwa_pending_ops": "{count} aldaketa zain [needs review]",
|
||||
"pwa_pending_ops_plural": "{count} aldaketa zain [needs review]",
|
||||
"pwa_sync_conflicts_banner": "Sinkronizazio gatazkak daude [needs review]",
|
||||
"pwa_sync_conflicts_review": "Berrikusi [needs review]",
|
||||
"sync_conflicts_title": "Sinkronizazio gatazkak [needs review]",
|
||||
"sync_conflicts_empty": "Ezer zain — lokala eta zerbitzaria bat datoz. [needs review]",
|
||||
"sync_conflicts_discard_local": "Lokala baztertu [needs review]",
|
||||
"sync_conflicts_discard_remote": "Urrunekoa baztertu [needs review]",
|
||||
"sync_conflicts_back": "Ezarpenetara itzuli [needs review]",
|
||||
"settings_about": "Honi buruz [needs review]",
|
||||
"settings_about_version": "v{version} · {commit} · {date} [needs review]",
|
||||
"settings_about_view_changelog": "Historia ikusi [needs review]",
|
||||
"changelog_modal_title": "Aldaketen historia [needs review]",
|
||||
"changelog_modal_close": "Itxi [needs review]",
|
||||
"sync_offline": "Konexiorik gabe — aldaketak berriz konektatzean sinkronizatuko dira [needs review]",
|
||||
"sync_syncing": "Sinkronizatzen… [needs review]",
|
||||
"undo": "Desegin [needs review]",
|
||||
"undo_deleted_item": "{name} ezabatuta [needs review]",
|
||||
"list_item_delete_aria": "Produktua ezabatu [needs review]",
|
||||
"list_qty_decrease": "Kopurua jaitsi [needs review]",
|
||||
"list_qty_increase": "Kopurua igo [needs review]",
|
||||
"list_reorder_handle": "Berrantolatu [needs review]",
|
||||
"list_edit_item": "Produktua editatu [needs review]",
|
||||
"list_confirm": "Baieztatu [needs review]",
|
||||
"list_close": "Itxi [needs review]",
|
||||
"list_select": "Hautatu [needs review]",
|
||||
"list_selection_count": "{n} hautatuta [needs review]",
|
||||
"list_cancel_selection": "Utzi [needs review]",
|
||||
"list_bulk_delete": "Ezabatu [needs review]",
|
||||
"list_bulk_move": "Mugitu [needs review]",
|
||||
"list_bulk_mark_checked": "Markatu [needs review]",
|
||||
"list_undo_bulk_delete": "{n} ezabatuta [needs review]",
|
||||
"list_move_title": "Zerrendara eraman [needs review]",
|
||||
"list_move_create_new": "Zerrenda berria sortu [needs review]",
|
||||
"settings_appearance": "Itxura [needs review]",
|
||||
"settings_theme_light": "Argia [needs review]",
|
||||
"settings_theme_dark": "Iluna [needs review]",
|
||||
"settings_theme_system": "Sistema [needs review]",
|
||||
"settings_sync_conflicts": "Sinkronizazio gatazkak [needs review]",
|
||||
"settings_sync_conflicts_empty": "Erregistratutako gatazkarik ez. [needs review]",
|
||||
"settings_sync_conflicts_export": "JSON esportatu [needs review]",
|
||||
"settings_collectives": "Zure kolektiboak [needs review]",
|
||||
"settings_collectives_empty": "Ez zara inongo kolektiboko kide. [needs review]",
|
||||
"settings_collectives_leave": "Utzi [needs review]",
|
||||
"settings_collectives_confirm_leave_title": "{name} utzi? [needs review]",
|
||||
"settings_collectives_confirm_leave_body": "Kolektibo honetarako sarbidea galduko duzu. Edukia gainerako kideentzat geratuko da. [needs review]",
|
||||
"settings_collectives_confirm_leave_button": "Kolektiboa utzi [needs review]",
|
||||
"settings_collectives_dissolve_hint": "Kide bakarra zara — erabili 'Desegin' kolektiboaren orrian horren ordez. [needs review]",
|
||||
"settings_danger_zone": "Eremu arriskutsua [needs review]",
|
||||
"settings_delete_account": "Nire kontua ezabatu [needs review]",
|
||||
"settings_delete_account_blurb": "Behin betiko ezabatzen ditu zure kontua eta zure kidetza guztiak. Sortu duzun edukia kolektiboetan geratuko da, ezabatutako erabiltzaile bati esleitua. [needs review]",
|
||||
"settings_delete_account_modal_title": "Zure kontua ezabatu? [needs review]",
|
||||
"settings_delete_account_modal_body": "Hau behin betikoa da. Ezabatzen dena: zure kontua, zure kidetzak, zure hizkuntza eta gai gordeak. Geratzen dena: sortu dituzun zerrendak, zereginak edo oharrak — euren kolektiboetan geratzen dira, ezabatutako erabiltzaile bati esleituak. Kolektiboren bateko administratzaile bakarra bazara, kide zaharrena automatikoki sustatuko da. Oharra: identitate-hornitzailearen kontua EZ da ezabatzen; helbide elektroniko berarekin berriro erregistratu zaitezke eta profil berri bat sortuko da. [needs review]",
|
||||
"settings_delete_account_confirm_label": "Idatzi {word} baieztatzeko [needs review]",
|
||||
"settings_delete_account_confirm_word": "EZABATU [needs review]",
|
||||
"settings_delete_account_button": "Kontua behin betiko ezabatu [needs review]",
|
||||
"settings_delete_account_blocked": "Sustatu ezin diren kideak dituen kolektibo bateko administratzaile bakarra zara. Sustatu norbait lehenik edo ezabatu gainerako kideak. [needs review]",
|
||||
"common_cancel": "Utzi [needs review]",
|
||||
"manage_collective_name_label": "Kolektiboaren izena [needs review]",
|
||||
"manage_collective_emoji_label": "Ikonoa [needs review]",
|
||||
"manage_collective_save": "Gorde [needs review]",
|
||||
"manage_dissolve_section": "Eremu arriskutsua [needs review]",
|
||||
"manage_dissolve_button": "Kolektiboa desegin [needs review]",
|
||||
"manage_dissolve_blurb": "Kolektibo hau eta bere eduki guztia behin betiko ezabatzen ditu. Ezin da desegin. [needs review]",
|
||||
"manage_dissolve_title": "{name} desegin? [needs review]",
|
||||
"manage_dissolve_warning": "{lists} zerrenda, {tasks} zeregin eta {notes} ohar ezabatuko dira. Ekintza hau itzulezina da. [needs review]",
|
||||
"manage_dissolve_confirm_label": "Idatzi {name} baieztatzeko [needs review]",
|
||||
"manage_dissolve_confirm_button": "Behin betiko desegin [needs review]",
|
||||
"sidebar_create_collective": "+ Kolektibo berria [needs review]",
|
||||
"create_collective_modal_title": "Kolektibo berria [needs review]",
|
||||
"create_collective_modal_button": "Sortu [needs review]",
|
||||
"tag_picker_placeholder": "Etiketa bilatu edo sortu [needs review]",
|
||||
"tag_picker_create": "«{name}» sortu [needs review]",
|
||||
"tag_picker_empty": "Oraindik ez dago etiketarik. [needs review]",
|
||||
"tag_picker_label": "Etiketak [needs review]",
|
||||
"list_filter_by_tag": "Etiketaz iragazi [needs review]",
|
||||
"list_filter_clear": "Garbitu [needs review]",
|
||||
"settings_tags_section": "Etiketak [needs review]",
|
||||
"settings_tags_empty": "Oraindik ez dago etiketarik — sortu edozein zerrendatatik. [needs review]",
|
||||
"settings_tags_delete": "Ezabatu [needs review]",
|
||||
"settings_tags_color": "Kolorea [needs review]",
|
||||
"section_disabled_for_collective": "Atal hau kolektibo honentzat ezkutatuta dago. [needs review]",
|
||||
"settings_section_visibility_title": "Ikusgai dauden atalak [needs review]",
|
||||
"settings_section_visibility_blurb": "Ezkutatu erabiltzen ez dituzun goi-mailako atalak. Zure administratzaileak ere kolektibo osorako ezkuta ditzake. [needs review]",
|
||||
"settings_section_visibility_overridden": "Kolektiboak ezkutatua. [needs review]",
|
||||
"collective_section_visibility_title": "Kolektiboaren atalak [needs review]",
|
||||
"collective_section_visibility_blurb": "Ezkutatu atalak kolektibo honetako kide guztientzat. Kide bakoitzak ere bere buruarentzat ezkuta ditzake. [needs review]",
|
||||
"section_label_lists": "Zerrendak [needs review]",
|
||||
"section_label_tasks": "Zereginak [needs review]",
|
||||
"section_label_notes": "Oharrak [needs review]",
|
||||
"section_label_search": "Bilatu [needs review]",
|
||||
"admin_banner": "Admin modua — ekintzak erregistratzen dira [needs review]",
|
||||
"admin_nav_collectives": "Kolektiboak [needs review]",
|
||||
"admin_nav_admins": "Administratzaileak [needs review]",
|
||||
"admin_nav_audit": "Erregistroa [needs review]",
|
||||
"admin_nav_server": "Zerbitzaria [needs review]",
|
||||
"admin_menu_entry": "Zerbitzariko administratzailea [needs review]",
|
||||
"admin_collectives_title": "Kolektiboak [needs review]",
|
||||
"admin_collectives_search_placeholder": "Izenez bilatu… [needs review]",
|
||||
"admin_collectives_empty": "Ez dator bat kolektiborik. [needs review]",
|
||||
"admin_collectives_col_name": "Izena [needs review]",
|
||||
"admin_collectives_col_members": "Kideak [needs review]",
|
||||
"admin_collectives_col_created": "Sortua [needs review]",
|
||||
"admin_collectives_col_status": "Egoera [needs review]",
|
||||
"admin_collectives_status_active": "Aktiboa [needs review]",
|
||||
"admin_collectives_status_deleted": "Ezabatua [needs review]",
|
||||
"admin_action_view": "Ikusi [needs review]",
|
||||
"admin_action_soft_delete": "Soft-delete [needs review]",
|
||||
"admin_action_restore": "Berreskuratu [needs review]",
|
||||
"admin_action_hard_delete": "Ezabatu [needs review]",
|
||||
"admin_action_remove": "Kanporatu [needs review]",
|
||||
"admin_modal_soft_delete_title": "Kolektiboa soft-delete egin [needs review]",
|
||||
"admin_modal_soft_delete_help": "Kolektiboa ezkutatuta geratzen da kideentzat, baina berreskuragarri 30 egunez. Arrazoia erregistratzen da. [needs review]",
|
||||
"admin_modal_reason_label": "Arrazoia [needs review]",
|
||||
"admin_modal_reason_placeholder": "Zergatik? [needs review]",
|
||||
"admin_modal_hard_delete_title": "Kolektiboa ezabatu [needs review]",
|
||||
"admin_modal_hard_delete_help": "Hau itzulezina da. Zerrenda, item, zeregin eta ohar guztiak ezabatuko dira. [needs review]",
|
||||
"admin_modal_hard_delete_confirm": "Itzulezina dela ulertzen dut [needs review]",
|
||||
"admin_modal_hard_delete_force_label": "Behartu (30 eguneko itxaronaldia saltatu) [needs review]",
|
||||
"admin_modal_cancel": "Utzi [needs review]",
|
||||
"admin_modal_confirm": "Baieztatu [needs review]",
|
||||
"admin_collective_detail_back": "Kolektiboetara itzuli [needs review]",
|
||||
"admin_collective_detail_members": "Kideak [needs review]",
|
||||
"admin_collective_detail_recent_actions": "Azken ekintzak [needs review]",
|
||||
"admin_modal_remove_member_title": "Kidea kanporatu [needs review]",
|
||||
"admin_modal_remove_member_help": "Kidetza berehala ezeztatzen da. Arrazoia erregistratzen da. [needs review]",
|
||||
"admin_admins_title": "Zerbitzariko administratzaileak [needs review]",
|
||||
"admin_admins_promote": "Erabiltzailea sustatu [needs review]",
|
||||
"admin_admins_promote_help": "Sartu sustatu beharreko erabiltzailearen helbide elektronikoa. [needs review]",
|
||||
"admin_admins_email_label": "Helbide elektronikoa [needs review]",
|
||||
"admin_admins_email_placeholder": "erabiltzailea@adibidea.com [needs review]",
|
||||
"admin_admins_email_not_found": "Helbide elektroniko hori duen erabiltzailerik ez. [needs review]",
|
||||
"admin_admins_revoke_self": "zu (ezin duzu zeure burua ezeztatu administratzaile bakarra bazara) [needs review]",
|
||||
"admin_admins_revoke": "Ezeztatu [needs review]",
|
||||
"admin_audit_title": "Ekintzen erregistroa [needs review]",
|
||||
"admin_audit_empty": "Oraindik ez da ekintzarik erregistratu. [needs review]",
|
||||
"admin_audit_col_when": "Noiz [needs review]",
|
||||
"admin_audit_col_actor": "Eragilea [needs review]",
|
||||
"admin_audit_col_action": "Ekintza [needs review]",
|
||||
"admin_audit_col_target": "Helburua [needs review]",
|
||||
"admin_audit_col_payload": "Payload [needs review]",
|
||||
"admin_server_title": "Zerbitzariaren konfigurazioa [needs review]",
|
||||
"admin_server_default_sections": "Atalen lehenetsitako ikusgaitasuna [needs review]",
|
||||
"admin_server_default_sections_help": "Zerbitzari mailako override-a. Hemen ON edo OFF jartzeak edozein kolektibo edo erabiltzaile gainditzen du. [needs review]",
|
||||
"admin_server_section_state_off": "OFF [needs review]",
|
||||
"admin_server_section_state_on": "ON [needs review]",
|
||||
"admin_server_section_state_unset": "Iritzirik gabe [needs review]",
|
||||
"admin_server_info": "Zerbitzariaren informazioa [needs review]",
|
||||
"admin_error_forbidden": "Ez duzu eremu honetarako sarbiderik. [needs review]",
|
||||
"admin_error_unknown": "Zerbait gaizki joan da. Saiatu berriro. [needs review]",
|
||||
"common_items_title": "Item ohikoak [needs review]",
|
||||
"common_items_blurb": "Sustatu beti iradokizunetan lehenik agertzea nahi dituzun itemak, edo ezkutatu ikusi nahi ez dituzunak. Kideek katalogoa ikusten dute baina ezin dute editatu. [needs review]",
|
||||
"common_items_search_placeholder": "Itemak bilatu… [needs review]",
|
||||
"common_items_empty": "Oraindik ez dago item ohikorik. Zerrendetara gehitzen diren itemak hemen agertuko dira. [needs review]",
|
||||
"common_items_empty_filtered": "Ez dator bat itemik zure bilaketarekin. [needs review]",
|
||||
"common_items_col_name": "Izena [needs review]",
|
||||
"common_items_col_weight": "Ikusgaitasuna [needs review]",
|
||||
"common_items_col_uses": "Erabilerak [needs review]",
|
||||
"common_items_col_last_used": "Azken erabilera [needs review]",
|
||||
"common_items_col_actions": "Ekintzak [needs review]",
|
||||
"common_items_state_hide": "Ezkutatu [needs review]",
|
||||
"common_items_state_normal": "Normala [needs review]",
|
||||
"common_items_state_boost": "Nabarmendu [needs review]",
|
||||
"common_items_state_locked_tooltip": "Administratzaileek soilik kudea ditzakete item ohikoak. [needs review]",
|
||||
"common_items_purge_button": "Ezabatu [needs review]",
|
||||
"common_items_purge_modal_title": "{name} ezabatu? [needs review]",
|
||||
"common_items_purge_modal_body": "Honek iradokizunetatik sarrera kentzen du soilik. Dagoeneko zerrendetan dauden itemak ez dira aldatzen. [needs review]",
|
||||
"common_items_purge_confirm": "Ezabatu [needs review]",
|
||||
"common_items_add_button": "Item komuna gehitu [needs review]",
|
||||
"common_items_add_modal_title": "Item komuna gehitu [needs review]",
|
||||
"common_items_add_name_label": "Itemaren izena [needs review]",
|
||||
"common_items_add_name_placeholder": "adib. oliba olioa [needs review]",
|
||||
"common_items_add_visibility_label": "Ikusgaitasuna [needs review]",
|
||||
"common_items_add_save": "Gehitu [needs review]",
|
||||
"create_list_modal_title": "Erosketa zerrenda berria [needs review]",
|
||||
"create_list_input_label": "Zerrendaren izena [needs review]",
|
||||
"create_list_input_placeholder": "adib. Asteko erosketa [needs review]",
|
||||
"create_list_submit": "Sortu [needs review]",
|
||||
"create_list_suggestion_chip": "Iradokizuna: {title} [needs review]",
|
||||
"create_list_auto_numbered_toast": "«{title}» bezala sortua [needs review]",
|
||||
"manage_list_titles_section_title": "Iradokitako izenburuak [needs review]",
|
||||
"manage_list_titles_blurb": "Aurrekonfiguratu zerrenda berrientzat lehenetsitako izenburu bat eta mantendu iradokizun zerrenda bat. [needs review]",
|
||||
"manage_default_list_title_label": "Lehenetsitako izenburua [needs review]",
|
||||
"manage_default_list_title_placeholder": "adib. Asteko erosketa [needs review]",
|
||||
"manage_default_list_title_clear": "Garbitu [needs review]",
|
||||
"manage_list_titles_empty": "Oraindik ez dago iradokitako izenbururik. [needs review]",
|
||||
"manage_list_titles_add_button": "Izenburua gehitu [needs review]",
|
||||
"manage_list_titles_add_modal_title": "Iradokitako izenburua gehitu [needs review]",
|
||||
"manage_list_titles_add_name_label": "Izenburua [needs review]",
|
||||
"manage_list_titles_add_name_placeholder": "adib. Erosketa [needs review]",
|
||||
"manage_list_titles_add_save": "Gehitu [needs review]",
|
||||
"manage_list_titles_remove": "Ezabatu [needs review]",
|
||||
"manage_list_titles_readonly": "Administratzaileek soilik kudea ditzakete izenburu hauek. [needs review]"
|
||||
}
|
||||
@@ -28,8 +28,33 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
// Skip WebKit-only specs (touch-gesture tests) — Chromium's
|
||||
// touch emulation does not fire the touch* sequence the
|
||||
// swipe handlers expect.
|
||||
testIgnore: /.*\.webkit\.test\.ts/
|
||||
},
|
||||
// Fase 9.4: WebKit (Mobile Safari) project for touch-gesture
|
||||
// tests. The previous swipe-to-delete tests were left .skip in
|
||||
// mobile.test.ts because Chromium's touch emulation broke the
|
||||
// gesture; that file was removed in Fase 5.10 when swipe was
|
||||
// repurposed as a check-toggle. The replacement webkit specs
|
||||
// live in `*.webkit.test.ts` and only run here.
|
||||
//
|
||||
// The WebKit binary needs system libs (libicu74, libwoff1,
|
||||
// libmanette-0.2-0, libxml2 — install via
|
||||
// `sudo pnpm exec playwright install-deps`). Hosts that don't
|
||||
// have them can run the rest of the suite by leaving the env
|
||||
// var unset; gated to keep `just test-all` green elsewhere.
|
||||
...(process.env.RUN_WEBKIT === '1'
|
||||
? [
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['iPhone 13'] },
|
||||
testMatch: /.*\.webkit\.test\.ts/
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
|
||||
webServer: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"sourceLanguageTag": "en",
|
||||
"languageTags": ["en", "es"],
|
||||
"languageTags": ["en", "es", "eu"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@1/dist/index.js"
|
||||
|
||||
@@ -5,11 +5,22 @@
|
||||
/* Inter font */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* Design tokens — light mode
|
||||
/* Design tokens — light mode (default).
|
||||
*
|
||||
* Surface hierarchy (stacked sheets of paper):
|
||||
* background → surface-container-low → surface → surface-raised (cards pop up)
|
||||
*
|
||||
* IMPORTANT (repo-wide gotcha): values are RGB triplets so they can be
|
||||
* consumed via rgb(var(--token) / <alpha>). Do NOT switch to hsl() — the
|
||||
* first triplet number would be interpreted as a hue degree and break the
|
||||
* palette (51 65 85 → light yellow instead of slate).
|
||||
*
|
||||
* Fase 9.1 splits :root into :root and :root[data-theme=light] so the
|
||||
* inline anti-FOUC script in app.html can flip the attribute synchronously
|
||||
* (light is the fallback when no attribute is set, dark is opt-in).
|
||||
*/
|
||||
:root {
|
||||
:root,
|
||||
:root[data-theme='light'] {
|
||||
--background: 247 249 251; /* #f7f9fb — spec exact value */
|
||||
--surface-container-low: 241 245 249; /* slate-100 — subtle grouping bg */
|
||||
--surface: 255 255 255; /* white — base card surface */
|
||||
@@ -20,8 +31,11 @@
|
||||
--destructive: 185 28 28; /* red-700 ≈ #ba1a1a spec */
|
||||
}
|
||||
|
||||
/* Design tokens — dark mode */
|
||||
.dark {
|
||||
/* Design tokens — dark mode.
|
||||
* Contrast on text-primary against background = 14.7:1 (slate-50 on
|
||||
* slate-950) — well above WCAG AAA 7:1. text-secondary on background =
|
||||
* 5.8:1, above AA 4.5:1. */
|
||||
:root[data-theme='dark'] {
|
||||
--background: 2 6 23; /* #020617 slate-950 — spec exact value */
|
||||
--surface-container-low: 15 23 42; /* slate-900 — subtle grouping */
|
||||
--surface: 30 41 59; /* slate-800 — base card surface */
|
||||
|
||||
@@ -5,11 +5,40 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<!-- PWA manifest link. Required for Android Chrome install prompt;
|
||||
iOS Safari "Add to Home Screen" uses it for name/icon fallback. -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<!-- iOS PWA install hints (Fase 6.2). -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Colectivo" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-180.png" />
|
||||
<!--
|
||||
Fase 9.1: anti-FOUC theme bootstrapping.
|
||||
Runs synchronously before the first paint so the page never
|
||||
renders a light frame and then flashes dark on refresh.
|
||||
localStorage.theme is one of 'light' | 'dark' | 'system' (default
|
||||
'system'). For 'system' we read prefers-color-scheme. The result is
|
||||
written to <html data-theme="..."> — Tailwind's `dark:` utilities
|
||||
and the :root[data-theme=...] tokens both resolve from this attr.
|
||||
-->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem('theme');
|
||||
var pref = stored === 'light' || stored === 'dark' ? stored : 'system';
|
||||
var resolved =
|
||||
pref === 'system'
|
||||
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light')
|
||||
: pref;
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
} catch (_) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
40
apps/web/src/global.d.ts
vendored
Normal file
40
apps/web/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Ambient declarations for app-wide constants and virtual modules.
|
||||
*
|
||||
* - `virtual:pwa-register/svelte` is provided at build time by
|
||||
* `vite-plugin-pwa`'s Svelte helper (Fase 14.1). vite-plugin-pwa ships
|
||||
* `svelte.d.ts` for this but TypeScript only picks it up if the path
|
||||
* is referenced; declaring the module here inlines the (small) surface
|
||||
* without coupling to the package's internal layout.
|
||||
*
|
||||
* - `__APP_VERSION__`, `__APP_COMMIT__`, `__BUILD_TIME__` (Fase 14.3)
|
||||
* are replaced literally by Vite at build time via the `define`
|
||||
* option in `vite.config.ts`. They're consumed exclusively through
|
||||
* `$lib/version.ts` — never inline — so tree-shaking can't drop
|
||||
* the build-time substitution.
|
||||
*/
|
||||
|
||||
declare module 'virtual:pwa-register/svelte' {
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean;
|
||||
onNeedRefresh?: () => void;
|
||||
onOfflineReady?: () => void;
|
||||
onRegisteredSW?: (
|
||||
swScriptUrl: string,
|
||||
registration: ServiceWorkerRegistration | undefined
|
||||
) => void;
|
||||
onRegisterError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function useRegisterSW(options?: RegisterSWOptions): {
|
||||
needRefresh: Writable<boolean>;
|
||||
offlineReady: Writable<boolean>;
|
||||
updateServiceWorker: (reloadPage?: boolean) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __APP_COMMIT__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
174
apps/web/src/lib/components/ChangelogModal.svelte
Normal file
174
apps/web/src/lib/components/ChangelogModal.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<!--
|
||||
Fase 17.2 — About-modal that renders the repo-root CHANGELOG.md.
|
||||
|
||||
Architecture notes:
|
||||
* CHANGELOG is imported via Vite's `?raw` query so the file is
|
||||
bundled at build time — no fetch at runtime, no SSR + adapter-node
|
||||
filesystem read, no service-worker miss. Bundle size is ~6 KB
|
||||
today; revisit (dynamic import, marked, etc.) when it grows past
|
||||
~30 KB or starts using non-trivial markdown features (tables,
|
||||
links, code blocks).
|
||||
* The path is 5 levels up from this file:
|
||||
components → lib → src → web → apps → repo-root. The plan
|
||||
estimated 3; documenting the off-by-two so anyone moving the
|
||||
component up/down the tree adjusts the import accordingly.
|
||||
* Markdown is parsed inline (`renderMarkdown` below). Input is
|
||||
static repo content, never user-supplied, so we're not exposed to
|
||||
the usual sanitisation concerns — we still HTML-escape every
|
||||
input line before adding our own tags to keep the surface clean
|
||||
and to defend against accidentally putting `<` in a future entry.
|
||||
* Esc + overlay-click both call `onClose`. We attach the keydown
|
||||
listener to `window` only while the modal is open; otherwise
|
||||
typing Escape outside the modal would still call back.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import changelogRaw from '../../../../../CHANGELOG.md?raw';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Tailwind class strings for each rendered element. Living here so
|
||||
// JIT scans them at build time — putting them inside a template
|
||||
// string in renderMarkdown also works, but keeping them as named
|
||||
// constants makes them grep-able and theme-able.
|
||||
const CLS_H1 = 'mb-3 text-xl font-bold text-slate-900 dark:text-slate-50';
|
||||
const CLS_H2 = 'mt-6 mb-2 text-base font-semibold text-slate-900 dark:text-slate-50';
|
||||
const CLS_H3 = 'mt-4 mb-1 text-sm font-semibold text-slate-800 dark:text-slate-100';
|
||||
const CLS_UL = 'mt-1 mb-3 list-disc pl-5 space-y-0.5';
|
||||
const CLS_LI = 'leading-relaxed';
|
||||
const CLS_P = 'my-2 leading-relaxed';
|
||||
const CLS_STRONG = 'font-semibold text-slate-900 dark:text-slate-50';
|
||||
|
||||
function renderMarkdown(src: string): string {
|
||||
const lines = src.split('\n');
|
||||
const out: string[] = [];
|
||||
let inList = false;
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
out.push('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
const inlineStrong = (s: string): string =>
|
||||
s.replace(/\*\*([^*]+)\*\*/g, `<strong class="${CLS_STRONG}">$1</strong>`);
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (line.startsWith('### ')) {
|
||||
closeList();
|
||||
out.push(`<h3 class="${CLS_H3}">${inlineStrong(escapeHtml(line.slice(4)))}</h3>`);
|
||||
} else if (line.startsWith('## ')) {
|
||||
closeList();
|
||||
out.push(`<h2 class="${CLS_H2}">${inlineStrong(escapeHtml(line.slice(3)))}</h2>`);
|
||||
} else if (line.startsWith('# ')) {
|
||||
closeList();
|
||||
out.push(`<h1 class="${CLS_H1}">${inlineStrong(escapeHtml(line.slice(2)))}</h1>`);
|
||||
} else if (/^[-*]\s+/.test(line)) {
|
||||
if (!inList) {
|
||||
out.push(`<ul class="${CLS_UL}">`);
|
||||
inList = true;
|
||||
}
|
||||
const body = line.replace(/^[-*]\s+/, '');
|
||||
out.push(`<li class="${CLS_LI}">${inlineStrong(escapeHtml(body))}</li>`);
|
||||
} else if (line === '') {
|
||||
closeList();
|
||||
} else {
|
||||
closeList();
|
||||
out.push(`<p class="${CLS_P}">${inlineStrong(escapeHtml(line))}</p>`);
|
||||
}
|
||||
}
|
||||
closeList();
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// Pre-render once on module evaluation — the source is build-time
|
||||
// static, so there's no point re-running per modal open. Placed
|
||||
// after the const class strings + renderMarkdown declaration so the
|
||||
// TDZ doesn't kick in.
|
||||
const html = renderMarkdown(changelogRaw);
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
// Attach the global keydown listener as soon as `open` flips true.
|
||||
// $effect runs after mount and re-runs on each prop change, so this
|
||||
// gives us subscribe-on-open and unsubscribe-on-close without an
|
||||
// extra `mounted` gate (which delayed subscription past the first
|
||||
// dispatched keydown in jsdom).
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!open) return;
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('keydown', handleKey);
|
||||
}
|
||||
});
|
||||
|
||||
function onOverlayClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
data-testid="changelog-modal-overlay"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onclick={onOverlayClick}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
data-testid="changelog-modal"
|
||||
class="flex w-full max-w-2xl flex-col rounded-lg bg-surface shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.changelog_modal_title()}
|
||||
>
|
||||
<header
|
||||
class="flex items-center justify-between border-b border-slate-200 px-5 py-3 dark:border-slate-700"
|
||||
>
|
||||
<h2 class="text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.changelog_modal_title()}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="changelog-modal-close"
|
||||
onclick={onClose}
|
||||
aria-label={m.changelog_modal_close()}
|
||||
class="rounded-md p-1 text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
data-testid="changelog-modal-body"
|
||||
class="max-h-[70vh] overflow-y-auto px-5 py-4 text-sm text-slate-700 dark:text-slate-200"
|
||||
>
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
77
apps/web/src/lib/components/ChangelogModal.test.ts
Normal file
77
apps/web/src/lib/components/ChangelogModal.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Fase 17.4.1 — ChangelogModal unit tests.
|
||||
*
|
||||
* Mirrors the Spinner.test.ts pattern (Svelte 5 `mount`/`unmount`, no
|
||||
* @testing-library dependency). The modal imports CHANGELOG.md via Vite's
|
||||
* `?raw` query — vitest runs against the same Vite config (see
|
||||
* apps/web/vitest.config.ts) so the import resolves to the file contents at
|
||||
* test time.
|
||||
*
|
||||
* Specs:
|
||||
* CHM-U-01 — `open={false}` renders nothing.
|
||||
* CHM-U-02 — `open={true}` renders the CHANGELOG `<h1>`.
|
||||
* CHM-U-03 — click on the overlay fires `onClose`.
|
||||
* CHM-U-04 — pressing Escape fires `onClose`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import ChangelogModal from './ChangelogModal.svelte';
|
||||
|
||||
let host: HTMLElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let instance: any;
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
instance = mount(ChangelogModal, { target: host, props });
|
||||
return host;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
if (host?.parentNode) host.parentNode.removeChild(host);
|
||||
instance = null;
|
||||
});
|
||||
|
||||
describe('ChangelogModal component', () => {
|
||||
it('CHM-U-01: open=false renders nothing visible', () => {
|
||||
const root = render({ open: false, onClose: () => {} });
|
||||
expect(root.querySelector('[data-testid="changelog-modal"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('CHM-U-02: open=true renders the CHANGELOG h1 heading', () => {
|
||||
const root = render({ open: true, onClose: () => {} });
|
||||
const modal = root.querySelector('[data-testid="changelog-modal"]');
|
||||
expect(modal).not.toBeNull();
|
||||
const h1 = modal?.querySelector('h1');
|
||||
expect(h1).not.toBeNull();
|
||||
expect(h1?.textContent?.trim()).toBe('Changelog');
|
||||
});
|
||||
|
||||
it('CHM-U-03: click on overlay fires onClose', () => {
|
||||
const onClose = vi.fn();
|
||||
const root = render({ open: true, onClose });
|
||||
const overlay = root.querySelector('[data-testid="changelog-modal-overlay"]') as
|
||||
| HTMLElement
|
||||
| null;
|
||||
expect(overlay).not.toBeNull();
|
||||
overlay?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('CHM-U-04: Escape key fires onClose', () => {
|
||||
const onClose = vi.fn();
|
||||
render({ open: true, onClose });
|
||||
// $effect runs in a microtask after mount. flushSync drains pending
|
||||
// reactive effects so the keydown listener is attached before we
|
||||
// dispatch the event.
|
||||
flushSync();
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
109
apps/web/src/lib/components/CreateCollectiveModal.svelte
Normal file
109
apps/web/src/lib/components/CreateCollectiveModal.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import EmojiPicker from './EmojiPicker.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
// Fase 19 — emoji catalog moved into the shared `EmojiPicker`. The 24
|
||||
// hardcoded city/house glyphs the old `EMOJI_OPTIONS` array shipped are
|
||||
// covered by the new "objects" tab.
|
||||
|
||||
let name = $state('');
|
||||
let emoji = $state('🏠');
|
||||
let creating = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim() || creating) return;
|
||||
creating = true;
|
||||
error = null;
|
||||
const { data, error: rpcErr } = await getSupabase().rpc('create_collective', {
|
||||
p_name: name.trim(),
|
||||
p_emoji: emoji
|
||||
});
|
||||
if (rpcErr || !data) {
|
||||
creating = false;
|
||||
error = rpcErr?.message ?? 'Failed to create collective.';
|
||||
return;
|
||||
}
|
||||
const created = {
|
||||
...(data as { id: string; name: string; emoji: string; created_at: string }),
|
||||
// New collective starts with no flags — every section ON by default.
|
||||
feature_flags: {} as import('@colectivo/types').FeatureFlags,
|
||||
// Fase 18: no default list title until an admin sets one.
|
||||
default_list_title: null as string | null
|
||||
};
|
||||
userCollectives.update((list) => [...list, created]);
|
||||
currentCollective.set(created);
|
||||
localStorage.setItem('activeCollectiveId', created.id);
|
||||
creating = false;
|
||||
onClose();
|
||||
await goto('/lists');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="create-collective-modal">
|
||||
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
|
||||
<h2 class="mb-4 text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.create_collective_modal_title()}
|
||||
</h2>
|
||||
<form onsubmit={(e) => { e.preventDefault(); void submit(); }} class="space-y-4">
|
||||
<div>
|
||||
<label for="cc-modal-name" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.onboarding_collective_name()}
|
||||
</label>
|
||||
<input
|
||||
id="cc-modal-name"
|
||||
data-testid="create-collective-modal-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={m.onboarding_collective_name_placeholder()}
|
||||
maxlength="60"
|
||||
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
||||
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.onboarding_collective_emoji()}
|
||||
</p>
|
||||
<EmojiPicker selected={emoji} onSelect={(e) => (emoji = e)} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100
|
||||
dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="create-collective-modal-submit"
|
||||
disabled={!name.trim() || creating}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white
|
||||
transition-opacity hover:opacity-90 disabled:opacity-50
|
||||
dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{#if creating}
|
||||
<Spinner size="sm" />
|
||||
<span>{m.loading()}</span>
|
||||
{:else}
|
||||
{m.create_collective_modal_button()}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
283
apps/web/src/lib/components/CreateListModal.svelte
Normal file
283
apps/web/src/lib/components/CreateListModal.svelte
Normal file
@@ -0,0 +1,283 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { createList } from '$lib/stores/lists';
|
||||
import {
|
||||
listTitleCatalog,
|
||||
loadTitleCatalog,
|
||||
fetchTitleSuggestions,
|
||||
computeNextNumber
|
||||
} from '$lib/stores/listTitles';
|
||||
import { parseTitle } from '$lib/utils/list-title';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string, finalName: string, wasAutoNumbered: boolean) => void;
|
||||
}
|
||||
|
||||
const { open, onClose, onCreated }: Props = $props();
|
||||
|
||||
let value = $state('');
|
||||
let suggestions = $state<string[]>([]);
|
||||
let suggestionsTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let creating = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Track open transitions so we only prefill on the false→true edge.
|
||||
// A naive `$effect(() => { if (open) value = default })` re-runs whenever
|
||||
// `$currentCollective` updates (e.g. realtime UPDATE after the user types
|
||||
// something), clobbering the typed value. Mirroring `open` into a local
|
||||
// state and comparing isolates the trigger to the actual open edge.
|
||||
let wasOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) {
|
||||
value = $currentCollective?.default_list_title ?? '';
|
||||
error = null;
|
||||
if ($currentCollective) {
|
||||
void loadTitleCatalog($currentCollective.id);
|
||||
}
|
||||
wasOpen = true;
|
||||
} else if (!open && wasOpen) {
|
||||
// Reset on close so reopening starts fresh.
|
||||
value = '';
|
||||
suggestions = [];
|
||||
error = null;
|
||||
wasOpen = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Suggestions (debounced) ─────────────────────────────────────────────
|
||||
|
||||
function refreshSuggestions(prefix: string) {
|
||||
if (suggestionsTimer) clearTimeout(suggestionsTimer);
|
||||
const cid = $currentCollective?.id;
|
||||
if (!cid) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
suggestionsTimer = setTimeout(async () => {
|
||||
suggestions = await fetchTitleSuggestions(cid, prefix);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Drive suggestions reactively off `value` (which is `bind:value`d below).
|
||||
// Using `$effect` instead of an explicit onInput keeps the click-on-chip /
|
||||
// click-on-suggestion paths consistent — they assign `value` and the
|
||||
// effect re-fires automatically.
|
||||
$effect(() => {
|
||||
// Read `value` so this effect depends on it.
|
||||
const v = value;
|
||||
refreshSuggestions(v);
|
||||
});
|
||||
|
||||
// ── Catalog match (drives the suggestion chip + auto-suffix) ────────────
|
||||
|
||||
const catalogTitles = $derived($listTitleCatalog.map((r) => r.title));
|
||||
|
||||
const trimmed = $derived(value.trim());
|
||||
const parsed = $derived(parseTitle(trimmed));
|
||||
|
||||
/**
|
||||
* True when the typed value exactly matches a catalog entry
|
||||
* case-insensitively AND has no `#N` suffix yet. That's the only state
|
||||
* where auto-suffix fires (rule 9 from the orchestrating prompt).
|
||||
*/
|
||||
const catalogMatch = $derived.by(() => {
|
||||
if (!parsed.prefix || parsed.number !== null) return null;
|
||||
const norm = parsed.prefix.toLowerCase();
|
||||
return catalogTitles.find((t) => t.toLowerCase() === norm) ?? null;
|
||||
});
|
||||
|
||||
// Suggestion chip — `Sugerencia: Compra #6`. We compute the proposed
|
||||
// number lazily when the catalog match changes.
|
||||
let proposedNumber = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const cm = catalogMatch;
|
||||
const cid = $currentCollective?.id;
|
||||
if (!cm || !cid) {
|
||||
proposedNumber = null;
|
||||
return;
|
||||
}
|
||||
void computeNextNumber(cid, cm).then((n) => {
|
||||
// Only update if the inputs haven't drifted while we awaited.
|
||||
if (catalogMatch === cm && $currentCollective?.id === cid) {
|
||||
proposedNumber = n;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const suggestionChipLabel = $derived.by(() => {
|
||||
if (!catalogMatch || proposedNumber === null) return null;
|
||||
return `${catalogMatch} #${proposedNumber}`;
|
||||
});
|
||||
|
||||
function applyChip() {
|
||||
if (!suggestionChipLabel) return;
|
||||
value = suggestionChipLabel;
|
||||
void refreshSuggestions(value);
|
||||
}
|
||||
|
||||
function applySuggestion(s: string) {
|
||||
value = s;
|
||||
void refreshSuggestions(value);
|
||||
}
|
||||
|
||||
// ── Submit ──────────────────────────────────────────────────────────────
|
||||
|
||||
const submitDisabled = $derived(trimmed.length === 0 || creating);
|
||||
|
||||
async function submit() {
|
||||
if (submitDisabled || !$currentCollective || !$currentUser) return;
|
||||
creating = true;
|
||||
error = null;
|
||||
|
||||
let finalName = trimmed;
|
||||
let wasAutoNumbered = false;
|
||||
|
||||
// Auto-suffix only if (a) value exactly matches a catalog entry and
|
||||
// (b) `computeNextNumber` returns a value. Otherwise insert as-is.
|
||||
if (catalogMatch) {
|
||||
const n = await computeNextNumber($currentCollective.id, catalogMatch);
|
||||
finalName = `${catalogMatch} #${n}`;
|
||||
wasAutoNumbered = true;
|
||||
}
|
||||
|
||||
const created = await createList($currentCollective.id, finalName, $currentUser.id);
|
||||
creating = false;
|
||||
if (!created) {
|
||||
error = 'Failed to create list.';
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
onCreated?.(created.id, finalName, wasAutoNumbered);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
// Always-prevent default to keep the form behaviour identical
|
||||
// whether the input or the submit button has focus; we drive the
|
||||
// submit explicitly so the disabled check applies uniformly.
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
data-testid="create-list-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
onkeydown={onKeydown}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised"
|
||||
>
|
||||
<h2 class="mb-4 text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.create_list_modal_title()}
|
||||
</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
class="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="create-list-modal-name"
|
||||
class="mb-1 block text-xs font-medium text-text-secondary"
|
||||
>
|
||||
{m.create_list_input_label()}
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="create-list-modal-name"
|
||||
data-testid="create-list-modal-name"
|
||||
type="text"
|
||||
bind:value
|
||||
autofocus
|
||||
placeholder={m.create_list_input_placeholder()}
|
||||
maxlength="120"
|
||||
class="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if suggestionChipLabel}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-list-suggestion-chip"
|
||||
onclick={applyChip}
|
||||
class="inline-flex items-center rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
{m.create_list_suggestion_chip({ title: suggestionChipLabel })}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if suggestions.length > 0}
|
||||
<ul
|
||||
data-testid="create-list-suggestion-list"
|
||||
class="max-h-40 overflow-y-auto rounded-md border border-slate-200 bg-surface text-sm dark:border-slate-700"
|
||||
>
|
||||
{#each suggestions as s (s)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`create-list-suggestion-${s}`}
|
||||
onclick={() => applySuggestion(s)}
|
||||
class="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive" data-testid="create-list-modal-error">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="create-list-modal-submit"
|
||||
disabled={submitDisabled}
|
||||
class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{#if creating}
|
||||
<Spinner size="sm" />
|
||||
<span>{m.loading()}</span>
|
||||
{:else}
|
||||
{m.create_list_submit()}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
133
apps/web/src/lib/components/EmojiPicker.svelte
Normal file
133
apps/web/src/lib/components/EmojiPicker.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<!--
|
||||
Fase 19.2 — Emoji picker.
|
||||
|
||||
Tabbed grid of ~280–360 emoji codepoints (see $lib/data/emoji-catalog).
|
||||
Header is a tablist with 4 category buttons; body is a single grid for
|
||||
the currently-selected tab. Switching tabs unmounts the previous grid
|
||||
and mounts the new one — lazy-render — so the DOM never holds more
|
||||
than `EMOJI_CATALOG[activeCategory].length` cells at once (the
|
||||
"objects" tab is the smallest; "animals" is the worst case at ~140).
|
||||
|
||||
Props:
|
||||
- `selected`: the currently-active emoji string (highlights the
|
||||
matching cell, if any).
|
||||
- `onSelect(emoji)`: fired on cell click. Caller owns the persistence
|
||||
side-effect (update DB / Supabase row).
|
||||
|
||||
Keyboard nav: arrow keys move focus between cells in the active grid.
|
||||
Cells are `tabindex={index === 0 ? 0 : -1}` (roving focus) per WAI-ARIA
|
||||
grid pattern — the parent tab order has a single entry into the grid,
|
||||
and arrow keys then walk it cell-by-cell. Tab order is consistent with
|
||||
existing modals on the host pages (settings, onboarding, manage,
|
||||
CreateCollectiveModal).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
EMOJI_CATALOG,
|
||||
CATEGORY_LABELS,
|
||||
type EmojiCategory
|
||||
} from '$lib/data/emoji-catalog';
|
||||
|
||||
type Props = {
|
||||
selected: string | null;
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const { selected, onSelect }: Props = $props();
|
||||
|
||||
const CATEGORIES: EmojiCategory[] = ['faces', 'food', 'animals', 'objects'];
|
||||
|
||||
let activeCategory = $state<EmojiCategory>('faces');
|
||||
const activeList = $derived(EMOJI_CATALOG[activeCategory]);
|
||||
|
||||
let gridEl: HTMLDivElement | null = $state(null);
|
||||
|
||||
function selectTab(cat: EmojiCategory) {
|
||||
activeCategory = cat;
|
||||
}
|
||||
|
||||
function pick(emoji: string) {
|
||||
onSelect(emoji);
|
||||
}
|
||||
|
||||
// Roving-focus keyboard nav. We resolve cells from the live DOM each
|
||||
// keystroke (instead of caching the array) so the grid swap on tab
|
||||
// change doesn't strand stale references.
|
||||
function onGridKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target || !gridEl) return;
|
||||
const cells = Array.from(
|
||||
gridEl.querySelectorAll<HTMLElement>('[role="gridcell"]')
|
||||
);
|
||||
const idx = cells.indexOf(target);
|
||||
if (idx < 0) return;
|
||||
|
||||
let next = -1;
|
||||
const cols = 8;
|
||||
if (e.key === 'ArrowRight') next = Math.min(idx + 1, cells.length - 1);
|
||||
else if (e.key === 'ArrowLeft') next = Math.max(idx - 1, 0);
|
||||
else if (e.key === 'ArrowDown') next = Math.min(idx + cols, cells.length - 1);
|
||||
else if (e.key === 'ArrowUp') next = Math.max(idx - cols, 0);
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = cells.length - 1;
|
||||
else return;
|
||||
|
||||
e.preventDefault();
|
||||
cells[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="emoji-picker" data-testid="emoji-picker">
|
||||
<!-- Tablist header. Each button toggles `activeCategory` and Svelte
|
||||
re-renders the grid below from `EMOJI_CATALOG[activeCategory]`. -->
|
||||
<div role="tablist" class="mb-2 flex gap-1">
|
||||
{#each CATEGORIES as cat (cat)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeCategory === cat}
|
||||
aria-controls="emoji-picker-grid"
|
||||
data-testid={`emoji-picker-tab-${cat}`}
|
||||
onclick={() => selectTab(cat)}
|
||||
class="flex-1 rounded-md px-2 py-1 text-xs font-medium transition-colors
|
||||
{activeCategory === cat
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400 dark:hover:bg-slate-700'}"
|
||||
>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Active grid. Only one category mounted at a time. Cells have
|
||||
fixed 32×32 footprint (h-9/w-9 with text-xl) — same size as the
|
||||
legacy single-row picker so caller layouts don't reflow. -->
|
||||
<div
|
||||
id="emoji-picker-grid"
|
||||
bind:this={gridEl}
|
||||
role="grid"
|
||||
tabindex="-1"
|
||||
aria-label={CATEGORY_LABELS[activeCategory]}
|
||||
data-testid="emoji-picker-grid"
|
||||
data-category={activeCategory}
|
||||
onkeydown={onGridKeydown}
|
||||
class="grid max-h-64 grid-cols-8 gap-1 overflow-y-auto sm:grid-cols-10"
|
||||
>
|
||||
{#each activeList as e, i (e)}
|
||||
<button
|
||||
type="button"
|
||||
role="gridcell"
|
||||
tabindex={i === 0 ? 0 : -1}
|
||||
data-testid={`emoji-picker-cell-${e}`}
|
||||
aria-label={e}
|
||||
aria-selected={selected === e}
|
||||
onclick={() => pick(e)}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
||||
{selected === e
|
||||
? 'bg-slate-900 dark:bg-slate-100'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
115
apps/web/src/lib/components/EmojiPicker.test.ts
Normal file
115
apps/web/src/lib/components/EmojiPicker.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Fase 19.4.1 — EmojiPicker component unit tests.
|
||||
*
|
||||
* Mirrors the Spinner.test.ts / ChangelogModal.test.ts pattern (Svelte 5
|
||||
* `mount`/`unmount`, no @testing-library dep).
|
||||
*
|
||||
* Specs:
|
||||
* EP-U-01 — 4 category tabs are rendered with role="tab".
|
||||
* EP-U-02 — default selected tab is "faces" (aria-selected on the faces tab).
|
||||
* EP-U-03 — clicking a gridcell fires `onSelect` with the underlying emoji.
|
||||
* EP-U-04 — clicking a different tab re-renders the grid with that
|
||||
* category's first emoji (lazy render: only the active grid
|
||||
* is mounted, so the previously-active grid is gone from the DOM).
|
||||
* EP-U-05 — ArrowRight moves focus from cell N to cell N+1.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import EmojiPicker from './EmojiPicker.svelte';
|
||||
import { EMOJI_CATALOG } from '$lib/data/emoji-catalog';
|
||||
|
||||
let host: HTMLElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let instance: any;
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
instance = mount(EmojiPicker, { target: host, props });
|
||||
flushSync();
|
||||
return host;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
if (host?.parentNode) host.parentNode.removeChild(host);
|
||||
instance = null;
|
||||
});
|
||||
|
||||
describe('EmojiPicker component', () => {
|
||||
it('EP-U-01: renders 4 category tabs with role="tab"', () => {
|
||||
const root = render({ selected: null, onSelect: () => {} });
|
||||
const tabs = root.querySelectorAll('[role="tab"]');
|
||||
expect(tabs.length).toBe(4);
|
||||
});
|
||||
|
||||
it('EP-U-02: default category is "faces" (aria-selected)', () => {
|
||||
const root = render({ selected: null, onSelect: () => {} });
|
||||
const facesTab = root.querySelector('[data-testid="emoji-picker-tab-faces"]');
|
||||
expect(facesTab).not.toBeNull();
|
||||
expect(facesTab?.getAttribute('aria-selected')).toBe('true');
|
||||
|
||||
// And the grid's first cell should be the first faces emoji.
|
||||
const firstCell = root.querySelector('[role="gridcell"]');
|
||||
expect(firstCell?.textContent?.trim()).toBe(EMOJI_CATALOG.faces[0]);
|
||||
});
|
||||
|
||||
it('EP-U-03: clicking a gridcell fires onSelect with the emoji', () => {
|
||||
const onSelect = vi.fn();
|
||||
const root = render({ selected: null, onSelect });
|
||||
const cells = root.querySelectorAll('[role="gridcell"]');
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
const target = cells[2] as HTMLElement;
|
||||
const expected = EMOJI_CATALOG.faces[2];
|
||||
target.click();
|
||||
expect(onSelect).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it('EP-U-04: switching tabs lazy-renders the new category grid', () => {
|
||||
const root = render({ selected: null, onSelect: () => {} });
|
||||
|
||||
const animalsTab = root.querySelector(
|
||||
'[data-testid="emoji-picker-tab-animals"]'
|
||||
) as HTMLElement;
|
||||
expect(animalsTab).not.toBeNull();
|
||||
animalsTab.click();
|
||||
flushSync();
|
||||
|
||||
// Tab state moved.
|
||||
expect(animalsTab.getAttribute('aria-selected')).toBe('true');
|
||||
expect(
|
||||
root
|
||||
.querySelector('[data-testid="emoji-picker-tab-faces"]')
|
||||
?.getAttribute('aria-selected')
|
||||
).toBe('false');
|
||||
|
||||
// Grid's first cell is now the first animals emoji.
|
||||
const firstCell = root.querySelector('[role="gridcell"]');
|
||||
expect(firstCell?.textContent?.trim()).toBe(EMOJI_CATALOG.animals[0]);
|
||||
|
||||
// Lazy-render invariant: only the active category's grid lives in the
|
||||
// DOM. We assert the total cell count equals the active category size.
|
||||
const cells = root.querySelectorAll('[role="gridcell"]');
|
||||
expect(cells.length).toBe(EMOJI_CATALOG.animals.length);
|
||||
});
|
||||
|
||||
it('EP-U-05: ArrowRight moves focus from one cell to the next', () => {
|
||||
const root = render({ selected: null, onSelect: () => {} });
|
||||
const cells = Array.from(root.querySelectorAll('[role="gridcell"]')) as HTMLElement[];
|
||||
expect(cells.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
cells[0].focus();
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
|
||||
cells[0].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
|
||||
);
|
||||
flushSync();
|
||||
|
||||
expect(document.activeElement).toBe(cells[1]);
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,13 @@
|
||||
Desktop (md+): wraps to two lines as before.
|
||||
-->
|
||||
<div
|
||||
data-testid="item-suggestions-strip"
|
||||
class="flex gap-1.5 overflow-x-auto px-4 pb-2 pt-1 md:flex-wrap md:overflow-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{#each suggestions as item (item.name)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="item-suggestion-{item.name}"
|
||||
onclick={() => onselect(item.name)}
|
||||
class="shrink-0 rounded-full px-3 py-1 text-[13px] font-medium transition-colors
|
||||
{activePrefix && item.name.startsWith(activePrefix.toLowerCase().trim())
|
||||
|
||||
41
apps/web/src/lib/components/OfflineChip.svelte
Normal file
41
apps/web/src/lib/components/OfflineChip.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Fase 14.2.1 — persistent offline indicator surfaced in the global
|
||||
* chrome (mobile top bar + desktop sidebar). Hidden while online.
|
||||
*
|
||||
* Fase 14.2.2: when there are queued ops, append a small numeric
|
||||
* badge so the user knows how many writes are still pending. The
|
||||
* count comes from `pendingOpsCount` (kept in sync by the SyncQueue
|
||||
* singleton). A non-zero count is shown even when the badge says 0
|
||||
* → 0 is filtered out — there's nothing to communicate when both
|
||||
* online AND empty.
|
||||
*
|
||||
* Tooltip explains the implication: changes will sync on reconnect.
|
||||
*/
|
||||
import { isOnline, pendingOpsCount } from '$lib/stores/syncStatus';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const { compact = false }: { compact?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
{#if !$isOnline}
|
||||
<div
|
||||
data-testid="pwa-offline-chip"
|
||||
title={m.pwa_offline_tooltip()}
|
||||
role="status"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
|
||||
{#if !compact}
|
||||
<span>{m.pwa_offline_chip()}</span>
|
||||
{/if}
|
||||
{#if $pendingOpsCount > 0}
|
||||
<span
|
||||
data-testid="pwa-offline-chip-badge"
|
||||
class="rounded-full bg-amber-500/20 px-1.5 text-[10px] font-semibold leading-4 text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{$pendingOpsCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
34
apps/web/src/lib/components/Spinner.svelte
Normal file
34
apps/web/src/lib/components/Spinner.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
class?: string;
|
||||
}
|
||||
let { size = 'md', class: extra = '' }: Props = $props();
|
||||
|
||||
const px = $derived(size === 'sm' ? 16 : size === 'lg' ? 40 : 24);
|
||||
</script>
|
||||
|
||||
<div role="status" aria-live="polite" class={extra}>
|
||||
<svg
|
||||
data-testid="spinner"
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
class="animate-spin motion-reduce:animate-none"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="42 14"
|
||||
opacity="0.75"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">{m.loading()}</span>
|
||||
</div>
|
||||
79
apps/web/src/lib/components/Spinner.test.ts
Normal file
79
apps/web/src/lib/components/Spinner.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Fase 16 — Spinner component unit tests.
|
||||
*
|
||||
* Vitest + jsdom + Svelte 5's `mount()` API. There is no @testing-library/svelte
|
||||
* in the repo (yet), but Svelte 5 exposes `mount`/`unmount` directly so we can
|
||||
* inspect the rendered DOM without an extra dependency.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mount, unmount } from 'svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let host: HTMLElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let instance: any;
|
||||
|
||||
function render(props: Record<string, unknown> = {}) {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
instance = mount(Spinner, { target: host, props });
|
||||
return host;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
if (host?.parentNode) host.parentNode.removeChild(host);
|
||||
});
|
||||
|
||||
describe('Spinner component', () => {
|
||||
it('SP-U-01: default size renders a 24x24 SVG', () => {
|
||||
const root = render();
|
||||
const svg = root.querySelector('svg[data-testid="spinner"]');
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg?.getAttribute('width')).toBe('24');
|
||||
expect(svg?.getAttribute('height')).toBe('24');
|
||||
});
|
||||
|
||||
it("SP-U-02: size='sm' renders 16x16 and size='lg' renders 40x40", () => {
|
||||
const smRoot = render({ size: 'sm' });
|
||||
const smSvg = smRoot.querySelector('svg[data-testid="spinner"]');
|
||||
expect(smSvg?.getAttribute('width')).toBe('16');
|
||||
expect(smSvg?.getAttribute('height')).toBe('16');
|
||||
unmount(instance);
|
||||
instance = null;
|
||||
smRoot.remove();
|
||||
|
||||
const lgRoot = render({ size: 'lg' });
|
||||
const lgSvg = lgRoot.querySelector('svg[data-testid="spinner"]');
|
||||
expect(lgSvg?.getAttribute('width')).toBe('40');
|
||||
expect(lgSvg?.getAttribute('height')).toBe('40');
|
||||
});
|
||||
|
||||
it('SP-U-03: outer wrapper exposes role="status" and aria-live="polite"', () => {
|
||||
const root = render();
|
||||
const wrapper = root.querySelector('[role="status"]');
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper?.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it("SP-U-04: includes an sr-only label sourced from Paraglide m.loading()", () => {
|
||||
const root = render();
|
||||
const srOnly = root.querySelector('.sr-only');
|
||||
expect(srOnly).not.toBeNull();
|
||||
// Paraglide's English default is "Loading…" — we don't hard-code it here,
|
||||
// we compare against the message itself so an i18n switch doesn't break.
|
||||
expect(srOnly?.textContent).toBe(m.loading());
|
||||
});
|
||||
|
||||
it('SP-U-05: forwards the `class` prop onto the wrapper element', () => {
|
||||
const root = render({ class: 'text-amber-500 ml-2' });
|
||||
const wrapper = root.querySelector('[role="status"]');
|
||||
expect(wrapper?.className).toContain('text-amber-500');
|
||||
expect(wrapper?.className).toContain('ml-2');
|
||||
});
|
||||
});
|
||||
124
apps/web/src/lib/components/SyncConflictsPanel.svelte
Normal file
124
apps/web/src/lib/components/SyncConflictsPanel.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Fase 9.3.3 — debug panel for the last 20 sync_conflicts rows owned
|
||||
* by the current user. Gated on `import.meta.env.DEV` so it never
|
||||
* ships in a production bundle; flip the prop to force-show for an
|
||||
* eventual "secret toggle" without touching the gate.
|
||||
*
|
||||
* The component is presentational: pass in a `loader` that returns
|
||||
* the rows so tests can inject a mock without going through Supabase.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type ConflictRow = {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
local_version: unknown;
|
||||
remote_version: unknown;
|
||||
resolution: 'remote_won' | 'local_won';
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Loader = (userId: string) => Promise<ConflictRow[]>;
|
||||
|
||||
const defaultLoader: Loader = async (userId) => {
|
||||
const { data } = await getSupabase()
|
||||
.from('sync_conflicts')
|
||||
.select('id, entity_type, entity_id, local_version, remote_version, resolution, created_at')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
return (data ?? []) as ConflictRow[];
|
||||
};
|
||||
|
||||
let {
|
||||
loader = defaultLoader,
|
||||
forceShow = false
|
||||
}: { loader?: Loader; forceShow?: boolean } = $props();
|
||||
|
||||
let rows = $state<ConflictRow[]>([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
// Hide in production unless `forceShow` is set. `import.meta.env.DEV`
|
||||
// is statically replaced at build time. `$derived` re-evaluates when
|
||||
// the prop changes (e.g. a parent toggles the secret unlock).
|
||||
const visible = $derived(import.meta.env.DEV || forceShow);
|
||||
|
||||
onMount(async () => {
|
||||
if (!visible || !$currentUser) {
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rows = await loader($currentUser.id);
|
||||
} catch {
|
||||
rows = [];
|
||||
}
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
function exportJson() {
|
||||
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `sync-conflicts-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<section data-testid="sync-conflicts-panel">
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<p class="text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_sync_conflicts()}
|
||||
</p>
|
||||
{#if rows.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={exportJson}
|
||||
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
data-testid="sync-conflicts-export"
|
||||
>
|
||||
{m.settings_sync_conflicts_export()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loaded && rows.length === 0}
|
||||
<p class="text-sm text-text-secondary" data-testid="sync-conflicts-empty">
|
||||
{m.settings_sync_conflicts_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each rows as row (row.id)}
|
||||
<li
|
||||
data-testid="sync-conflict-row"
|
||||
class="rounded-md border border-slate-200 bg-surface p-3 text-xs dark:border-slate-700"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between text-text-secondary">
|
||||
<span class="font-mono">{row.entity_type}</span>
|
||||
<span>{new Date(row.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p class="text-text-muted">local</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(row.local_version, null, 0)}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-text-muted">remote ({row.resolution})</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(row.remote_version, null, 0)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
67
apps/web/src/lib/components/TagChip.svelte
Normal file
67
apps/web/src/lib/components/TagChip.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import type { ItemTagColor } from '@colectivo/types';
|
||||
|
||||
/**
|
||||
* Small pill that renders an item tag.
|
||||
*
|
||||
* - default: display-only `<span>`
|
||||
* - `onRemove`: renders the pill as a `<span>` with an inline X button
|
||||
* - `onSelect`: renders the pill as a single `<button>` (used by the
|
||||
* filter bar and item-row chips that filter the list on click)
|
||||
*
|
||||
* `onSelect` and `onRemove` are mutually exclusive — supplying both is a
|
||||
* programming error (nested `<button>` is invalid HTML and SvelteKit
|
||||
* warns about hydration mismatches). The picker-selected variant uses
|
||||
* `onRemove`; the filter-bar variant uses `onSelect`.
|
||||
*/
|
||||
interface Props {
|
||||
name: string;
|
||||
color: ItemTagColor;
|
||||
onRemove?: () => void;
|
||||
onSelect?: () => void;
|
||||
active?: boolean;
|
||||
testid?: string;
|
||||
}
|
||||
|
||||
const { name, color, onRemove, onSelect, active = false, testid }: Props = $props();
|
||||
|
||||
// Tailwind cannot see dynamic class names — see tailwind.config.ts safelist.
|
||||
const bg = $derived(`bg-${color}-100 dark:bg-${color}-900/40`);
|
||||
const fg = $derived(`text-${color}-700 dark:text-${color}-200`);
|
||||
const ring = $derived(active ? `ring-1 ring-${color}-200 dark:ring-${color}-700/50` : '');
|
||||
const baseClasses = $derived(
|
||||
`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${bg} ${fg} ${ring}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if onSelect}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSelect}
|
||||
data-testid={testid ?? 'tag-chip'}
|
||||
data-tag={name}
|
||||
data-active={active ? 'true' : undefined}
|
||||
class="{baseClasses} transition-opacity hover:opacity-90"
|
||||
>
|
||||
<span class="truncate">{name}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
data-testid={testid ?? 'tag-chip'}
|
||||
data-tag={name}
|
||||
class={baseClasses}
|
||||
>
|
||||
<span class="truncate">{name}</span>
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
aria-label="Remove"
|
||||
class="-mr-0.5 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
130
apps/web/src/lib/components/TagPicker.svelte
Normal file
130
apps/web/src/lib/components/TagPicker.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { Plus, Check } from 'lucide-svelte';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import { filterTagOptions } from './tag-picker-filter';
|
||||
import type { ItemTag, ItemTagColor } from '@colectivo/types';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
/**
|
||||
* Combobox-style picker for collective tags.
|
||||
*
|
||||
* - filters `availableTags` by substring on `query`
|
||||
* - shows a "Create `query`" affordance when no exact match exists
|
||||
* - calls `onToggle(tag)` for an existing tag click (caller decides
|
||||
* attach vs detach by reading whether `selectedIds` already has it)
|
||||
* - calls `onCreate(name)` for a brand-new tag
|
||||
*
|
||||
* Pure UI — does not call the network itself. Re-usable from the item
|
||||
* modal (single-item attach/detach) and the list-filter bar (toggle
|
||||
* filter chips).
|
||||
*/
|
||||
interface Props {
|
||||
availableTags: ItemTag[];
|
||||
selectedIds: string[];
|
||||
onToggle: (tag: ItemTag) => void | Promise<void>;
|
||||
onCreate?: (name: string) => void | Promise<void>;
|
||||
placeholder?: string;
|
||||
showSelected?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
availableTags,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
placeholder,
|
||||
showSelected = true
|
||||
}: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
const result = $derived(filterTagOptions(availableTags, query, selectedIds));
|
||||
const selectedTags = $derived(availableTags.filter((t) => selectedIds.includes(t.id)));
|
||||
|
||||
async function handleCreate() {
|
||||
if (!onCreate || !result.canCreate) return;
|
||||
const name = result.createName;
|
||||
await onCreate(name);
|
||||
query = '';
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
async function handleToggle(tag: ItemTag) {
|
||||
await onToggle(tag);
|
||||
query = '';
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Prefer toggling the first match (mirrors typical combobox behaviour);
|
||||
// fall back to creating a new tag when nothing matches.
|
||||
if (result.matches.length > 0) {
|
||||
void handleToggle(result.matches[0]);
|
||||
} else if (result.canCreate) {
|
||||
void handleCreate();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div data-testid="tag-picker" class="space-y-2">
|
||||
{#if showSelected && selectedTags.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each selectedTags as tag (tag.id)}
|
||||
<TagChip
|
||||
name={tag.name}
|
||||
color={tag.color as ItemTagColor}
|
||||
onRemove={() => handleToggle(tag)}
|
||||
testid="tag-picker-selected"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={handleKeydown}
|
||||
type="text"
|
||||
placeholder={placeholder ?? m.tag_picker_placeholder()}
|
||||
data-testid="tag-picker-input"
|
||||
class="w-full rounded-md bg-background px-3 py-2 text-sm text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
|
||||
/>
|
||||
|
||||
<div class="flex max-h-48 flex-col gap-1 overflow-y-auto" data-testid="tag-picker-options">
|
||||
{#each result.matches as tag (tag.id)}
|
||||
{@const isSelected = selectedIds.includes(tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleToggle(tag)}
|
||||
data-testid="tag-picker-option"
|
||||
data-tag={tag.name}
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<TagChip name={tag.name} color={tag.color as ItemTagColor} />
|
||||
{#if isSelected}
|
||||
<Check size={14} strokeWidth={2} class="ml-auto text-text-secondary" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if result.canCreate && onCreate}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreate}
|
||||
data-testid="tag-picker-create"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
<span>{m.tag_picker_create({ name: result.createName })}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if result.matches.length === 0 && !result.canCreate}
|
||||
<p class="px-2 py-1.5 text-xs text-text-muted">{m.tag_picker_empty()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
53
apps/web/src/lib/components/ThemeToggle.svelte
Normal file
53
apps/web/src/lib/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Fase 9.1 — three-way segmented control: Light / Dark / System.
|
||||
*
|
||||
* The component is purely presentational state-wise: it reads the
|
||||
* authoritative `themePreference` store from `$lib/theme` and writes
|
||||
* back via `setThemePreference`. That single function takes care of
|
||||
* persisting to localStorage and flipping <html data-theme>.
|
||||
*
|
||||
* The optional `onChange` prop lets the parent persist the choice to
|
||||
* the user's row in `public.users.theme`. We keep the DB write outside
|
||||
* the toggle so the component stays storage-agnostic (and easy to test).
|
||||
*/
|
||||
import { themePreference, setThemePreference, type ThemePreference } from '$lib/theme';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let { onChange }: { onChange?: (next: ThemePreference) => void } = $props();
|
||||
|
||||
const options: Array<{ value: ThemePreference; label: () => string }> = [
|
||||
{ value: 'light', label: () => m.settings_theme_light() },
|
||||
{ value: 'dark', label: () => m.settings_theme_dark() },
|
||||
{ value: 'system', label: () => m.settings_theme_system() }
|
||||
];
|
||||
|
||||
function select(value: ThemePreference) {
|
||||
if ($themePreference === value) return;
|
||||
setThemePreference(value);
|
||||
onChange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={m.settings_appearance()}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-surface p-1 dark:border-slate-600 dark:bg-slate-800"
|
||||
data-testid="theme-toggle"
|
||||
>
|
||||
{#each options as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={$themePreference === opt.value}
|
||||
data-theme-option={opt.value}
|
||||
onclick={() => select(opt.value)}
|
||||
class="rounded px-3 py-1.5 text-xs font-medium transition-colors
|
||||
{$themePreference === opt.value
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
|
||||
>
|
||||
{opt.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
92
apps/web/src/lib/components/UpdateToast.svelte
Normal file
92
apps/web/src/lib/components/UpdateToast.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Fase 14.1 — PWA update + offline-ready notification.
|
||||
*
|
||||
* Driven by two writable<boolean> stores produced by `useRegisterSW()`
|
||||
* (or its test stub). The component is purely presentational: state
|
||||
* lives in the stores, behaviour (`onReload`) is injected.
|
||||
*
|
||||
* - When `needRefresh` flips true, a sticky pill appears with a
|
||||
* "Reload" button. Clicking it calls `onReload()` which is wired
|
||||
* in the root layout to `updateServiceWorker(true)`.
|
||||
* - When `offlineReady` flips true (first install), a transient
|
||||
* pill appears for 4s and then auto-dismisses.
|
||||
*
|
||||
* Both pills share the same bottom-center slot. If both fire at once
|
||||
* the update toast wins (more actionable).
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const {
|
||||
needRefresh,
|
||||
offlineReady,
|
||||
onReload
|
||||
}: {
|
||||
needRefresh: Writable<boolean>;
|
||||
offlineReady: Writable<boolean>;
|
||||
onReload: () => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let showOfflineReady = $state(false);
|
||||
let offlineTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
const unsub = offlineReady.subscribe((v) => {
|
||||
if (!v) return;
|
||||
showOfflineReady = true;
|
||||
console.info('[pwa] offline-ready');
|
||||
if (offlineTimer) clearTimeout(offlineTimer);
|
||||
offlineTimer = setTimeout(() => {
|
||||
showOfflineReady = false;
|
||||
offlineReady.set(false);
|
||||
}, 4_000);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
if (offlineTimer) clearTimeout(offlineTimer);
|
||||
};
|
||||
});
|
||||
|
||||
async function handleReload() {
|
||||
console.info('[pwa] applying update');
|
||||
await onReload();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $needRefresh}
|
||||
<div
|
||||
data-testid="pwa-update-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4 md:bottom-6"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto flex items-center gap-3 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.3)] dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
<span>{m.pwa_update_available()}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pwa-update-reload"
|
||||
onclick={handleReload}
|
||||
class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold text-white hover:bg-white/25 dark:bg-slate-900/15 dark:text-slate-900 dark:hover:bg-slate-900/25"
|
||||
>
|
||||
{m.pwa_update_reload()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if showOfflineReady}
|
||||
<div
|
||||
data-testid="pwa-offline-ready-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4 md:bottom-6"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.3)] dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
<span>{m.pwa_offline_ready()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,15 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { enabledSections } from '$lib/stores/features';
|
||||
import type { SectionKey } from '@colectivo/types';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { ShoppingCart, CheckSquare, FileText, Search } from 'lucide-svelte';
|
||||
|
||||
const items = [
|
||||
{ href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
||||
{ href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
||||
{ href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
||||
{ href: '/search', label: () => m.nav_search(), icon: Search }
|
||||
const allItems: Array<{ section: SectionKey; href: string; label: () => string; icon: typeof ShoppingCart }> = [
|
||||
{ section: 'lists', href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
||||
{ section: 'tasks', href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
||||
{ section: 'notes', href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
||||
{ section: 'search', href: '/search', label: () => m.nav_search(), icon: Search }
|
||||
];
|
||||
|
||||
// Fase 12.3.1/12.3.3: filter by enabled flags + recompute the grid column
|
||||
// count from the visible length so the tab-bar adapts (the original was
|
||||
// `justify-around` over a fixed 4; we keep that distribution but the
|
||||
// number of children is now dynamic). "lists" is always shown
|
||||
// (§12.3.4 hard override).
|
||||
const items = $derived(
|
||||
allItems.filter((it) => it.section === 'lists' || $enabledSections[it.section])
|
||||
);
|
||||
|
||||
const activePath = $derived($page.url.pathname);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
@@ -19,14 +30,16 @@
|
||||
|
||||
<nav
|
||||
data-testid="bottom-tab-bar"
|
||||
data-section-count={items.length}
|
||||
class="md:hidden fixed inset-x-0 bottom-0 z-40 border-t border-black/5 bg-surface/80 backdrop-blur-xl pb-[env(safe-area-inset-bottom)] dark:border-white/5"
|
||||
>
|
||||
<ul class="flex items-stretch justify-around">
|
||||
{#each items as { href, label, icon: Icon }}
|
||||
{#each items as { section, href, label, icon: Icon } (section)}
|
||||
{@const active = isActive(href)}
|
||||
<li class="flex-1">
|
||||
<a
|
||||
{href}
|
||||
data-section={section}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={label()}
|
||||
class="flex h-14 flex-col items-center justify-center gap-0.5 text-xs transition-colors {active ? 'text-slate-900 dark:text-slate-50' : 'text-slate-400 dark:text-slate-500'}"
|
||||
|
||||
@@ -2,18 +2,33 @@
|
||||
import { page } from '$app/stores';
|
||||
import { displayName } from '$lib/stores/auth';
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import { enabledSections } from '$lib/stores/features';
|
||||
import { isServerAdmin } from '$lib/stores/serverAdmin';
|
||||
import type { SectionKey } from '@colectivo/types';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
||||
import OfflineChip from '$lib/components/OfflineChip.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { ShoppingCart, CheckSquare, FileText, Search, Settings } from 'lucide-svelte';
|
||||
import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus, Shield, Users } from 'lucide-svelte';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
||||
{ href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
||||
{ href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
||||
{ href: '/search', label: () => m.nav_search(), icon: Search }
|
||||
// Fase 12.3.1: nav items declared with their section key so we can filter
|
||||
// by $enabledSections (precedence resolved client-side; see features.ts).
|
||||
// Force "lists" through unconditionally in case every flag layer says OFF
|
||||
// — matches the BottomTabBar / MobileDrawer "always one safe landing"
|
||||
// rule documented in §12.3.4.
|
||||
const allNavItems: Array<{ section: SectionKey; href: string; label: () => string; icon: typeof ShoppingCart }> = [
|
||||
{ section: 'lists', href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
||||
{ section: 'tasks', href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
||||
{ section: 'notes', href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
||||
{ section: 'search', href: '/search', label: () => m.nav_search(), icon: Search }
|
||||
];
|
||||
|
||||
const navItems = $derived(
|
||||
allNavItems.filter((item) => item.section === 'lists' || $enabledSections[item.section])
|
||||
);
|
||||
|
||||
let switcherOpen = $state(false);
|
||||
let showCreate = $state(false);
|
||||
|
||||
const activePath = $derived($page.url.pathname);
|
||||
|
||||
@@ -25,6 +40,11 @@
|
||||
switcherOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateCollective() {
|
||||
switcherOpen = false;
|
||||
showCreate = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
@@ -33,6 +53,7 @@
|
||||
>
|
||||
<div class="relative">
|
||||
<button
|
||||
data-testid="sidebar-collective-switcher"
|
||||
class="flex h-14 w-full items-center gap-2 px-4 text-left hover:bg-black/5 dark:hover:bg-white/5"
|
||||
onclick={() => (switcherOpen = !switcherOpen)}
|
||||
aria-expanded={switcherOpen}
|
||||
@@ -41,11 +62,9 @@
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
{$currentCollective?.name ?? m.app_name()}
|
||||
</span>
|
||||
{#if $userCollectives.length > 1}
|
||||
<span class="text-xs text-slate-400">⌄</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if switcherOpen && $userCollectives.length > 1}
|
||||
{#if switcherOpen}
|
||||
<div class="absolute left-0 right-0 top-full z-50 bg-surface-raised shadow-[0px_20px_40px_rgba(15,23,42,0.06)]">
|
||||
{#each $userCollectives as c}
|
||||
<button
|
||||
@@ -56,23 +75,61 @@
|
||||
<span class="truncate">{c.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
data-testid="sidebar-create-collective"
|
||||
class="flex w-full items-center gap-2 border-t border-black/5 px-4 py-2.5 text-left text-sm font-medium text-slate-700
|
||||
hover:bg-black/5 dark:border-white/5 dark:text-slate-300 dark:hover:bg-white/5"
|
||||
onclick={openCreateCollective}
|
||||
>
|
||||
<Plus size={14} strokeWidth={1.5} />
|
||||
{m.sidebar_create_collective()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3">
|
||||
{#each navItems as { href, label, icon: Icon }}
|
||||
{#each navItems as { section, href, label, icon: Icon } (section)}
|
||||
<a
|
||||
{href}
|
||||
data-section={section}
|
||||
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {activePath.startsWith(href) ? 'bg-black/10 text-slate-900 dark:bg-white/10 dark:text-slate-50' : 'text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50'}"
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.5} />
|
||||
{label()}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- Spacer + manage-collective entry, last in nav. Always visible
|
||||
(no section-flag gating) since membership management is orthogonal
|
||||
to feature toggles. -->
|
||||
<div class="my-2 border-t border-black/10 dark:border-white/10" aria-hidden="true"></div>
|
||||
<a
|
||||
href="/collective/manage"
|
||||
data-testid="sidebar-manage-link"
|
||||
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {activePath.startsWith('/collective/manage') ? 'bg-black/10 text-slate-900 dark:bg-white/10 dark:text-slate-50' : 'text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50'}"
|
||||
>
|
||||
<Users size={16} strokeWidth={1.5} />
|
||||
{m.manage_title()}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-3 pt-2">
|
||||
<div class="mb-2 flex justify-end">
|
||||
<OfflineChip />
|
||||
</div>
|
||||
{#if $isServerAdmin}
|
||||
<!-- Fase 13.4: server-admin entry. Distinct red icon so it doesn't
|
||||
blend with normal app nav. Hidden for non-admins. -->
|
||||
<a
|
||||
href="/admin"
|
||||
data-testid="sidebar-admin-link"
|
||||
class="mb-2 flex items-center gap-2 rounded-md border border-red-200 bg-red-50/50 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||
>
|
||||
<Shield size={14} strokeWidth={1.5} />
|
||||
{m.admin_menu_entry()}
|
||||
</a>
|
||||
{/if}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Avatar name={$displayName} size={32} />
|
||||
@@ -90,3 +147,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#if showCreate}
|
||||
<CreateCollectiveModal onClose={() => (showCreate = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import { displayName } from '$lib/stores/auth';
|
||||
import { isServerAdmin } from '$lib/stores/serverAdmin';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
||||
import { logout } from '$lib/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { Settings, Users, LogOut, X } from 'lucide-svelte';
|
||||
import { Settings, Users, LogOut, X, Plus, Shield } from 'lucide-svelte';
|
||||
|
||||
let showCreate = $state(false);
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
@@ -65,10 +69,21 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
close();
|
||||
showCreate = true;
|
||||
}}
|
||||
class="mt-2 flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus size={14} strokeWidth={1.5} />
|
||||
{m.sidebar_create_collective()}
|
||||
</button>
|
||||
<a
|
||||
href="/collective/manage"
|
||||
onclick={close}
|
||||
class="mt-2 flex items-center gap-2 rounded-md px-2 py-2 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
class="mt-1 flex items-center gap-2 rounded-md px-2 py-2 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Users size={14} strokeWidth={1.5} />
|
||||
{m.manage_title()}
|
||||
@@ -84,6 +99,18 @@
|
||||
<Avatar name={$displayName} size={28} />
|
||||
<span class="truncate text-sm font-medium text-text-primary">{$displayName}</span>
|
||||
</div>
|
||||
{#if $isServerAdmin}
|
||||
<!-- Fase 13.4: server-admin entry. Same gating as DesktopSidebar. -->
|
||||
<a
|
||||
href="/admin"
|
||||
onclick={close}
|
||||
data-testid="drawer-admin-link"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||
>
|
||||
<Shield size={14} strokeWidth={1.5} />
|
||||
{m.admin_menu_entry()}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={close}
|
||||
@@ -106,3 +133,7 @@
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
{#if showCreate}
|
||||
<CreateCollectiveModal onClose={() => (showCreate = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { displayName } from '$lib/stores/auth';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import OfflineChip from '$lib/components/OfflineChip.svelte';
|
||||
import { Menu } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -26,6 +27,8 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<OfflineChip compact={true} />
|
||||
|
||||
<a href="/settings" aria-label={m.nav_settings()} class="rounded-full">
|
||||
<Avatar name={$displayName} size={28} />
|
||||
</a>
|
||||
|
||||
58
apps/web/src/lib/components/tag-picker-filter.test.ts
Normal file
58
apps/web/src/lib/components/tag-picker-filter.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { filterTagOptions } from './tag-picker-filter';
|
||||
import type { ItemTag } from '@colectivo/types';
|
||||
|
||||
function tag(name: string, id = name): ItemTag {
|
||||
return {
|
||||
id,
|
||||
collective_id: 'c',
|
||||
name,
|
||||
color: 'slate',
|
||||
created_at: '2026-05-18T00:00:00Z'
|
||||
};
|
||||
}
|
||||
|
||||
describe('filterTagOptions', () => {
|
||||
const all = [tag('Vegano'), tag('Oferta'), tag('Vegetariano'), tag('Bio')];
|
||||
|
||||
it('TP-01: substring match is case-insensitive', () => {
|
||||
const result = filterTagOptions(all, 'veg', []);
|
||||
expect(result.matches.map((t) => t.name)).toEqual(['Vegano', 'Vegetariano']);
|
||||
});
|
||||
|
||||
it('TP-02: returns the full list when query is empty', () => {
|
||||
const result = filterTagOptions(all, '', []);
|
||||
expect(result.matches).toHaveLength(4);
|
||||
expect(result.canCreate).toBe(false);
|
||||
});
|
||||
|
||||
it('TP-03: canCreate is true when the query is non-empty and no exact match exists', () => {
|
||||
const result = filterTagOptions(all, 'Lácteo', []);
|
||||
expect(result.canCreate).toBe(true);
|
||||
expect(result.createName).toBe('Lácteo');
|
||||
});
|
||||
|
||||
it('TP-04: canCreate is false when an exact (case-insensitive) match exists', () => {
|
||||
const result = filterTagOptions(all, 'vegano', []);
|
||||
expect(result.canCreate).toBe(false);
|
||||
// The exact match must still appear in the matches list.
|
||||
expect(result.matches.map((t) => t.name)).toContain('Vegano');
|
||||
});
|
||||
|
||||
it('TP-05: excludes already-selected ids from the suggestions', () => {
|
||||
const result = filterTagOptions(all, 'veg', ['Vegano']);
|
||||
expect(result.matches.map((t) => t.name)).toEqual(['Vegetariano']);
|
||||
});
|
||||
|
||||
it('TP-06: trims whitespace before evaluating the query', () => {
|
||||
const result = filterTagOptions(all, ' ', []);
|
||||
expect(result.matches).toHaveLength(4);
|
||||
expect(result.canCreate).toBe(false);
|
||||
});
|
||||
|
||||
it('TP-07: createName preserves the user input verbatim (no lowercasing)', () => {
|
||||
const result = filterTagOptions(all, ' Sin-Gluten ', []);
|
||||
expect(result.createName).toBe('Sin-Gluten');
|
||||
expect(result.canCreate).toBe(true);
|
||||
});
|
||||
});
|
||||
45
apps/web/src/lib/components/tag-picker-filter.ts
Normal file
45
apps/web/src/lib/components/tag-picker-filter.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Pure logic backing TagPicker.svelte — extracted so it can be unit-tested
|
||||
* without rendering a component.
|
||||
*
|
||||
* Given the collective's full tag list, a search query, and the set of
|
||||
* already-selected tag ids, returns:
|
||||
* - `matches`: tags matching the query (substring, case-insensitive)
|
||||
* with already-selected entries filtered out
|
||||
* - `canCreate`: whether the "Create `query`" affordance should appear
|
||||
* (true iff the trimmed query is non-empty AND no existing tag matches
|
||||
* the query exactly, case-insensitive)
|
||||
* - `createName`: the verbatim trimmed query — used as the new tag name
|
||||
* when the user picks the create affordance (no casing applied)
|
||||
*/
|
||||
import type { ItemTag } from '@colectivo/types';
|
||||
|
||||
export interface TagFilterResult {
|
||||
matches: ItemTag[];
|
||||
canCreate: boolean;
|
||||
createName: string;
|
||||
}
|
||||
|
||||
export function filterTagOptions(
|
||||
all: ItemTag[],
|
||||
query: string,
|
||||
selectedIds: string[]
|
||||
): TagFilterResult {
|
||||
const trimmed = query.trim();
|
||||
const lowerQuery = trimmed.toLowerCase();
|
||||
const selectedSet = new Set(selectedIds);
|
||||
|
||||
const filtered = all.filter((t) => !selectedSet.has(t.id));
|
||||
|
||||
const matches = trimmed
|
||||
? filtered.filter((t) => t.name.toLowerCase().includes(lowerQuery))
|
||||
: filtered;
|
||||
|
||||
const exactMatchExists = all.some((t) => t.name.toLowerCase() === lowerQuery);
|
||||
|
||||
return {
|
||||
matches,
|
||||
canCreate: trimmed.length > 0 && !exactMatchExists,
|
||||
createName: trimmed
|
||||
};
|
||||
}
|
||||
64
apps/web/src/lib/data/emoji-catalog.test.ts
Normal file
64
apps/web/src/lib/data/emoji-catalog.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Fase 19.4.2 — EmojiCatalog data-module unit tests.
|
||||
*
|
||||
* The catalog is a hardcoded `Record<EmojiCategory, readonly string[]>`. It
|
||||
* has to satisfy size lower bounds (food + animals are the headline categories
|
||||
* being expanded), and never let a single emoji land in two categories — the
|
||||
* picker draws one tab at a time so duplicates would render in only one tab
|
||||
* silently and confuse the "selected" highlight when the source-of-truth tab
|
||||
* changes.
|
||||
*
|
||||
* Specs:
|
||||
* EC-U-01 — food >= 85 entries.
|
||||
* EC-U-02 — animals >= 130 entries.
|
||||
* EC-U-03 — no duplicates within any category.
|
||||
* EC-U-04 — no overlap across categories (an emoji belongs to exactly one).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EMOJI_CATALOG, CATEGORY_LABELS, type EmojiCategory } from './emoji-catalog';
|
||||
|
||||
describe('EMOJI_CATALOG', () => {
|
||||
it('EC-U-01: food category has at least 85 entries', () => {
|
||||
expect(EMOJI_CATALOG.food.length).toBeGreaterThanOrEqual(85);
|
||||
});
|
||||
|
||||
it('EC-U-02: animals category has at least 130 entries', () => {
|
||||
expect(EMOJI_CATALOG.animals.length).toBeGreaterThanOrEqual(130);
|
||||
});
|
||||
|
||||
it('EC-U-03: no duplicates within any category', () => {
|
||||
for (const cat of Object.keys(EMOJI_CATALOG) as EmojiCategory[]) {
|
||||
const list = EMOJI_CATALOG[cat];
|
||||
const set = new Set(list);
|
||||
expect(
|
||||
set.size,
|
||||
`category "${cat}" has duplicates (${list.length - set.size} repeats)`
|
||||
).toBe(list.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('EC-U-04: no overlap between categories — each emoji lives in exactly one', () => {
|
||||
const seen = new Map<string, EmojiCategory>();
|
||||
const conflicts: Array<{ emoji: string; first: EmojiCategory; second: EmojiCategory }> = [];
|
||||
for (const cat of Object.keys(EMOJI_CATALOG) as EmojiCategory[]) {
|
||||
for (const e of EMOJI_CATALOG[cat]) {
|
||||
const prev = seen.get(e);
|
||||
if (prev !== undefined && prev !== cat) {
|
||||
conflicts.push({ emoji: e, first: prev, second: cat });
|
||||
} else {
|
||||
seen.set(e, cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(conflicts, `cross-category duplicates: ${JSON.stringify(conflicts)}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('catalog has all 4 expected categories with non-empty label entries', () => {
|
||||
const expected: EmojiCategory[] = ['faces', 'food', 'animals', 'objects'];
|
||||
for (const cat of expected) {
|
||||
expect(EMOJI_CATALOG[cat]).toBeDefined();
|
||||
expect(EMOJI_CATALOG[cat].length).toBeGreaterThan(0);
|
||||
expect(CATEGORY_LABELS[cat]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
119
apps/web/src/lib/data/emoji-catalog.ts
Normal file
119
apps/web/src/lib/data/emoji-catalog.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Fase 19.1 — Emoji catalogue.
|
||||
*
|
||||
* Source of truth for the avatar / collective-icon picker. Hardcoded — not
|
||||
* fetched — because the list grows once per Unicode release and is small
|
||||
* enough (~280 strings, ~3KB) to ship in the JS bundle.
|
||||
*
|
||||
* Inclusion rules (kept narrow so every codepoint renders consistently on
|
||||
* iOS Safari ≥ 16, Android Chrome, and desktop browsers):
|
||||
*
|
||||
* 1. Unicode 14 baseline only. Unicode 15 + 16 codepoints (🩷, 🩵, 🪿,
|
||||
* 🪿, 🫨 etc.) are excluded — iOS < 17 renders them as tofu.
|
||||
* 2. NO ZWJ sequences (no 👨👩👧, no 🐕🦺, no 🏳️🌈). Two reasons:
|
||||
* (a) the picker stores `users.avatar_emoji` as text and ZWJ
|
||||
* sequences serialize as multi-codepoint strings whose width is
|
||||
* font-dependent; (b) older Android skins render them as the
|
||||
* component glyphs side-by-side.
|
||||
* 3. NO skin-tone variants (🏻 .. 🏿). Parked for a future pass — the
|
||||
* base codepoint always renders and is good enough for an avatar.
|
||||
* 4. Variation selectors kept where the canonical emoji form needs
|
||||
* them (☀️ U+2600 U+FE0F, ❤️ U+2764 U+FE0F) — leaving them off
|
||||
* shows the text presentation.
|
||||
*
|
||||
* Total ~280 entries split across four categories. The catalog must satisfy
|
||||
* the invariants enforced by `emoji-catalog.test.ts` (size lower bounds,
|
||||
* no intra-category duplicates, no cross-category overlap).
|
||||
*
|
||||
* Mutation procedure when Unicode lifts the floor: edit this file by hand,
|
||||
* run `pnpm --filter @colectivo/web test:unit -- emoji-catalog`, and verify
|
||||
* the bundle-size delta stays under ~10 KB gzipped.
|
||||
*/
|
||||
|
||||
export type EmojiCategory = 'faces' | 'food' | 'animals' | 'objects';
|
||||
|
||||
export const EMOJI_CATALOG: Record<EmojiCategory, readonly string[]> = {
|
||||
// ── Faces & people (smileys + a few hats/headwear). ~40 entries. ────────
|
||||
faces: [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
|
||||
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
|
||||
'😘', '😗', '😚', '🥲', '😋', '😛', '😜', '🤪',
|
||||
'😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨',
|
||||
'😐', '😑', '😶', '😏', '😒', '🙄', '😬', '😮',
|
||||
'🥱', '😴', '😪', '🤤', '😵', '🤯', '🤠', '🥳',
|
||||
'🥸', '😎', '🤓', '🧐', '😢', '😭', '😤', '😡',
|
||||
'🤬', '😈', '👿', '💀', '👻', '👽', '🤖', '👾'
|
||||
],
|
||||
|
||||
// ── Food & drink. All of Unicode 14 "Food & Drink" minus skin-tone
|
||||
// variants and ZWJ sequences. ~95 entries.
|
||||
food: [
|
||||
// Fruit
|
||||
'🍇', '🍈', '🍉', '🍊', '🍋', '🍌', '🍍', '🥭',
|
||||
'🍎', '🍏', '🍐', '🍑', '🍒', '🍓', '🫐', '🥝',
|
||||
'🍅', '🫒', '🥥',
|
||||
// Vegetables
|
||||
'🥑', '🍆', '🥔', '🥕', '🌽', '🌶️', '🫑', '🥒',
|
||||
'🥬', '🥦', '🧄', '🧅', '🍄', '🥜', '🫘', '🌰',
|
||||
// Prepared / bakery
|
||||
'🍞', '🥐', '🥖', '🫓', '🥨', '🥯', '🥞', '🧇',
|
||||
'🧀', '🍖', '🍗', '🥩', '🥓', '🍔', '🍟', '🍕',
|
||||
'🌭', '🥪', '🌮', '🌯', '🫔', '🥙', '🧆', '🥚',
|
||||
'🍳', '🥘', '🍲', '🫕', '🥣', '🥗', '🍿', '🧈',
|
||||
'🧂', '🥫',
|
||||
// Asian + global
|
||||
'🍱', '🍘', '🍙', '🍚', '🍛', '🍜', '🍝', '🍠',
|
||||
'🍢', '🍣', '🍤', '🍥', '🥮', '🍡', '🥟', '🥠',
|
||||
'🥡',
|
||||
// Sweets
|
||||
'🍦', '🍧', '🍨', '🍩', '🍪', '🎂', '🍰', '🧁',
|
||||
'🥧', '🍫', '🍬', '🍭', '🍮', '🍯',
|
||||
// Drinks
|
||||
'🍼', '🥛', '☕', '🫖', '🍵', '🍶', '🍾', '🍷',
|
||||
'🍸', '🍹', '🍺', '🍻', '🥂', '🥃', '🥤', '🧋',
|
||||
'🧃', '🧉', '🧊'
|
||||
],
|
||||
|
||||
// ── Animals & nature. All of Unicode 14 "Animals & Nature" minus ZWJ
|
||||
// sequences (eye contact, service dog, etc.). ~135 entries.
|
||||
animals: [
|
||||
// Mammals
|
||||
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
|
||||
'🐨', '🐯', '🦁', '🐮', '🐷', '🐽', '🐸', '🐵',
|
||||
'🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤',
|
||||
'🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗',
|
||||
'🐴', '🦄', '🐝', '🪱', '🐛', '🦋', '🐌', '🐞',
|
||||
'🐜', '🪰', '🪲', '🪳', '🦟', '🦗', '🕷️', '🕸️',
|
||||
'🦂', '🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑',
|
||||
'🦐', '🦞', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳',
|
||||
'🐋', '🦈', '🦭', '🐊', '🐅', '🐆', '🦓', '🦍',
|
||||
'🦧', '🦣', '🐘', '🦛', '🦏', '🐪', '🐫', '🦒',
|
||||
'🦘', '🦬', '🐃', '🐂', '🐄', '🐎', '🐖', '🐏',
|
||||
'🐑', '🦙', '🐐', '🦌', '🐕', '🐩', '🦮', '🐈',
|
||||
'🪶', '🐓', '🦃', '🦚', '🦜', '🦢', '🦩', '🕊️',
|
||||
'🐇', '🦝', '🦨', '🦡', '🦫', '🦦', '🦥', '🐁',
|
||||
'🐀', '🐿️', '🦔',
|
||||
// Plants
|
||||
'🌵', '🎄', '🌲', '🌳', '🌴', '🪵', '🌱', '🌿',
|
||||
'☘️', '🍀', '🎍', '🪴', '🎋', '🍃', '🍂', '🍁',
|
||||
'🌾', '💐', '🌷', '🌹', '🥀', '🌺', '🌸',
|
||||
'🌼', '🌻'
|
||||
],
|
||||
|
||||
// ── Misc household / nature / activity objects. Small curated set.
|
||||
// ~22 entries.
|
||||
objects: [
|
||||
'🏠', '🏡', '🏢', '🏬', '🏭', '🏰', '⛺', '🌍',
|
||||
'🌎', '🌏', '🌐', '⭐', '🌟', '✨', '🔥', '💧',
|
||||
'🌊', '🌈', '☀️', '🌙', '☁️', '⚡', '❄️', '🎁',
|
||||
'🎈', '🎉', '🎊', '🎵', '🎶', '🎯', '🎮', '🚀',
|
||||
'⚽', '🏀', '🎨', '🎭'
|
||||
]
|
||||
} as const;
|
||||
|
||||
export const CATEGORY_LABELS: Record<EmojiCategory, string> = {
|
||||
faces: 'Caras',
|
||||
food: 'Comida',
|
||||
animals: 'Animales',
|
||||
objects: 'Objetos'
|
||||
};
|
||||
@@ -1,10 +1,18 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { FeatureFlags } from '@colectivo/types';
|
||||
|
||||
export interface Collective {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
feature_flags: FeatureFlags;
|
||||
/**
|
||||
* Fase 18: per-collective default for the create-list modal's title input.
|
||||
* NULL/empty means no prefill — the input opens blank and the user must
|
||||
* type something before submit is enabled.
|
||||
*/
|
||||
default_list_title: string | null;
|
||||
}
|
||||
|
||||
export interface CollectiveMember {
|
||||
|
||||
105
apps/web/src/lib/stores/commonItems.ts
Normal file
105
apps/web/src/lib/stores/commonItems.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Common-items store (Fase 15) — the admin curation view of the per-collective
|
||||
* `item_frequency` table. Unlike `fetchSuggestions` (which is the bounded
|
||||
* dropdown query on the lists page), this loader is unbounded: the manage
|
||||
* screen shows the full catalogue so admins can sort, search and act on it.
|
||||
*
|
||||
* Writes go through SECURITY DEFINER RPCs that gate on
|
||||
* `collective_members.role = 'admin'`. The all-deny RLS from migration 006
|
||||
* is the safety net; the RPCs raise P0002 ("only admins…") for non-admins.
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { ItemFrequency } from '@colectivo/types';
|
||||
|
||||
export const commonItems = writable<ItemFrequency[]>([]);
|
||||
export const commonItemsLoading = writable(false);
|
||||
|
||||
export async function loadCommonItems(collectiveId: string): Promise<void> {
|
||||
commonItemsLoading.set(true);
|
||||
try {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('item_frequency')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.order('weight', { ascending: false })
|
||||
.order('use_count', { ascending: false })
|
||||
.order('last_used_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
commonItems.set(data as ItemFrequency[]);
|
||||
}
|
||||
} finally {
|
||||
commonItemsLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert weight for a single (collective, name). The RPC normalises name to
|
||||
* lower(trim(...)) internally; we mirror the same normalisation client-side
|
||||
* so the optimistic update lands on the right row.
|
||||
*
|
||||
* Returns the error (or null) so the caller can surface it as toast/message.
|
||||
*/
|
||||
export async function setWeight(
|
||||
collectiveId: string,
|
||||
name: string,
|
||||
weight: number
|
||||
): Promise<Error | null> {
|
||||
const normalized = name.toLowerCase().trim();
|
||||
if (!normalized) return new Error('name must not be empty');
|
||||
|
||||
const { error } = await getSupabase().rpc('set_item_frequency_weight', {
|
||||
p_collective_id: collectiveId,
|
||||
p_name: normalized,
|
||||
p_weight: weight
|
||||
});
|
||||
if (error) return new Error(error.message);
|
||||
|
||||
// Optimistic local update — upsert into the store.
|
||||
commonItems.update((arr) => {
|
||||
const idx = arr.findIndex((r) => r.name === normalized);
|
||||
if (idx >= 0) {
|
||||
const next = [...arr];
|
||||
next[idx] = { ...next[idx], weight };
|
||||
return sortRows(next);
|
||||
}
|
||||
const row: ItemFrequency = {
|
||||
collective_id: collectiveId,
|
||||
name: normalized,
|
||||
use_count: 0,
|
||||
weight,
|
||||
last_used_at: new Date().toISOString()
|
||||
};
|
||||
return sortRows([...arr, row]);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the (collective, name) row from item_frequency. Does NOT touch
|
||||
* shopping_items — items already living in lists are untouched, but the
|
||||
* dropdown will stop suggesting this name until someone adds it again (the
|
||||
* existing trigger will re-create the row with weight=0 then).
|
||||
*/
|
||||
export async function purge(collectiveId: string, name: string): Promise<Error | null> {
|
||||
const normalized = name.toLowerCase().trim();
|
||||
if (!normalized) return new Error('name must not be empty');
|
||||
|
||||
const { error } = await getSupabase().rpc('purge_item_frequency', {
|
||||
p_collective_id: collectiveId,
|
||||
p_name: normalized
|
||||
});
|
||||
if (error) return new Error(error.message);
|
||||
|
||||
commonItems.update((arr) => arr.filter((r) => r.name !== normalized));
|
||||
return null;
|
||||
}
|
||||
|
||||
function sortRows(arr: ItemFrequency[]): ItemFrequency[] {
|
||||
return [...arr].sort((a, b) => {
|
||||
if (b.weight !== a.weight) return b.weight - a.weight;
|
||||
if (b.use_count !== a.use_count) return b.use_count - a.use_count;
|
||||
return b.last_used_at.localeCompare(a.last_used_at);
|
||||
});
|
||||
}
|
||||
317
apps/web/src/lib/stores/features.ts
Normal file
317
apps/web/src/lib/stores/features.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Section visibility / feature flags — client-side mirror of
|
||||
* `public.section_enabled()` (Fase 12).
|
||||
*
|
||||
* Why mirror instead of round-trip per render: every navigation tile, redirect
|
||||
* guard, and tab-bar grid recompute would otherwise issue one PostgREST call
|
||||
* per section per render — three orders of magnitude too noisy. The server
|
||||
* function stays the source of truth (RLS-guarded writes still go through it
|
||||
* indirectly via the `users.feature_flags` / `collectives.feature_flags`
|
||||
* columns); this store just resolves the same precedence locally.
|
||||
*
|
||||
* Precedence (mirrors migration 023):
|
||||
* collective override > user override > default true
|
||||
* Fase 13 will add a server layer on top; this file plus the SQL function
|
||||
* are the two places to touch.
|
||||
*
|
||||
* Persistence layer:
|
||||
* - `currentUserFeatures` — { feature_flags: FeatureFlags } for the
|
||||
* logged-in user, populated by root `+layout.svelte`.
|
||||
* - `currentCollective` — already exposes `feature_flags` after the
|
||||
* `loadUserCollectives` extension in this fase.
|
||||
*
|
||||
* Realtime: this module subscribes to the relevant rows so an admin in
|
||||
* another tab can flip a collective flag and the current tab reacts
|
||||
* within a second. The subscriptions are keyed off the active user / active
|
||||
* collective and torn down when those change (one channel each at a time).
|
||||
*/
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import type { FeatureFlags, SectionKey } from '@colectivo/types';
|
||||
import { SECTION_KEYS } from '@colectivo/types';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
|
||||
// ── Stores ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The logged-in user's feature_flags row. Populated by root layout's
|
||||
* `loadCurrentUserFeatures()` after sign-in / token-refresh, and kept fresh
|
||||
* by the realtime subscription below. `null` until the first load resolves.
|
||||
*/
|
||||
export const currentUserFeatures = writable<{ feature_flags: FeatureFlags } | null>(null);
|
||||
|
||||
/**
|
||||
* Server-layer overrides (Fase 13). Mirrors
|
||||
* `public.server_settings.value WHERE key = 'default_sections'`. Loaded on
|
||||
* sign-in and refreshed (no realtime — promote/revoke at this layer is rare
|
||||
* and a reload is acceptable; a future migration can put server_settings in
|
||||
* the realtime publication if needed).
|
||||
*/
|
||||
export const serverSectionDefaults = writable<FeatureFlags>({});
|
||||
|
||||
/**
|
||||
* Derived map { section → boolean } applying the same precedence as
|
||||
* `public.section_enabled()`. Layers, MOST restrictive wins:
|
||||
* 1. server.default_sections[section] (operator override)
|
||||
* 2. collective.feature_flags[section] (admin override)
|
||||
* 3. user.feature_flags[section] (per-user override)
|
||||
* 4. default true
|
||||
*
|
||||
* Unknown section keys are not part of SECTION_KEYS so they are never queried
|
||||
* here — that path is reserved for SQL-side callers (migrations 13+).
|
||||
*/
|
||||
export const enabledSections = derived(
|
||||
[currentUserFeatures, currentCollective, serverSectionDefaults],
|
||||
([$user, $collective, $server]) => {
|
||||
const userFlags = ($user?.feature_flags ?? {}) as FeatureFlags;
|
||||
const collectiveFlags = ($collective?.feature_flags ?? {}) as FeatureFlags;
|
||||
const serverFlags = ($server ?? {}) as FeatureFlags;
|
||||
const out: Record<SectionKey, boolean> = {} as Record<SectionKey, boolean>;
|
||||
for (const key of SECTION_KEYS) {
|
||||
const serverVal = serverFlags[key];
|
||||
const collectiveVal = collectiveFlags[key];
|
||||
const userVal = userFlags[key];
|
||||
out[key] = serverVal ?? collectiveVal ?? userVal ?? true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Lower-cardinality derived view: just the SectionKeys that are enabled,
|
||||
* preserved in the SECTION_KEYS order. Cheap to iterate in nav lists.
|
||||
*
|
||||
* Edge case: if every layer answers OFF for every section, force "lists" ON
|
||||
* (the user must always have somewhere to land). This is the only client-side
|
||||
* hard override and matches plan §12.3.4.
|
||||
*/
|
||||
export const enabledSectionList = derived(enabledSections, ($flags) => {
|
||||
const visible = SECTION_KEYS.filter((k) => $flags[k]);
|
||||
if (visible.length === 0) return ['lists' as SectionKey];
|
||||
return visible;
|
||||
});
|
||||
|
||||
// ── Mutations ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write the calling user's per-section flag. Patches the existing JSONB row
|
||||
* so we don't clobber other sections.
|
||||
*/
|
||||
export async function setUserFeature(section: SectionKey, value: boolean): Promise<void> {
|
||||
const user = get(currentUser);
|
||||
if (!user) return;
|
||||
|
||||
const current = get(currentUserFeatures);
|
||||
const next: FeatureFlags = { ...(current?.feature_flags ?? {}), [section]: value };
|
||||
|
||||
// Optimistic local update — realtime echo will deduplicate.
|
||||
currentUserFeatures.set({ feature_flags: next });
|
||||
|
||||
const { error } = await getSupabase()
|
||||
.from('users')
|
||||
.update({ feature_flags: next })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (error) {
|
||||
// Roll back on failure so the toggle reflects ground truth.
|
||||
currentUserFeatures.set(current);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only: write the active collective's per-section flag. Patches the
|
||||
* existing JSONB row. RLS (collectives_update) returns 0 rows updated for
|
||||
* non-admins; we surface that as a thrown Error so the UI can show a hint
|
||||
* instead of silently no-op-ing.
|
||||
*/
|
||||
export async function setCollectiveFeature(
|
||||
section: SectionKey,
|
||||
value: boolean
|
||||
): Promise<void> {
|
||||
const collective = get(currentCollective);
|
||||
if (!collective) return;
|
||||
|
||||
const currentFlags = (collective.feature_flags ?? {}) as FeatureFlags;
|
||||
const next: FeatureFlags = { ...currentFlags, [section]: value };
|
||||
|
||||
// Optimistic local update for the active collective in both stores.
|
||||
const optimistic = { ...collective, feature_flags: next };
|
||||
currentCollective.set(optimistic);
|
||||
|
||||
const { data, error } = await getSupabase()
|
||||
.from('collectives')
|
||||
.update({ feature_flags: next })
|
||||
.eq('id', collective.id)
|
||||
.select('id, feature_flags');
|
||||
|
||||
if (error) {
|
||||
currentCollective.set(collective);
|
||||
throw error;
|
||||
}
|
||||
if (!data || data.length === 0) {
|
||||
// RLS denied — restore.
|
||||
currentCollective.set(collective);
|
||||
throw new Error('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Realtime ────────────────────────────────────────────────────────────────
|
||||
|
||||
let userChannel: RealtimeChannel | null = null;
|
||||
let userChannelUserId: string | null = null;
|
||||
let collectiveChannel: RealtimeChannel | null = null;
|
||||
let collectiveChannelId: string | null = null;
|
||||
|
||||
/**
|
||||
* Initial load + realtime subscription for the logged-in user's row. Called
|
||||
* by root `+layout.svelte` after the auth callback resolves a session.
|
||||
*
|
||||
* Per CLAUDE.md gotcha "loadUserCollectives ... inside onAuthStateChange must
|
||||
* be deferred" — callers MUST already have done the setTimeout(..., 0)
|
||||
* unwrap. We do not re-defer here.
|
||||
*/
|
||||
export async function loadCurrentUserFeatures(userId: string): Promise<void> {
|
||||
const supabase = getSupabase();
|
||||
const { data } = await supabase
|
||||
.from('users')
|
||||
.select('feature_flags')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
currentUserFeatures.set({
|
||||
feature_flags: ((data?.feature_flags ?? {}) as FeatureFlags) || {}
|
||||
});
|
||||
|
||||
// Fase 13: also fetch the server-layer defaults. Read-allowed to everyone
|
||||
// by `select_all` policy on server_settings. Failure is non-fatal — the
|
||||
// derived store falls through to collective/user/default.
|
||||
try {
|
||||
const { data: ssRow } = await supabase
|
||||
.from('server_settings')
|
||||
.select('value')
|
||||
.eq('key', 'default_sections')
|
||||
.maybeSingle();
|
||||
serverSectionDefaults.set(((ssRow?.value ?? {}) as FeatureFlags) || {});
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
if (userChannelUserId !== userId) {
|
||||
await teardownUserChannel();
|
||||
userChannelUserId = userId;
|
||||
const channel = supabase.channel(`features:user:${userId}`);
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'users',
|
||||
filter: `id=eq.${userId}`
|
||||
} as never,
|
||||
((payload: { new: { feature_flags?: FeatureFlags | null } }) => {
|
||||
const next = (payload.new?.feature_flags ?? {}) as FeatureFlags;
|
||||
currentUserFeatures.set({ feature_flags: next });
|
||||
}) as never
|
||||
);
|
||||
await subscribeWithTimeout(channel, `features:user:${userId}`);
|
||||
userChannel = channel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Realtime subscription for the active collective's row. Called by root
|
||||
* layout whenever `$currentCollective.id` changes.
|
||||
*/
|
||||
export async function subscribeCollectiveFeatures(collectiveId: string): Promise<void> {
|
||||
const supabase = getSupabase();
|
||||
if (collectiveChannelId === collectiveId) return;
|
||||
await teardownCollectiveChannel();
|
||||
collectiveChannelId = collectiveId;
|
||||
const channel = supabase.channel(`features:collective:${collectiveId}`);
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'collectives',
|
||||
filter: `id=eq.${collectiveId}`
|
||||
} as never,
|
||||
((payload: {
|
||||
new: {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
feature_flags?: FeatureFlags | null;
|
||||
default_list_title?: string | null;
|
||||
};
|
||||
}) => {
|
||||
const incoming = payload.new;
|
||||
const flags = (incoming.feature_flags ?? {}) as FeatureFlags;
|
||||
// Patch the active-collective store if the event refers to it.
|
||||
const active = get(currentCollective);
|
||||
if (active && active.id === incoming.id) {
|
||||
currentCollective.set({
|
||||
...active,
|
||||
name: incoming.name,
|
||||
emoji: incoming.emoji,
|
||||
feature_flags: flags,
|
||||
default_list_title: incoming.default_list_title ?? null
|
||||
});
|
||||
}
|
||||
}) as never
|
||||
);
|
||||
await subscribeWithTimeout(channel, `features:collective:${collectiveId}`);
|
||||
collectiveChannel = channel;
|
||||
}
|
||||
|
||||
export async function teardownFeatureSubscriptions(): Promise<void> {
|
||||
await teardownUserChannel();
|
||||
await teardownCollectiveChannel();
|
||||
currentUserFeatures.set(null);
|
||||
serverSectionDefaults.set({});
|
||||
}
|
||||
|
||||
async function teardownUserChannel(): Promise<void> {
|
||||
if (!userChannel) return;
|
||||
const supabase = getSupabase();
|
||||
try {
|
||||
await userChannel.unsubscribe();
|
||||
await supabase.removeChannel(userChannel);
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
userChannel = null;
|
||||
userChannelUserId = null;
|
||||
}
|
||||
|
||||
async function teardownCollectiveChannel(): Promise<void> {
|
||||
if (!collectiveChannel) return;
|
||||
const supabase = getSupabase();
|
||||
try {
|
||||
await collectiveChannel.unsubscribe();
|
||||
await supabase.removeChannel(collectiveChannel);
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
collectiveChannel = null;
|
||||
collectiveChannelId = null;
|
||||
}
|
||||
|
||||
async function subscribeWithTimeout(channel: RealtimeChannel, label: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} subscribe timeout`)), 10_000);
|
||||
channel.subscribe((status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`${label} subscribe failed: ${status}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
224
apps/web/src/lib/stores/listTitles.ts
Normal file
224
apps/web/src/lib/stores/listTitles.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Fase 18: per-collective list-title catalog store + helpers.
|
||||
*
|
||||
* Two surfaces:
|
||||
*
|
||||
* • The /collective/manage UI loads the full catalog and lets admins
|
||||
* add/remove entries via the two SECURITY DEFINER RPCs from migration
|
||||
* 027. Direct writes are all-deny RLS — the RPCs are the only entry
|
||||
* points. P0002 on non-admin matches the Fase 15 common-items flow.
|
||||
*
|
||||
* • The create-list modal queries `fetchTitleSuggestions(collectiveId,
|
||||
* prefix)` for the autocomplete dropdown — union of the catalog +
|
||||
* the last 10 distinct `shopping_lists.name` values, dedup'd case-
|
||||
* insensitively, capped at 15. And `computeNextNumber(collectiveId,
|
||||
* prefix)` to decide whether to auto-suffix "#N" on submit.
|
||||
*
|
||||
* The pure numbering rules live in `$lib/utils/list-title` so they're
|
||||
* unit-testable without a DB.
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { computeNextNumberFromNames } from '$lib/utils/list-title';
|
||||
|
||||
export interface CollectiveListTitle {
|
||||
collective_id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const listTitleCatalog = writable<CollectiveListTitle[]>([]);
|
||||
export const listTitleCatalogLoading = writable(false);
|
||||
|
||||
// ── Catalog (admin curation) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load every curated title for the active collective. Members + guests
|
||||
* read (the `select_member` policy on `collective_list_titles` includes
|
||||
* everyone in the collective); only admins write via the RPCs below.
|
||||
*/
|
||||
export async function loadTitleCatalog(collectiveId: string): Promise<void> {
|
||||
listTitleCatalogLoading.set(true);
|
||||
try {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('collective_list_titles')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.order('title', { ascending: true });
|
||||
|
||||
if (!error && data) {
|
||||
listTitleCatalog.set(data as CollectiveListTitle[]);
|
||||
}
|
||||
} finally {
|
||||
listTitleCatalogLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only: add a title to the catalog. The RPC trims + rejects empty +
|
||||
* is idempotent via ON CONFLICT DO NOTHING. Returns the error (or null)
|
||||
* so the caller can surface it as a toast / inline message.
|
||||
*/
|
||||
export async function addTitle(
|
||||
collectiveId: string,
|
||||
title: string
|
||||
): Promise<Error | null> {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed.length === 0) return new Error('title must not be empty');
|
||||
|
||||
const { error } = await getSupabase().rpc('add_list_title', {
|
||||
p_collective_id: collectiveId,
|
||||
p_title: trimmed
|
||||
});
|
||||
if (error) return new Error(error.message);
|
||||
|
||||
// Optimistic local upsert — keep the store sorted by title to match the
|
||||
// load order. The realtime subscription will overwrite this when the
|
||||
// INSERT event comes back, but the modal close shouldn't have to wait.
|
||||
listTitleCatalog.update((arr) => {
|
||||
if (arr.some((r) => r.title === trimmed)) return arr;
|
||||
const row: CollectiveListTitle = {
|
||||
collective_id: collectiveId,
|
||||
title: trimmed,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
return [...arr, row].sort((a, b) => a.title.localeCompare(b.title));
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only: remove a title from the catalog. Idempotent — removing a
|
||||
* row that was never there is a no-op (the DELETE just matches zero rows).
|
||||
*/
|
||||
export async function removeTitle(
|
||||
collectiveId: string,
|
||||
title: string
|
||||
): Promise<Error | null> {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed.length === 0) return new Error('title must not be empty');
|
||||
|
||||
const { error } = await getSupabase().rpc('remove_list_title', {
|
||||
p_collective_id: collectiveId,
|
||||
p_title: trimmed
|
||||
});
|
||||
if (error) return new Error(error.message);
|
||||
|
||||
listTitleCatalog.update((arr) => arr.filter((r) => r.title !== trimmed));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only: persist the new default_list_title on the collective itself.
|
||||
* Empty string / null clears the default. RLS on `collectives` already
|
||||
* gates UPDATE to admins — no separate RPC needed.
|
||||
*
|
||||
* Caller is responsible for re-fetching the active collective row (or
|
||||
* letting the features-store realtime subscription do it).
|
||||
*/
|
||||
export async function setDefaultListTitle(
|
||||
collectiveId: string,
|
||||
value: string | null
|
||||
): Promise<Error | null> {
|
||||
const normalized = value === null ? null : value.trim();
|
||||
const persisted = normalized && normalized.length > 0 ? normalized : null;
|
||||
|
||||
const { error } = await getSupabase()
|
||||
.from('collectives')
|
||||
.update({ default_list_title: persisted })
|
||||
.eq('id', collectiveId);
|
||||
|
||||
if (error) return new Error(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Modal helpers (autocomplete + numbering) ────────────────────────────────
|
||||
|
||||
const SUGGESTION_LIMIT = 15;
|
||||
const RECENT_LISTS_SCAN = 10;
|
||||
|
||||
/**
|
||||
* Return up to 15 suggestion strings for the create-list autocomplete.
|
||||
* Union of (a) the curated catalog and (b) the most recent
|
||||
* RECENT_LISTS_SCAN distinct `shopping_lists.name` values for the active
|
||||
* collective. Dedup case-insensitively (first occurrence wins).
|
||||
*
|
||||
* If `prefix` is non-empty, both sources are filtered case-insensitively
|
||||
* by prefix; otherwise the union is returned unfiltered.
|
||||
*/
|
||||
export async function fetchTitleSuggestions(
|
||||
collectiveId: string,
|
||||
prefix: string
|
||||
): Promise<string[]> {
|
||||
const normPrefix = prefix.trim().toLowerCase();
|
||||
|
||||
const sb = getSupabase();
|
||||
const catalogQuery = sb
|
||||
.from('collective_list_titles')
|
||||
.select('title')
|
||||
.eq('collective_id', collectiveId)
|
||||
.order('title', { ascending: true });
|
||||
if (normPrefix.length > 0) {
|
||||
catalogQuery.ilike('title', `${normPrefix}%`);
|
||||
}
|
||||
|
||||
const recentQuery = sb
|
||||
.from('shopping_lists')
|
||||
.select('name')
|
||||
.eq('collective_id', collectiveId)
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(RECENT_LISTS_SCAN);
|
||||
if (normPrefix.length > 0) {
|
||||
recentQuery.ilike('name', `${normPrefix}%`);
|
||||
}
|
||||
|
||||
const [catalogRes, recentRes] = await Promise.all([catalogQuery, recentQuery]);
|
||||
const catalog = (catalogRes.data ?? []).map((r) => r.title);
|
||||
const recent = (recentRes.data ?? []).map((r) => r.name);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const candidate of [...catalog, ...recent]) {
|
||||
const key = candidate.trim().toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(candidate);
|
||||
if (out.length >= SUGGESTION_LIMIT) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look at every active `shopping_lists.name` in the collective whose
|
||||
* prefix matches `basePrefix` (case-insensitive), and return the next
|
||||
* `#N` (max(N) + 1). Returns 1 when no rows match.
|
||||
*
|
||||
* Race-condition note: two clients computing this concurrently may both
|
||||
* resolve to the same N. Accepted (see migration 027 header). The DB has
|
||||
* no unique constraint on (collective_id, name) so the duplicate
|
||||
* "Compra #6" lands without error.
|
||||
*/
|
||||
export async function computeNextNumber(
|
||||
collectiveId: string,
|
||||
basePrefix: string
|
||||
): Promise<number> {
|
||||
const trimmed = basePrefix.trim();
|
||||
if (trimmed.length === 0) return 1;
|
||||
|
||||
const sb = getSupabase();
|
||||
const orFilter = `name.ilike.${trimmed},name.ilike.${trimmed} #%`;
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('shopping_lists')
|
||||
.select('name')
|
||||
.eq('collective_id', collectiveId)
|
||||
.is('deleted_at', null)
|
||||
.or(orFilter);
|
||||
|
||||
if (error || !data) return 1;
|
||||
return computeNextNumberFromNames(
|
||||
trimmed,
|
||||
data.map((r) => r.name)
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { generateId } from '$lib/utils/id';
|
||||
import type { ShoppingList, ShoppingItem, ItemFrequency } from '@colectivo/types';
|
||||
import type {
|
||||
ShoppingList,
|
||||
ShoppingItem,
|
||||
ItemFrequency,
|
||||
ItemTag,
|
||||
ItemWithTags
|
||||
} from '@colectivo/types';
|
||||
|
||||
// ── Global list store (overview page) ─────────────────────────────────────────
|
||||
|
||||
@@ -85,12 +91,19 @@ export async function softDeleteList(id: string) {
|
||||
}
|
||||
|
||||
export async function restoreList(id: string, collectiveId: string) {
|
||||
await getSupabase().from('shopping_lists').update({ deleted_at: null }).eq('id', id);
|
||||
// Fase 10.4: route through SECURITY DEFINER RPC so the role check is
|
||||
// explicit and not implicit-via-RLS (guest is allowed by UPDATE policy
|
||||
// today because we never bothered to scope `restoreList`-style writes
|
||||
// distinctly from "any UPDATE" in 005_shopping.sql — the RPC closes that).
|
||||
await getSupabase().rpc('restore_list', { p_list_id: id });
|
||||
await loadLists(collectiveId);
|
||||
}
|
||||
|
||||
export async function permanentDeleteList(id: string) {
|
||||
await getSupabase().from('shopping_lists').delete().eq('id', id);
|
||||
// Fase 10.4: matching RPC for hard delete, which also requires the row
|
||||
// to be soft-deleted first (defensive guard against accidental hard
|
||||
// delete from the active view).
|
||||
await getSupabase().rpc('hard_delete_list', { p_list_id: id });
|
||||
}
|
||||
|
||||
export async function archiveList(id: string) {
|
||||
@@ -140,6 +153,39 @@ export async function loadItems(listId: string): Promise<ShoppingItem[]> {
|
||||
return data as ShoppingItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load items with their tag rows embedded — one PostgREST call instead of
|
||||
* N+1 lookups. The join goes via shopping_item_tags; PostgREST returns
|
||||
* `{ ...item, shopping_item_tags: [{ item_tags: {...tag} }, ...] }` so we
|
||||
* flatten the shape before returning so callers see `tags: ItemTag[]`.
|
||||
*
|
||||
* Note: the realtime channel for shopping_item_tags is the source of truth
|
||||
* for live updates — the embedded view here is the cold-load snapshot only.
|
||||
*/
|
||||
export async function loadListItems(listId: string): Promise<ItemWithTags[]> {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('shopping_items')
|
||||
.select('*, shopping_item_tags(item_tags(*))')
|
||||
.eq('list_id', listId)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (error || !data) return [];
|
||||
|
||||
type Embedded = ShoppingItem & {
|
||||
shopping_item_tags: Array<{ item_tags: ItemTag | null }> | null;
|
||||
};
|
||||
// Cast via unknown: the curated database.ts does not declare the
|
||||
// shopping_items ↔ shopping_item_tags relationship to PostgREST, so the
|
||||
// generated query type sees `SelectQueryError`. The runtime shape is
|
||||
// correct (PostgREST resolves the embedding from the FK).
|
||||
return (data as unknown as Embedded[]).map(({ shopping_item_tags, ...rest }) => ({
|
||||
...rest,
|
||||
tags: (shopping_item_tags ?? [])
|
||||
.map((row) => row.item_tags)
|
||||
.filter((t): t is ItemTag => t !== null)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new item. Accepts an optional pre-generated `id` so callers can
|
||||
* use the same UUID as their local optimistic row — this makes the write
|
||||
@@ -228,26 +274,72 @@ export async function reorderItems(items: Pick<ShoppingItem, 'id'>[]) {
|
||||
|
||||
// ── Suggestions (item_frequency) ──────────────────────────────────────────────
|
||||
|
||||
export async function fetchSuggestions(
|
||||
collectiveId: string,
|
||||
prefix: string
|
||||
): Promise<ItemFrequency[]> {
|
||||
if (!prefix.trim()) {
|
||||
const { data } = await getSupabase()
|
||||
.from('item_frequency')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.order('use_count', { ascending: false })
|
||||
.limit(5);
|
||||
return (data as ItemFrequency[]) ?? [];
|
||||
/**
|
||||
* Fase 15: ordering switches from pure `use_count DESC` to
|
||||
* `weight DESC, use_count DESC, last_used_at DESC` — admins can curate the
|
||||
* top of the dropdown via the new `set_item_frequency_weight` RPC.
|
||||
*
|
||||
* `excludeNames` (optional) drops entries already present in the caller's
|
||||
* working list so the user is not nudged to re-add what they already have.
|
||||
* Each entry is lowercased + trimmed before being sent (matching the
|
||||
* normalized form `item_frequency.name` is stored in). When the array is
|
||||
* empty the .not() filter is skipped entirely — PostgREST renders an empty
|
||||
* `(...)` list as a SQL error, and the cost of adding the filter for zero
|
||||
* effect would be wasted bytes on the URL anyway.
|
||||
*
|
||||
* Long-list caveat: PostgREST encodes `not.in.(a,b,…)` literally in the
|
||||
* query string, and supabase-js sends it as a single GET. Some upstream
|
||||
* proxies / Kong configs cap request URI length around 16 KiB. We hard-cap
|
||||
* the exclude list at EXCLUDE_NAMES_HARD_CAP names — past that point the
|
||||
* filter degrades to a no-op (the dropdown may show a few names that are
|
||||
* already on the list; acceptable UX) instead of failing the whole query.
|
||||
*
|
||||
* `limit` overrides the default (5 without prefix, 10 with). The /collective
|
||||
* manage view does not call this — it has its own unbounded list query in
|
||||
* commonItems.ts — but UI experimentation may want a smaller bar.
|
||||
*/
|
||||
export interface FetchSuggestionsOptions {
|
||||
excludeNames?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const { data } = await getSupabase()
|
||||
const EXCLUDE_NAMES_HARD_CAP = 200;
|
||||
|
||||
export async function fetchSuggestions(
|
||||
collectiveId: string,
|
||||
prefix: string,
|
||||
options: FetchSuggestionsOptions = {}
|
||||
): Promise<ItemFrequency[]> {
|
||||
const hasPrefix = !!prefix.trim();
|
||||
const limit = options.limit ?? (hasPrefix ? 10 : 5);
|
||||
|
||||
let query = getSupabase()
|
||||
.from('item_frequency')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.ilike('name', `${prefix.toLowerCase().trim()}%`)
|
||||
.eq('collective_id', collectiveId);
|
||||
|
||||
if (hasPrefix) {
|
||||
query = query.ilike('name', `${prefix.toLowerCase().trim()}%`);
|
||||
}
|
||||
|
||||
const exclude = (options.excludeNames ?? [])
|
||||
.map((n) => n.toLowerCase().trim())
|
||||
.filter((n) => n.length > 0);
|
||||
|
||||
if (exclude.length > 0 && exclude.length <= EXCLUDE_NAMES_HARD_CAP) {
|
||||
// PostgREST in() takes a comma-separated list wrapped in parens. We
|
||||
// double-quote each entry to be safe against names containing commas
|
||||
// or parens (e.g. "rice (white)"); the inner double quotes are escaped
|
||||
// per PostgREST's filter grammar.
|
||||
const list = `(${exclude.map((n) => `"${n.replace(/"/g, '\\"')}"`).join(',')})`;
|
||||
query = query.not('name', 'in', list);
|
||||
}
|
||||
|
||||
const { data } = await query
|
||||
.order('weight', { ascending: false })
|
||||
.order('use_count', { ascending: false })
|
||||
.limit(10);
|
||||
.order('last_used_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
return (data as ItemFrequency[]) ?? [];
|
||||
}
|
||||
|
||||
57
apps/web/src/lib/stores/serverAdmin.ts
Normal file
57
apps/web/src/lib/stores/serverAdmin.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Server admin flag store (Fase 13.4).
|
||||
*
|
||||
* `is_server_admin()` is a cheap STABLE SECURITY DEFINER RPC; we cache its
|
||||
* result client-side so the sidebar entry, route guards, and any other
|
||||
* call-site can read `$isServerAdmin` synchronously without round-trip.
|
||||
*
|
||||
* Refresh hooks live in the root `+layout.svelte` — see the
|
||||
* `onAuthStateChange` block where we re-run `refreshServerAdminFlag()` on
|
||||
* INITIAL_SESSION / SIGNED_IN / TOKEN_REFRESHED. There is no realtime
|
||||
* subscription: promote/revoke is rare and the flag stays accurate until the
|
||||
* next token refresh (~1 hour) which is acceptable for an operator-facing
|
||||
* surface. A user who is freshly granted can also just reload the page.
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
|
||||
export const isServerAdmin = writable<boolean>(false);
|
||||
|
||||
/**
|
||||
* Has the flag been resolved at least once for the current session? Tracks
|
||||
* the distinction between "definitely not admin" (false + loaded=true) and
|
||||
* "we don't know yet" (false + loaded=false). The (admin) layout uses this
|
||||
* to avoid redirecting away from /admin during the millisecond between the
|
||||
* SIGNED_IN event firing and the RPC resolving. Without this guard, a hard
|
||||
* navigation to /admin lands the user back at / every time.
|
||||
*/
|
||||
export const isServerAdminLoaded = writable<boolean>(false);
|
||||
|
||||
/**
|
||||
* Re-read `is_server_admin()` and update the store. Call from the root layout
|
||||
* after the auth listener fires so the flag follows the user's session.
|
||||
*
|
||||
* Defensive: any error (network, RLS, missing function on an older deploy)
|
||||
* sets the flag to false rather than throwing — the admin area is gated by a
|
||||
* second server-side check (the RPC guard), so a stale `false` is the safe
|
||||
* default.
|
||||
*/
|
||||
export async function refreshServerAdminFlag(): Promise<void> {
|
||||
try {
|
||||
const { data, error } = await getSupabase().rpc('is_server_admin', {});
|
||||
if (error) {
|
||||
isServerAdmin.set(false);
|
||||
return;
|
||||
}
|
||||
isServerAdmin.set(data === true);
|
||||
} catch {
|
||||
isServerAdmin.set(false);
|
||||
} finally {
|
||||
isServerAdminLoaded.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearServerAdminFlag(): void {
|
||||
isServerAdmin.set(false);
|
||||
isServerAdminLoaded.set(false);
|
||||
}
|
||||
@@ -30,6 +30,13 @@ export const isOnline = readable<boolean>(browser ? navigator.onLine : true, (se
|
||||
/** Number of pending ops in the offline queue (set by sync/queue integration). */
|
||||
export const pendingOpsCount = writable<number>(0);
|
||||
|
||||
/**
|
||||
* Fase 14.2.3 — number of `sync_conflicts` rows currently awaiting the
|
||||
* user's attention (i.e. on the server AND not dismissed locally). Set
|
||||
* by the `(app)/+layout.svelte` conflict probe; the banner reads it.
|
||||
*/
|
||||
export const unresolvedConflictsCount = writable<number>(0);
|
||||
|
||||
/** High-level status used by the UI banner. */
|
||||
export const syncStatus: Readable<SyncState> = derived(
|
||||
[isOnline, pendingOpsCount],
|
||||
|
||||
182
apps/web/src/lib/stores/tags.ts
Normal file
182
apps/web/src/lib/stores/tags.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Tag store — collective-scoped item tags (Fase 11).
|
||||
*
|
||||
* Loads + caches the tags for the active collective and subscribes to
|
||||
* `item_tags` postgres-changes so concurrent rename / create / delete from
|
||||
* another tab or device propagates without a reload.
|
||||
*
|
||||
* Per CLAUDE.md "Supabase JS v2 + SvelteKit deadlock" gotcha: this store is
|
||||
* created from `onMount`-style component lifecycles, never from inside
|
||||
* `onAuthStateChange`. The Realtime subscription itself does not call
|
||||
* `getAccessToken()`, so the lock-defer pattern is not needed here.
|
||||
*/
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { ItemTag, ItemTagColor } from '@colectivo/types';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
export const tags = writable<ItemTag[]>([]);
|
||||
export const tagsLoading = writable(false);
|
||||
|
||||
let activeChannel: RealtimeChannel | null = null;
|
||||
let activeCollectiveId: string | null = null;
|
||||
|
||||
export async function loadTags(collectiveId: string): Promise<void> {
|
||||
tagsLoading.set(true);
|
||||
try {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('item_tags')
|
||||
.select('*')
|
||||
.eq('collective_id', collectiveId)
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (!error && data) {
|
||||
tags.set(data as ItemTag[]);
|
||||
}
|
||||
} finally {
|
||||
tagsLoading.set(false);
|
||||
}
|
||||
|
||||
// Re-subscribe whenever the active collective changes. Cheap: one channel
|
||||
// per collective + tab.
|
||||
if (activeCollectiveId !== collectiveId) {
|
||||
await unsubscribe();
|
||||
activeCollectiveId = collectiveId;
|
||||
await subscribe(collectiveId);
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(collectiveId: string): Promise<void> {
|
||||
const channel = getSupabase().channel(`tags:${collectiveId}`);
|
||||
|
||||
channel.on(
|
||||
// @ts-expect-error — postgres_changes is a runtime string event
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'item_tags',
|
||||
filter: `collective_id=eq.${collectiveId}`
|
||||
},
|
||||
(payload: {
|
||||
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
new: ItemTag;
|
||||
old: ItemTag | { id: string };
|
||||
}) => {
|
||||
if (payload.eventType === 'INSERT') {
|
||||
tags.update((arr) =>
|
||||
arr.some((t) => t.id === payload.new.id) ? arr : sortByName([...arr, payload.new])
|
||||
);
|
||||
} else if (payload.eventType === 'UPDATE') {
|
||||
tags.update((arr) =>
|
||||
sortByName(arr.map((t) => (t.id === payload.new.id ? payload.new : t)))
|
||||
);
|
||||
} else {
|
||||
const id = (payload.old as { id: string }).id;
|
||||
tags.update((arr) => arr.filter((t) => t.id !== id));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('tags channel subscribe timeout')), 10_000);
|
||||
channel.subscribe((status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`tags channel subscribe failed: ${status}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
activeChannel = channel;
|
||||
}
|
||||
|
||||
async function unsubscribe(): Promise<void> {
|
||||
if (!activeChannel) return;
|
||||
const supabase = getSupabase();
|
||||
try {
|
||||
await activeChannel.unsubscribe();
|
||||
await supabase.removeChannel(activeChannel);
|
||||
} catch {
|
||||
// idempotent cleanup
|
||||
}
|
||||
activeChannel = null;
|
||||
}
|
||||
|
||||
export async function teardownTags(): Promise<void> {
|
||||
await unsubscribe();
|
||||
activeCollectiveId = null;
|
||||
tags.set([]);
|
||||
}
|
||||
|
||||
function sortByName(arr: ItemTag[]): ItemTag[] {
|
||||
return [...arr].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createTag(
|
||||
collectiveId: string,
|
||||
name: string,
|
||||
color: ItemTagColor = 'slate'
|
||||
): Promise<ItemTag | null> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const { data, error } = await getSupabase()
|
||||
.from('item_tags')
|
||||
.insert({ collective_id: collectiveId, name: trimmed, color })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) return null;
|
||||
const row = data as ItemTag;
|
||||
// Optimistic local insert — the Realtime echo is deduped by id.
|
||||
tags.update((arr) => (arr.some((t) => t.id === row.id) ? arr : sortByName([...arr, row])));
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function renameTag(id: string, name: string): Promise<void> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
tags.update((arr) => sortByName(arr.map((t) => (t.id === id ? { ...t, name: trimmed } : t))));
|
||||
await getSupabase().from('item_tags').update({ name: trimmed }).eq('id', id);
|
||||
}
|
||||
|
||||
export async function recolorTag(id: string, color: ItemTagColor): Promise<void> {
|
||||
tags.update((arr) => arr.map((t) => (t.id === id ? { ...t, color } : t)));
|
||||
await getSupabase().from('item_tags').update({ color }).eq('id', id);
|
||||
}
|
||||
|
||||
export async function deleteTag(id: string): Promise<void> {
|
||||
tags.update((arr) => arr.filter((t) => t.id !== id));
|
||||
await getSupabase().from('item_tags').delete().eq('id', id);
|
||||
}
|
||||
|
||||
// ── Attach / detach on a shopping_item ───────────────────────────────────────
|
||||
|
||||
export async function attachTag(itemId: string, tagId: string): Promise<boolean> {
|
||||
const { error } = await getSupabase()
|
||||
.from('shopping_item_tags')
|
||||
.insert({ item_id: itemId, tag_id: tagId });
|
||||
return !error;
|
||||
}
|
||||
|
||||
export async function detachTag(itemId: string, tagId: string): Promise<boolean> {
|
||||
const { error } = await getSupabase()
|
||||
.from('shopping_item_tags')
|
||||
.delete()
|
||||
.eq('item_id', itemId)
|
||||
.eq('tag_id', tagId);
|
||||
return !error;
|
||||
}
|
||||
|
||||
// ── Local-only helpers ───────────────────────────────────────────────────────
|
||||
|
||||
export function findTagByName(name: string): ItemTag | undefined {
|
||||
const lookup = name.trim().toLowerCase();
|
||||
return get(tags).find((t) => t.name.toLowerCase() === lookup);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { pendingOpsCount } from '$lib/stores/syncStatus';
|
||||
import { SyncQueue, attachOnlineFlush, type NewOp } from './queue';
|
||||
import { SyncQueue, attachOnlineFlush, type NewOp, type QueueContext } from './queue';
|
||||
|
||||
let singleton: SyncQueue | null = null;
|
||||
let detachOnline: (() => void) | null = null;
|
||||
@@ -42,6 +42,15 @@ export async function refreshPending(): Promise<void> {
|
||||
pendingOpsCount.set(ops.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the queue's "who am I / which collective" context. Called from
|
||||
* the top-level layout when auth + active collective resolve, so the
|
||||
* conflict logger (Fase 9.3) attributes rows correctly.
|
||||
*/
|
||||
export function setSyncContext(ctx: QueueContext): void {
|
||||
getQueue().setContext(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue-then-send convenience wrapper. Returns immediately on offline (op
|
||||
* stays queued) and propagates the online-success/failure otherwise.
|
||||
|
||||
@@ -184,6 +184,163 @@ describe('SyncQueue — pending_ops contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncQueue — last-write-wins conflict logging (Fase 9.3)', () => {
|
||||
it('SC-01: an UPDATE whose remote pre-image differs from the local pre-image logs a sync_conflicts row', async () => {
|
||||
// The client tracks two kinds of calls:
|
||||
// * shopping_items.update → succeeds (last-write-wins).
|
||||
// * shopping_items.select before that update returns a row whose
|
||||
// `name` already differs from what the queue believed.
|
||||
// * sync_conflicts.insert is observed here.
|
||||
const calls: Array<{ table: string; kind: string; payload?: unknown; patch?: unknown; match?: unknown }> = [];
|
||||
const client = {
|
||||
from: (table: string) => ({
|
||||
insert: (payload: unknown) => ({
|
||||
select: () => ({
|
||||
single: async () => {
|
||||
calls.push({ table, kind: 'insert', payload });
|
||||
return { error: null };
|
||||
}
|
||||
})
|
||||
}),
|
||||
update: (patch: unknown) => ({
|
||||
match: async (m: Record<string, unknown>) => {
|
||||
calls.push({ table, kind: 'update', patch, match: m });
|
||||
return { error: null };
|
||||
}
|
||||
}),
|
||||
delete: () => ({
|
||||
match: async (m: Record<string, unknown>) => {
|
||||
calls.push({ table, kind: 'delete', match: m });
|
||||
return { error: null };
|
||||
}
|
||||
}),
|
||||
select: () => ({
|
||||
match: () => ({
|
||||
maybeSingle: async () => {
|
||||
calls.push({ table, kind: 'select' });
|
||||
// Remote has a value that disagrees with our preImage.
|
||||
return { data: { name: 'remote-name' }, error: null };
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
const queue = new SyncQueue(client, { currentUserId: 'user-1', collectiveId: 'coll-1' });
|
||||
await queue.enqueue({
|
||||
kind: 'update',
|
||||
table: 'shopping_items',
|
||||
match: { id: 'item-1' },
|
||||
patch: { name: 'local-name' },
|
||||
preImage: { name: 'old-name' }
|
||||
});
|
||||
|
||||
// The UPDATE happened (remote-won under last-write-wins) AND a
|
||||
// sync_conflicts row was inserted with both payloads.
|
||||
const updateCall = calls.find((c) => c.kind === 'update' && c.table === 'shopping_items');
|
||||
expect(updateCall).toBeTruthy();
|
||||
|
||||
const conflict = calls.find((c) => c.kind === 'insert' && c.table === 'sync_conflicts');
|
||||
expect(conflict).toBeTruthy();
|
||||
const payload = conflict?.payload as {
|
||||
user_id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
local_version: Record<string, unknown>;
|
||||
remote_version: Record<string, unknown>;
|
||||
resolution: string;
|
||||
};
|
||||
expect(payload.user_id).toBe('user-1');
|
||||
expect(payload.entity_type).toBe('shopping_item');
|
||||
expect(payload.entity_id).toBe('item-1');
|
||||
expect(payload.local_version).toEqual({ name: 'local-name' });
|
||||
expect(payload.remote_version).toEqual({ name: 'remote-name' });
|
||||
expect(payload.resolution).toBe('remote_won');
|
||||
});
|
||||
|
||||
it('SC-02: no conflict is logged when the remote pre-image matches the local one', async () => {
|
||||
const calls: Array<{ table: string; kind: string }> = [];
|
||||
const client = {
|
||||
from: (table: string) => ({
|
||||
insert: (_payload: unknown) => ({
|
||||
select: () => ({ single: async () => ({ error: null }) })
|
||||
}),
|
||||
update: (_patch: unknown) => ({
|
||||
match: async (_m: Record<string, unknown>) => {
|
||||
calls.push({ table, kind: 'update' });
|
||||
return { error: null };
|
||||
}
|
||||
}),
|
||||
delete: () => ({ match: async () => ({ error: null }) }),
|
||||
select: () => ({
|
||||
match: () => ({
|
||||
maybeSingle: async () => {
|
||||
calls.push({ table, kind: 'select' });
|
||||
// Remote agrees with our preImage — no conflict.
|
||||
return { data: { name: 'old-name' }, error: null };
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
const queue = new SyncQueue(client, { currentUserId: 'user-1' });
|
||||
await queue.enqueue({
|
||||
kind: 'update',
|
||||
table: 'shopping_items',
|
||||
match: { id: 'item-1' },
|
||||
patch: { name: 'new-name' },
|
||||
preImage: { name: 'old-name' }
|
||||
});
|
||||
|
||||
expect(calls.find((c) => c.table === 'sync_conflicts')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SC-03: a failed sync_conflicts INSERT does NOT block the UPDATE', async () => {
|
||||
// The UPDATE must still come through even if the conflict log
|
||||
// insertion errors (best-effort logging).
|
||||
let updateLanded = false;
|
||||
const client = {
|
||||
from: (table: string) => ({
|
||||
insert: (_payload: unknown) => ({
|
||||
select: () => ({
|
||||
single: async () => {
|
||||
// sync_conflicts insert blows up
|
||||
if (table === 'sync_conflicts') return { error: { message: 'rls' } };
|
||||
return { error: null };
|
||||
}
|
||||
})
|
||||
}),
|
||||
update: (_patch: unknown) => ({
|
||||
match: async (_m: Record<string, unknown>) => {
|
||||
updateLanded = true;
|
||||
return { error: null };
|
||||
}
|
||||
}),
|
||||
delete: () => ({ match: async () => ({ error: null }) }),
|
||||
select: () => ({
|
||||
match: () => ({
|
||||
maybeSingle: async () => ({ data: { name: 'remote-name' }, error: null })
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
const queue = new SyncQueue(client, { currentUserId: 'user-1' });
|
||||
await queue.enqueue({
|
||||
kind: 'update',
|
||||
table: 'shopping_items',
|
||||
match: { id: 'item-1' },
|
||||
patch: { name: 'local' },
|
||||
preImage: { name: 'old' }
|
||||
});
|
||||
|
||||
expect(updateLanded).toBe(true);
|
||||
// Op was removed from the queue (treated as success).
|
||||
expect(await queue.pending()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachOnlineFlush — window online event triggers flush', () => {
|
||||
it('F-01: dispatching "online" calls flush() within a tick', async () => {
|
||||
const { client } = makeMockClient([{ error: null }]);
|
||||
|
||||
@@ -36,6 +36,13 @@ export type UpdateOp = BaseOp & {
|
||||
table: 'shopping_items' | 'shopping_lists';
|
||||
match: Record<string, unknown>;
|
||||
patch: Record<string, unknown>;
|
||||
/**
|
||||
* Fase 9.3 — the row state the client believed when it queued the
|
||||
* UPDATE, restricted to the fields being patched. Used to detect
|
||||
* last-write-wins conflicts at send time. Optional so legacy ops
|
||||
* (and tests not exercising the conflict path) keep working.
|
||||
*/
|
||||
preImage?: Record<string, unknown>;
|
||||
};
|
||||
export type DeleteOp = BaseOp & {
|
||||
kind: 'delete';
|
||||
@@ -49,6 +56,16 @@ export type NewOp =
|
||||
| Omit<UpdateOp, keyof BaseOp>
|
||||
| Omit<DeleteOp, keyof BaseOp>;
|
||||
|
||||
/**
|
||||
* Maps an op's `table` to the entity_type value we persist in
|
||||
* sync_conflicts. Keeping a small lookup avoids hard-coding the rule
|
||||
* "strip the trailing s" which would break for irregular plurals.
|
||||
*/
|
||||
const ENTITY_TYPE_FOR_TABLE: Record<UpdateOp['table'], string> = {
|
||||
shopping_items: 'shopping_item',
|
||||
shopping_lists: 'shopping_list'
|
||||
};
|
||||
|
||||
export const MAX_ATTEMPTS = 5;
|
||||
const DB_NAME = 'colectivo-sync';
|
||||
const DB_VERSION = 1;
|
||||
@@ -59,8 +76,30 @@ type SupabaseQueryClient = {
|
||||
insert: (row: unknown) => { select: () => { single: () => Promise<unknown> } };
|
||||
update: (patch: unknown) => { match: (m: Record<string, unknown>) => Promise<unknown> };
|
||||
delete: () => { match: (m: Record<string, unknown>) => Promise<unknown> };
|
||||
/**
|
||||
* Fase 9.3 — used to read the remote pre-image before sending an
|
||||
* UPDATE so we can detect a last-write-wins conflict. Optional
|
||||
* because legacy callers that never enqueue a `preImage` op also
|
||||
* never reach this branch.
|
||||
*/
|
||||
select?: (cols?: string) => {
|
||||
match: (m: Record<string, unknown>) => {
|
||||
maybeSingle: () => Promise<{ data: Record<string, unknown> | null; error: { message: string } | null }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional context the queue uses for last-write-wins conflict logging.
|
||||
* Without `currentUserId` the queue skips conflict detection entirely —
|
||||
* RLS on sync_conflicts requires `user_id = auth.uid()` so anonymous
|
||||
* writes would fail anyway.
|
||||
*/
|
||||
export type QueueContext = {
|
||||
currentUserId?: string | null;
|
||||
collectiveId?: string | null;
|
||||
};
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase> {
|
||||
return openDB(DB_NAME, DB_VERSION, {
|
||||
@@ -78,7 +117,17 @@ async function getDb(): Promise<IDBPDatabase> {
|
||||
* interface so tests can inject a mock.
|
||||
*/
|
||||
export class SyncQueue {
|
||||
constructor(private readonly client: SupabaseQueryClient) {}
|
||||
private readonly context: QueueContext;
|
||||
|
||||
constructor(private readonly client: SupabaseQueryClient, context: QueueContext = {}) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/** Replace the queue context (call when the user logs in / out). */
|
||||
setContext(context: QueueContext): void {
|
||||
this.context.currentUserId = context.currentUserId ?? null;
|
||||
this.context.collectiveId = context.collectiveId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the op to pending_ops, then attempt to send it to Supabase.
|
||||
@@ -163,10 +212,41 @@ export class SyncQueue {
|
||||
};
|
||||
if (r.error && !isIdempotentSuccess(r.error)) throw new Error(r.error.message);
|
||||
} else if (op.kind === 'update') {
|
||||
// Fase 9.3: best-effort last-write-wins conflict detection.
|
||||
// Read the remote row first (only fields we're patching) and
|
||||
// compare to the pre-image the caller captured when the op was
|
||||
// queued. Any mismatch on a patched field = remote moved
|
||||
// under us; log a sync_conflicts row and proceed with the
|
||||
// UPDATE (last-write-wins). A failure to fetch / log never
|
||||
// blocks the UPDATE — the conflict log is advisory.
|
||||
let remoteSnapshot: Record<string, unknown> | null = null;
|
||||
if (op.preImage && this.client.from(op.table).select && this.context.currentUserId) {
|
||||
try {
|
||||
const cols = Object.keys(op.preImage).join(', ');
|
||||
const sel = this.client.from(op.table).select?.(cols);
|
||||
if (sel) {
|
||||
const r = await sel.match(op.match).maybeSingle();
|
||||
if (!r.error) remoteSnapshot = r.data;
|
||||
}
|
||||
} catch {
|
||||
/* swallow — best effort */
|
||||
}
|
||||
}
|
||||
|
||||
const r = (await table.update(op.patch).match(op.match)) as {
|
||||
error: { message: string } | null;
|
||||
};
|
||||
if (r.error) throw new Error(r.error.message);
|
||||
|
||||
// After a successful UPDATE, fire-and-forget the conflict log.
|
||||
if (remoteSnapshot && op.preImage) {
|
||||
const diverged = Object.keys(op.preImage).some(
|
||||
(k) => !deepEqual(remoteSnapshot![k], op.preImage![k])
|
||||
);
|
||||
if (diverged) {
|
||||
await this.logConflict(op, remoteSnapshot);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const r = (await table.delete().match(op.match)) as {
|
||||
error: { message: string } | null;
|
||||
@@ -174,6 +254,54 @@ export class SyncQueue {
|
||||
if (r.error) throw new Error(r.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a sync_conflicts row for the given UPDATE op. Errors are
|
||||
* swallowed — the table is append-only/best-effort and a logging
|
||||
* failure must never break the user's sync.
|
||||
*/
|
||||
private async logConflict(op: UpdateOp, remoteSnapshot: Record<string, unknown>): Promise<void> {
|
||||
const userId = this.context.currentUserId;
|
||||
if (!userId) return;
|
||||
const entityType = ENTITY_TYPE_FOR_TABLE[op.table];
|
||||
const entityId = (op.match.id ?? op.match['id']) as string | undefined;
|
||||
if (!entityType || !entityId) return;
|
||||
try {
|
||||
await this.client
|
||||
.from('sync_conflicts')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
collective_id: this.context.collectiveId ?? null,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
local_version: op.patch,
|
||||
remote_version: remoteSnapshot,
|
||||
resolution: 'remote_won'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Local deep-equality good enough for primitive / JSON payloads. */
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (typeof a !== 'object') return false;
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
const ak = Object.keys(a as object);
|
||||
const bk = Object.keys(b as object);
|
||||
if (ak.length !== bk.length) return false;
|
||||
for (const k of ak) {
|
||||
if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
128
apps/web/src/lib/theme.test.ts
Normal file
128
apps/web/src/lib/theme.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Fase 9.1 — theme controller unit tests.
|
||||
*
|
||||
* `theme.ts` owns the source of truth for the user's UI theme. It exposes
|
||||
* a Svelte store + helpers that:
|
||||
* - persist the chosen preference (`light` | `dark` | `system`) to
|
||||
* localStorage so the anti-FOUC inline script in app.html reads it on
|
||||
* next load;
|
||||
* - reflect the *resolved* theme onto <html data-theme="..."> so Tailwind's
|
||||
* `dark:` selectors and the :root[data-theme=...] CSS tokens both fire;
|
||||
* - re-resolve when the user picks `system` and the OS preference flips.
|
||||
*
|
||||
* Test environment is jsdom (vitest config). We stub `matchMedia` per test.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
themePreference,
|
||||
resolvedTheme,
|
||||
setThemePreference,
|
||||
initTheme,
|
||||
type ThemePreference
|
||||
} from './theme';
|
||||
|
||||
function mockMatchMedia(prefersDark: boolean) {
|
||||
const listeners = new Set<(e: MediaQueryListEvent) => void>();
|
||||
const mql = {
|
||||
matches: prefersDark,
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn),
|
||||
// Legacy APIs Svelte does not use but jsdom may probe
|
||||
addListener: (fn: (e: MediaQueryListEvent) => void) => listeners.add(fn),
|
||||
removeListener: (fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn),
|
||||
dispatchEvent: (e: MediaQueryListEvent) => {
|
||||
listeners.forEach((l) => l(e));
|
||||
return true;
|
||||
},
|
||||
onchange: null
|
||||
};
|
||||
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(mql));
|
||||
(window as unknown as { matchMedia: typeof window.matchMedia }).matchMedia =
|
||||
globalThis.matchMedia;
|
||||
return mql;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
// Default the matchMedia mock to "system prefers light" unless a test
|
||||
// overrides it. Without this, jsdom would throw on the first read.
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('themePreference store + setThemePreference', () => {
|
||||
it('TH-01: defaults to "system" when nothing is stored', () => {
|
||||
initTheme();
|
||||
expect(get(themePreference)).toBe('system');
|
||||
});
|
||||
|
||||
it('TH-02: setting "light" writes localStorage and data-theme=light', () => {
|
||||
initTheme();
|
||||
setThemePreference('light');
|
||||
expect(localStorage.getItem('theme')).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
expect(get(themePreference)).toBe('light');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
});
|
||||
|
||||
it('TH-03: setting "dark" writes localStorage and data-theme=dark', () => {
|
||||
initTheme();
|
||||
setThemePreference('dark');
|
||||
expect(localStorage.getItem('theme')).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
expect(get(themePreference)).toBe('dark');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('TH-04: setting "system" resolves to dark when prefers-color-scheme: dark', () => {
|
||||
mockMatchMedia(true); // OS says dark
|
||||
initTheme();
|
||||
setThemePreference('system');
|
||||
expect(localStorage.getItem('theme')).toBe('system');
|
||||
expect(get(themePreference)).toBe('system');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('TH-05: setting "system" resolves to light when prefers-color-scheme: light', () => {
|
||||
mockMatchMedia(false); // OS says light
|
||||
initTheme();
|
||||
setThemePreference('system');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
|
||||
it('TH-06: initTheme picks up an existing localStorage value on mount', () => {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
initTheme();
|
||||
expect(get(themePreference)).toBe('dark');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('TH-07: rejects unknown preference values (falls back to "system")', () => {
|
||||
initTheme();
|
||||
// @ts-expect-error testing the runtime guard
|
||||
setThemePreference('sepia' as ThemePreference);
|
||||
expect(get(themePreference)).toBe('system');
|
||||
});
|
||||
|
||||
it('TH-08: "system" updates resolved theme when matchMedia fires "change"', () => {
|
||||
const mql = mockMatchMedia(false);
|
||||
initTheme();
|
||||
setThemePreference('system');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
|
||||
// OS switches to dark.
|
||||
mql.matches = true;
|
||||
mql.dispatchEvent(new Event('change') as MediaQueryListEvent);
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
119
apps/web/src/lib/theme.ts
Normal file
119
apps/web/src/lib/theme.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Fase 9.1 — theme controller.
|
||||
*
|
||||
* Source of truth for the user's UI theme. Three preference values:
|
||||
* - 'light' → force light mode
|
||||
* - 'dark' → force dark mode
|
||||
* - 'system' → follow OS prefers-color-scheme (default for new users)
|
||||
*
|
||||
* Two stores:
|
||||
* - `themePreference` — the raw user choice (one of the three above).
|
||||
* - `resolvedTheme` — what's actually painted: 'light' | 'dark'.
|
||||
*
|
||||
* Side effects (all browser-side, guarded for SSR):
|
||||
* - writes `localStorage.theme` so app.html's inline anti-FOUC script
|
||||
* can read it synchronously on the next load;
|
||||
* - mirrors `resolvedTheme` onto <html data-theme="..."> so Tailwind's
|
||||
* `dark:` utilities + the :root[data-theme=...] CSS tokens resolve.
|
||||
*
|
||||
* Persistence to public.users.theme is the caller's responsibility — see
|
||||
* `ThemeToggle.svelte` which fires the UPDATE in the background. We keep
|
||||
* the database write out of this module so unit tests don't need a
|
||||
* Supabase client.
|
||||
*/
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
|
||||
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'theme';
|
||||
const VALID: readonly ThemePreference[] = ['light', 'dark', 'system'] as const;
|
||||
|
||||
function isValid(value: string | null | undefined): value is ThemePreference {
|
||||
return value === 'light' || value === 'dark' || value === 'system';
|
||||
}
|
||||
|
||||
export const themePreference = writable<ThemePreference>('system');
|
||||
export const resolvedTheme = writable<ResolvedTheme>('light');
|
||||
|
||||
let mql: MediaQueryList | null = null;
|
||||
let mqlListener: ((e: MediaQueryListEvent) => void) | null = null;
|
||||
let initialized = false;
|
||||
|
||||
function detachMqlListener() {
|
||||
if (mql && mqlListener) {
|
||||
mql.removeEventListener('change', mqlListener);
|
||||
}
|
||||
mql = null;
|
||||
mqlListener = null;
|
||||
}
|
||||
|
||||
function attachMqlListener() {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
detachMqlListener();
|
||||
mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mqlListener = () => {
|
||||
// Only react if the user is currently on "system".
|
||||
if (get(themePreference) === 'system') {
|
||||
applyResolved();
|
||||
}
|
||||
};
|
||||
mql.addEventListener('change', mqlListener);
|
||||
}
|
||||
|
||||
function osPrefersDark(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolve(pref: ThemePreference): ResolvedTheme {
|
||||
if (pref === 'system') return osPrefersDark() ? 'dark' : 'light';
|
||||
return pref;
|
||||
}
|
||||
|
||||
function applyResolved() {
|
||||
const r = resolve(get(themePreference));
|
||||
resolvedTheme.set(r);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', r);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the stores from localStorage and start listening for OS
|
||||
* preference changes. Safe to call more than once — re-runs only refresh
|
||||
* the listener subscription, never duplicate it.
|
||||
*/
|
||||
export function initTheme(): void {
|
||||
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
|
||||
const pref: ThemePreference = isValid(stored) ? stored : 'system';
|
||||
themePreference.set(pref);
|
||||
applyResolved();
|
||||
attachMqlListener();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's preference. Persists to localStorage, refreshes the
|
||||
* resolved theme, and updates <html data-theme>. Invalid input falls back
|
||||
* to 'system' so we never store junk.
|
||||
*/
|
||||
export function setThemePreference(value: ThemePreference): void {
|
||||
if (!initialized) initTheme();
|
||||
const next: ThemePreference = VALID.includes(value) ? value : 'system';
|
||||
themePreference.set(next);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
}
|
||||
applyResolved();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived store useful for components that only care about the live
|
||||
* resolved value — re-exported so callers can `$isDark`-style.
|
||||
*/
|
||||
export const isDark = derived(resolvedTheme, (r) => r === 'dark');
|
||||
78
apps/web/src/lib/utils/accept-language.test.ts
Normal file
78
apps/web/src/lib/utils/accept-language.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectLanguage } from './accept-language';
|
||||
|
||||
describe('detectLanguage (Accept-Language style strings)', () => {
|
||||
it('AL-01: bare "es" maps to es', () => {
|
||||
expect(detectLanguage('es')).toBe('es');
|
||||
});
|
||||
|
||||
it('AL-02: bare "en" maps to en', () => {
|
||||
expect(detectLanguage('en')).toBe('en');
|
||||
});
|
||||
|
||||
it('AL-03: regional variant "es-ES" maps to es', () => {
|
||||
expect(detectLanguage('es-ES')).toBe('es');
|
||||
});
|
||||
|
||||
it('AL-04: es;q=0.9,en;q=0.8 — es wins (higher q)', () => {
|
||||
expect(detectLanguage('es;q=0.9,en;q=0.8')).toBe('es');
|
||||
});
|
||||
|
||||
it('AL-05: en-US,es;q=0.3 — en wins (no q means 1.0)', () => {
|
||||
expect(detectLanguage('en-US,es;q=0.3')).toBe('en');
|
||||
});
|
||||
|
||||
it('AL-06: empty string falls back to en', () => {
|
||||
expect(detectLanguage('')).toBe('en');
|
||||
});
|
||||
|
||||
it('AL-07: null falls back to en', () => {
|
||||
expect(detectLanguage(null)).toBe('en');
|
||||
});
|
||||
|
||||
it('AL-08: undefined falls back to en', () => {
|
||||
expect(detectLanguage(undefined)).toBe('en');
|
||||
});
|
||||
|
||||
it('AL-09: malformed string falls back to en', () => {
|
||||
expect(detectLanguage('!!!@#$;q=??')).toBe('en');
|
||||
});
|
||||
|
||||
it('AL-10: es with q=0.5 — borderline accepted', () => {
|
||||
// Per plan §10.8.1: q-score >= 0.5 still counts as Spanish preference.
|
||||
expect(detectLanguage('en;q=0.4,es;q=0.5')).toBe('es');
|
||||
});
|
||||
|
||||
it('AL-11: array of language tags (navigator.languages) — first es wins', () => {
|
||||
expect(detectLanguage(['en-GB', 'es-ES', 'fr'])).toBe('en');
|
||||
expect(detectLanguage(['es-ES', 'en-GB'])).toBe('es');
|
||||
});
|
||||
|
||||
it('AL-12: unknown language (fr) falls back to en', () => {
|
||||
expect(detectLanguage('fr-FR')).toBe('en');
|
||||
});
|
||||
|
||||
// ─── Fase 20.4.1 — Euskera (eu) support ───────────────────────────────
|
||||
// Mirrors the existing es-track tests. Coverage matches the plan's
|
||||
// LANG-U-EU-01..03 identifiers.
|
||||
|
||||
it('LANG-U-EU-01: bare "eu" maps to eu', () => {
|
||||
expect(detectLanguage('eu')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-02: regional variant "eu-ES" maps to eu', () => {
|
||||
expect(detectLanguage('eu-ES')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-03: "eu-FR;q=0.5,en" — eu wins by primary preference', () => {
|
||||
// q=0.5 is the lower bound we accept (mirrors AL-10 for es). The
|
||||
// borderline value still beats `en` because the parser ranks by
|
||||
// q-score and stops at the first known primary.
|
||||
expect(detectLanguage('eu-FR;q=0.5,en;q=0.4')).toBe('eu');
|
||||
});
|
||||
|
||||
it('LANG-U-EU-04: array of language tags — first eu wins over later en', () => {
|
||||
// Mirrors AL-11. Validates the navigator.languages path for eu.
|
||||
expect(detectLanguage(['eu-ES', 'en-GB'])).toBe('eu');
|
||||
});
|
||||
});
|
||||
98
apps/web/src/lib/utils/accept-language.ts
Normal file
98
apps/web/src/lib/utils/accept-language.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Detect the user's preferred app language from an Accept-Language header
|
||||
* (or navigator.languages array) — Fase 10.8 + Fase 20.3.2 (eu).
|
||||
*
|
||||
* Supported app languages (in sync with messages/{en,es,eu}.json +
|
||||
* Paraglide languageTags): `en`, `es`, `eu`. Anything else falls back
|
||||
* to `en`.
|
||||
*
|
||||
* Rules (per plan §10.8.1, extended for Fase 20):
|
||||
* * Inputs we accept:
|
||||
* - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'`, `'eu'`, `'eu-ES'`
|
||||
* - RFC 7231 list: `'es;q=0.9,en;q=0.8'`, `'eu-FR;q=0.5,en;q=0.4'`
|
||||
* - `navigator.languages` array: `['es-ES', 'en-GB']`, `['eu-ES', 'en']`
|
||||
* - null / undefined / empty → `'en'`
|
||||
* * The first entry (sorted by q-score descending) whose primary subtag
|
||||
* is an app-known language wins, provided q ≥ 0.5. `en` short-circuits
|
||||
* immediately regardless of q-score because it's the source language.
|
||||
* * Otherwise (including unknown languages, malformed input, or no
|
||||
* clear preference) → `'en'`.
|
||||
*/
|
||||
export type AppLanguage = 'en' | 'es' | 'eu';
|
||||
|
||||
interface ParsedTag {
|
||||
tag: string;
|
||||
primary: string;
|
||||
q: number;
|
||||
}
|
||||
|
||||
function parseTags(raw: string): ParsedTag[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const segments = part.trim().split(';').map((s) => s.trim());
|
||||
if (!segments[0]) return null;
|
||||
const tag = segments[0].toLowerCase();
|
||||
let q = 1.0;
|
||||
for (const seg of segments.slice(1)) {
|
||||
const match = seg.match(/^q\s*=\s*([0-9.]+)$/);
|
||||
if (match) {
|
||||
const parsed = parseFloat(match[1]);
|
||||
if (!Number.isNaN(parsed)) q = parsed;
|
||||
}
|
||||
}
|
||||
const primary = tag.split('-')[0];
|
||||
// Filter junk: primary must be alpha-only and 1+ chars.
|
||||
if (!/^[a-z]+$/.test(primary)) return null;
|
||||
return { tag, primary, q };
|
||||
})
|
||||
.filter((p): p is ParsedTag => p !== null);
|
||||
}
|
||||
|
||||
export function detectLanguage(
|
||||
input: string | string[] | null | undefined
|
||||
): AppLanguage {
|
||||
if (input == null) return 'en';
|
||||
|
||||
let parsed: ParsedTag[];
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
// navigator.languages: ordered by user preference, no q-scores.
|
||||
parsed = input
|
||||
.map((tag, index) => {
|
||||
const t = tag.toLowerCase().trim();
|
||||
if (!t) return null;
|
||||
const primary = t.split('-')[0];
|
||||
if (!/^[a-z]+$/.test(primary)) return null;
|
||||
// Synthesize a decreasing q-score so first-listed wins.
|
||||
return { tag: t, primary, q: 1 - index * 0.01 };
|
||||
})
|
||||
.filter((p): p is ParsedTag => p !== null);
|
||||
} else {
|
||||
const s = input.trim();
|
||||
if (!s) return 'en';
|
||||
parsed = parseTags(s);
|
||||
}
|
||||
|
||||
if (parsed.length === 0) return 'en';
|
||||
|
||||
// Sort by q-score descending.
|
||||
parsed.sort((a, b) => b.q - a.q);
|
||||
|
||||
// First app-known primary wins; non-`en` primaries require q ≥ 0.5 to
|
||||
// beat the implicit `en` fallback. `en` short-circuits because it's the
|
||||
// source language and can never be more wrong than the user's first
|
||||
// choice if they listed it at all. Fase 20 adds `eu` on the same gate
|
||||
// as `es`.
|
||||
for (const p of parsed) {
|
||||
if (p.primary === 'es') {
|
||||
return p.q >= 0.5 ? 'es' : 'en';
|
||||
}
|
||||
if (p.primary === 'eu') {
|
||||
return p.q >= 0.5 ? 'eu' : 'en';
|
||||
}
|
||||
if (p.primary === 'en') return 'en';
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
121
apps/web/src/lib/utils/list-title.test.ts
Normal file
121
apps/web/src/lib/utils/list-title.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Pure-function tests for the list-title numbering helpers (Fase 18.2).
|
||||
*
|
||||
* The runtime path runs in the browser (modal submit handler), but the
|
||||
* functions are I/O-free so jsdom is overkill — Vitest's node env is fine.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseTitle, nextNumberFromNumbers, computeNextNumberFromNames } from './list-title';
|
||||
|
||||
describe('list-title utils', () => {
|
||||
describe('parseTitle', () => {
|
||||
it('LT-U-01: "Compra #5" → { prefix: "Compra", number: 5 }', () => {
|
||||
expect(parseTitle('Compra #5')).toEqual({ prefix: 'Compra', number: 5 });
|
||||
});
|
||||
|
||||
it('LT-U-02: "Compra" → { prefix: "Compra", number: null }', () => {
|
||||
expect(parseTitle('Compra')).toEqual({ prefix: 'Compra', number: null });
|
||||
});
|
||||
|
||||
it('LT-U-03: "#5" (no prefix before the hash) → returns null prefix', () => {
|
||||
// The chip is meaningless without a base prefix, so the parser refuses
|
||||
// to autonumber bare-hash inputs. The caller treats this as "user
|
||||
// typed something literal — submit as-is".
|
||||
expect(parseTitle('#5')).toEqual({ prefix: null, number: null });
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before parsing', () => {
|
||||
expect(parseTitle(' Compra #3 ')).toEqual({ prefix: 'Compra', number: 3 });
|
||||
});
|
||||
|
||||
it('handles multi-word prefixes', () => {
|
||||
expect(parseTitle('Compra semanal #2')).toEqual({
|
||||
prefix: 'Compra semanal',
|
||||
number: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('treats "#" without digits as no-number suffix', () => {
|
||||
// "Compra #" with no trailing digits is just a weird name — the user
|
||||
// gets exactly that string back, no auto-number magic.
|
||||
expect(parseTitle('Compra #')).toEqual({ prefix: 'Compra #', number: null });
|
||||
});
|
||||
|
||||
it('returns prefix null on empty input', () => {
|
||||
expect(parseTitle('')).toEqual({ prefix: null, number: null });
|
||||
expect(parseTitle(' ')).toEqual({ prefix: null, number: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextNumberFromNumbers', () => {
|
||||
it('LT-U-04: [1, 2, 5] → 6', () => {
|
||||
expect(nextNumberFromNumbers([1, 2, 5])).toBe(6);
|
||||
});
|
||||
|
||||
it('LT-U-05: [] → 1', () => {
|
||||
expect(nextNumberFromNumbers([])).toBe(1);
|
||||
});
|
||||
|
||||
it('out-of-order list still returns max+1', () => {
|
||||
expect(nextNumberFromNumbers([7, 2, 4])).toBe(8);
|
||||
});
|
||||
|
||||
it('single element returns that+1', () => {
|
||||
expect(nextNumberFromNumbers([3])).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextNumberFromNames', () => {
|
||||
const PREFIX = 'Compra';
|
||||
|
||||
it('matches the documented LT-U behaviour: bare prefix counts as N=0', () => {
|
||||
// "Compra" (bare) → N=0, "Compra #1", "Compra #2", "Compra #5" → max=5
|
||||
// → next = 6.
|
||||
expect(
|
||||
computeNextNumberFromNames(PREFIX, [
|
||||
'Compra',
|
||||
'Compra #1',
|
||||
'Compra #2',
|
||||
'Compra #5'
|
||||
])
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('returns 1 when no row matches (per CLAUDE-md spec rule 8)', () => {
|
||||
expect(computeNextNumberFromNames(PREFIX, [])).toBe(1);
|
||||
expect(computeNextNumberFromNames(PREFIX, ['Unrelated list'])).toBe(1);
|
||||
});
|
||||
|
||||
it('bare prefix only (no numbered siblings) → next is 1', () => {
|
||||
// "Compra" alone counts as N=0 → max + 1 = 1.
|
||||
expect(computeNextNumberFromNames(PREFIX, ['Compra'])).toBe(1);
|
||||
});
|
||||
|
||||
it('numbered siblings only (no bare) → max + 1', () => {
|
||||
expect(computeNextNumberFromNames(PREFIX, ['Compra #3', 'Compra #4'])).toBe(5);
|
||||
});
|
||||
|
||||
it('prefix match is case-insensitive', () => {
|
||||
expect(
|
||||
computeNextNumberFromNames('compra', ['Compra #1', 'COMPRA #2', 'compra #4'])
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it('exact prefix-only match (case-insensitive) counts as N=0', () => {
|
||||
expect(computeNextNumberFromNames('compra', ['Compra'])).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores rows where the prefix is a substring but not a real match', () => {
|
||||
// "Compras del mes" starts with "Compra" but the next char isn't a
|
||||
// space or "#", so it's a different prefix entirely. Our regex
|
||||
// requires the post-prefix portion to be empty or " #<digits>".
|
||||
expect(
|
||||
computeNextNumberFromNames(PREFIX, ['Compras del mes', 'Compra #1'])
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('trims candidate names before comparison', () => {
|
||||
expect(computeNextNumberFromNames(PREFIX, [' Compra #2 '])).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
apps/web/src/lib/utils/list-title.ts
Normal file
100
apps/web/src/lib/utils/list-title.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Pure helpers for the Fase 18 list-title numbering flow.
|
||||
*
|
||||
* Split out of the store so unit tests can run without supabase-js or a
|
||||
* DOM. The two consumers are:
|
||||
*
|
||||
* • the create-list modal (decides whether to show the "Sugerencia:
|
||||
* Compra #6" chip and whether to auto-suffix on submit), and
|
||||
* • the listTitles store (which queries `shopping_lists.name` and pipes
|
||||
* the result through `computeNextNumberFromNames`).
|
||||
*
|
||||
* Semantics (per `plan/fase-18-shopping-list-flow.md` rule 8):
|
||||
* - Match a candidate `name` against a base `prefix` case-insensitively.
|
||||
* - A bare prefix match (e.g. "Compra") counts as N=0.
|
||||
* - A "<prefix> #<digits>" match contributes that digit value.
|
||||
* - The next number is `max(matched values) + 1`.
|
||||
* - If nothing matches, return 1 (UI uses this as "no prior context →
|
||||
* first numbered list").
|
||||
*
|
||||
* The DB never sees the #N — `shopping_lists.name` stores the final string
|
||||
* the user committed to. See migration 027 for why we don't add a unique
|
||||
* constraint on (collective_id, name).
|
||||
*/
|
||||
|
||||
export interface ParsedTitle {
|
||||
/** The text before the trailing " #<digits>", trimmed. Null on empty input. */
|
||||
prefix: string | null;
|
||||
/** The integer after "#", or null if no trailing "#<digits>" was present. */
|
||||
number: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a user-typed title into its (prefix, number) pair.
|
||||
*
|
||||
* - "Compra #5" → { prefix: "Compra", number: 5 }
|
||||
* - "Compra" → { prefix: "Compra", number: null }
|
||||
* - "#5" → { prefix: null, number: null } (no chip — no prefix
|
||||
* to auto-number on)
|
||||
* - "" → { prefix: null, number: null }
|
||||
*/
|
||||
export function parseTitle(input: string): ParsedTitle {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.length === 0) return { prefix: null, number: null };
|
||||
|
||||
// Match the trailing " #<digits>" suffix non-greedily. Require at least
|
||||
// one whitespace between prefix and "#" so "Compra#5" or "#5" alone do
|
||||
// not parse as numbered (the chip is for catalog-curated prefixes).
|
||||
const m = trimmed.match(/^(.+?)\s+#(\d+)$/);
|
||||
if (m) {
|
||||
return { prefix: m[1].trim(), number: Number.parseInt(m[2], 10) };
|
||||
}
|
||||
|
||||
// Bare "#5" (no leading prefix) → null prefix; the UI won't auto-number.
|
||||
if (/^#\d+$/.test(trimmed)) {
|
||||
return { prefix: null, number: null };
|
||||
}
|
||||
|
||||
return { prefix: trimmed, number: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next sequential number given an unsorted array of in-use
|
||||
* numbers. Empty array → 1 (the first numbered entry).
|
||||
*/
|
||||
export function nextNumberFromNumbers(numbers: number[]): number {
|
||||
if (numbers.length === 0) return 1;
|
||||
return Math.max(...numbers) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `names`, extract the N for every row matching `prefix`
|
||||
* (case-insensitive), and return `max(N) + 1`. A bare-prefix match counts
|
||||
* as N=0. Returns 1 if no rows match — matches CLAUDE-md rule 8 / plan
|
||||
* semantics 8.
|
||||
*
|
||||
* The DB query feeding this helper is `name ILIKE prefix OR name ILIKE
|
||||
* prefix || ' #%'`. We re-validate each row here so a sloppy ILIKE that
|
||||
* matches "Compras del mes" doesn't corrupt the count.
|
||||
*/
|
||||
export function computeNextNumberFromNames(prefix: string, names: string[]): number {
|
||||
const norm = prefix.trim().toLowerCase();
|
||||
if (norm.length === 0) return 1;
|
||||
|
||||
const numbers: number[] = [];
|
||||
let sawBare = false;
|
||||
|
||||
for (const raw of names) {
|
||||
const parsed = parseTitle(raw);
|
||||
if (!parsed.prefix) continue;
|
||||
if (parsed.prefix.toLowerCase() !== norm) continue;
|
||||
if (parsed.number === null) {
|
||||
sawBare = true;
|
||||
} else {
|
||||
numbers.push(parsed.number);
|
||||
}
|
||||
}
|
||||
|
||||
if (numbers.length === 0 && !sawBare) return 1;
|
||||
return nextNumberFromNumbers(sawBare ? [0, ...numbers] : numbers);
|
||||
}
|
||||
46
apps/web/src/lib/version.test.ts
Normal file
46
apps/web/src/lib/version.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Fase 14.3.6 — guard the version.ts barrel against accidental
|
||||
* tree-shaking / undefined replacement.
|
||||
*
|
||||
* Vitest doesn't run through Vite's `define`, so the `__APP_*`
|
||||
* globals don't exist in this environment. We stub them via
|
||||
* `vi.stubGlobal` to mimic what `vite build` (and `vite dev`)
|
||||
* inject at build/dev time.
|
||||
*
|
||||
* The point of the test is two-fold:
|
||||
* 1. The module re-exports the three globals as an object so
|
||||
* consumers never reach for the `__APP_*` symbols inline.
|
||||
* 2. None of the three exports collapse to `undefined` or the
|
||||
* sentinel `'dev'` when the build-time substitution is
|
||||
* working — that's the canary for an accidental tree-shake
|
||||
* that would silently break the version chip in production.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('version barrel', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('__APP_VERSION__', '1.2.3');
|
||||
vi.stubGlobal('__APP_COMMIT__', 'abc1234');
|
||||
vi.stubGlobal('__BUILD_TIME__', '2026-05-18T10:00:00.000Z');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('V-01: exports version / commit / builtAt from the build-time define', async () => {
|
||||
const mod = await import('./version');
|
||||
expect(mod.version).toBe('1.2.3');
|
||||
expect(mod.commit).toBe('abc1234');
|
||||
expect(mod.builtAt).toBe('2026-05-18T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('V-02: none of the exports are undefined or the "dev" sentinel under a real build define', async () => {
|
||||
const mod = await import('./version');
|
||||
for (const v of [mod.version, mod.commit, mod.builtAt]) {
|
||||
expect(v).toBeTruthy();
|
||||
expect(v).not.toBe('dev');
|
||||
}
|
||||
});
|
||||
});
|
||||
19
apps/web/src/lib/version.ts
Normal file
19
apps/web/src/lib/version.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Fase 14.3 — build-time version information.
|
||||
*
|
||||
* The `__APP_*` globals are inlined by Vite's `define` (see
|
||||
* `apps/web/vite.config.ts`) at build AND dev time. Touching them
|
||||
* only through this barrel keeps the substitution localised:
|
||||
*
|
||||
* - If the symbol is ever tree-shaken out of a component, only
|
||||
* this module breaks (caught by `version.test.ts`).
|
||||
* - Consumers depend on a stable named export instead of a magic
|
||||
* global, so swapping the source (e.g. moving to a runtime
|
||||
* /version endpoint) is a one-file change.
|
||||
*
|
||||
* Declarations for the three globals live in
|
||||
* `apps/web/src/global.d.ts`.
|
||||
*/
|
||||
export const version: string = __APP_VERSION__;
|
||||
export const commit: string = __APP_COMMIT__;
|
||||
export const builtAt: string = __BUILD_TIME__;
|
||||
106
apps/web/src/routes/(admin)/+layout.svelte
Normal file
106
apps/web/src/routes/(admin)/+layout.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { isAuthenticated, authLoading } from '$lib/stores/auth';
|
||||
import { isServerAdmin, isServerAdminLoaded } from '$lib/stores/serverAdmin';
|
||||
import { login, isLoggingOut } from '$lib/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { Shield, Database, Users, ScrollText, Settings } from 'lucide-svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
// Mirror the (app) group's auth-gate effect so an unauthenticated user
|
||||
// hitting /admin/* gets bounced through Keycloak instead of seeing a
|
||||
// blank screen. `isLoggingOut` suppresses re-login while logout is in
|
||||
// flight (same gotcha documented in CLAUDE.md).
|
||||
$effect(() => {
|
||||
if (!$authLoading && !$isAuthenticated && !isLoggingOut) {
|
||||
login();
|
||||
}
|
||||
});
|
||||
|
||||
// 13.3.1 client-side gate. The RPCs are also gated server-side
|
||||
// (`if not is_server_admin() then raise 'forbidden'`), but redirecting
|
||||
// non-admins to / instead of letting them see a half-rendered admin
|
||||
// shell is the obvious UX choice.
|
||||
//
|
||||
// Wait for `isServerAdminLoaded` before judging — on a cold hit to
|
||||
// /admin/* the auth listener fires SIGNED_IN, then refreshServerAdminFlag
|
||||
// runs async. Without this gate, the effect read `$isServerAdmin = false`
|
||||
// (the writable's initial value) and redirected away every time.
|
||||
$effect(() => {
|
||||
if (
|
||||
!$authLoading &&
|
||||
$isAuthenticated &&
|
||||
$isServerAdminLoaded &&
|
||||
!$isServerAdmin
|
||||
) {
|
||||
void goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{ href: '/admin/collectives', label: () => m.admin_nav_collectives(), icon: Database },
|
||||
{ href: '/admin/admins', label: () => m.admin_nav_admins(), icon: Users },
|
||||
{ href: '/admin/audit', label: () => m.admin_nav_audit(), icon: ScrollText },
|
||||
{ href: '/admin/server', label: () => m.admin_nav_server(), icon: Settings }
|
||||
];
|
||||
|
||||
const activePath = $derived($page.url.pathname);
|
||||
</script>
|
||||
|
||||
{#if $authLoading || ($isAuthenticated && !$isServerAdminLoaded)}
|
||||
<div class="flex h-screen items-center justify-center bg-background">
|
||||
<span class="text-sm text-text-secondary">{m.loading()}</span>
|
||||
</div>
|
||||
{:else if $isAuthenticated && $isServerAdmin}
|
||||
<div class="flex h-screen flex-col overflow-hidden bg-background">
|
||||
<!-- Red banner: this is the visual signal that you are NOT in the
|
||||
normal user shell. Plain HTML, no animation — operator UI. -->
|
||||
<div
|
||||
data-testid="admin-banner"
|
||||
class="flex h-10 flex-shrink-0 items-center justify-center bg-red-600 px-4 text-sm font-semibold text-white"
|
||||
>
|
||||
<Shield size={14} strokeWidth={2} class="mr-2" />
|
||||
{m.admin_banner()}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
data-testid="admin-sidebar"
|
||||
class="hidden w-56 flex-shrink-0 flex-col bg-surface-raised md:flex"
|
||||
>
|
||||
<div class="flex h-14 items-center px-4 text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
/admin
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3">
|
||||
{#each navItems as { href, label, icon: Icon } (href)}
|
||||
<a
|
||||
{href}
|
||||
data-testid={`admin-nav-${href.split('/').pop()}`}
|
||||
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {activePath === href || activePath.startsWith(href + '/') ? 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300' : 'text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50'}"
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.5} />
|
||||
{label()}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="p-3">
|
||||
<a
|
||||
href="/"
|
||||
class="block rounded-md px-3 py-2 text-xs text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50"
|
||||
>
|
||||
← {m.app_name()}
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-background">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
9
apps/web/src/routes/(admin)/+layout.ts
Normal file
9
apps/web/src/routes/(admin)/+layout.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Fase 13.3.1 — admin route group is client-rendered only (same rationale as
|
||||
// the (app) group: getSession() in load races Supabase's async storage init).
|
||||
// The actual is_server_admin() gate fires in +layout.svelte once the auth
|
||||
// listener resolves; the RPCs themselves enforce server-side. Belt-and-braces.
|
||||
export const ssr = false;
|
||||
|
||||
export function load() {
|
||||
return {};
|
||||
}
|
||||
11
apps/web/src/routes/(admin)/admin/+page.svelte
Normal file
11
apps/web/src/routes/(admin)/admin/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Default landing — collectives is the most-used surface, no extra clicks.
|
||||
onMount(() => {
|
||||
void goto('/admin/collectives', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div></div>
|
||||
183
apps/web/src/routes/(admin)/admin/admins/+page.svelte
Normal file
183
apps/web/src/routes/(admin)/admin/admins/+page.svelte
Normal file
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { UserPlus, UserX } from 'lucide-svelte';
|
||||
|
||||
type AdminRow = {
|
||||
user_id: string;
|
||||
granted_at: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
let admins = $state<AdminRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let showPromote = $state(false);
|
||||
let promoteEmail = $state('');
|
||||
let promoteError = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
const totalAdmins = $derived(admins.length);
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
// Disambiguate the FK: server_admins has TWO FKs to users (user_id +
|
||||
// granted_by); PostgREST refuses to embed without an explicit fk name.
|
||||
const { data, error: fetchErr } = await getSupabase()
|
||||
.from('server_admins')
|
||||
.select('user_id, granted_at, users!server_admins_user_id_fkey(display_name, email)')
|
||||
.order('granted_at');
|
||||
if (fetchErr) {
|
||||
error = fetchErr.message;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
admins = ((data ?? []) as unknown as { user_id: string; granted_at: string; users: { display_name: string; email: string } | null }[]).map((r) => ({
|
||||
user_id: r.user_id,
|
||||
granted_at: r.granted_at,
|
||||
display_name: r.users?.display_name ?? '',
|
||||
email: r.users?.email ?? ''
|
||||
}));
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function confirmPromote() {
|
||||
promoteError = null;
|
||||
const target = promoteEmail.trim().toLowerCase();
|
||||
if (!target) return;
|
||||
busy = true;
|
||||
|
||||
// Resolve the email to a user id via the public.users table. RLS on
|
||||
// public.users (Fase 3) lets any authenticated read by id but not by
|
||||
// email arbitrarily; admins can read everyone because the SELECT
|
||||
// policy is `true` per migration 003. If that ever tightens this
|
||||
// becomes a SECURITY DEFINER lookup.
|
||||
const sb = getSupabase();
|
||||
const { data: matches, error: lookupErr } = await sb
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('email', target)
|
||||
.limit(1);
|
||||
|
||||
if (lookupErr) {
|
||||
promoteError = lookupErr.message;
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
const userId = matches?.[0]?.id;
|
||||
if (!userId) {
|
||||
promoteError = m.admin_admins_email_not_found();
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: rpcErr } = await sb.rpc('grant_server_admin', { p_user: userId });
|
||||
busy = false;
|
||||
if (rpcErr) {
|
||||
promoteError = rpcErr.message;
|
||||
return;
|
||||
}
|
||||
|
||||
showPromote = false;
|
||||
promoteEmail = '';
|
||||
await load();
|
||||
}
|
||||
|
||||
async function revoke(userId: string) {
|
||||
busy = true;
|
||||
const { error: rpcErr } = await getSupabase().rpc('revoke_server_admin', { p_user: userId });
|
||||
busy = false;
|
||||
if (rpcErr) {
|
||||
error = rpcErr.message;
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 flex-col p-6">
|
||||
<header class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-50">{m.admin_admins_title()}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showPromote = true;
|
||||
promoteEmail = '';
|
||||
promoteError = null;
|
||||
}}
|
||||
class="ml-auto inline-flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
|
||||
data-testid="admin-promote-open"
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={1.5} />
|
||||
{m.admin_admins_promote()}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-slate-200 rounded-md border border-slate-200 dark:divide-slate-800 dark:border-slate-800" data-testid="admin-admins-list">
|
||||
{#each admins as a (a.user_id)}
|
||||
<li class="flex items-center gap-3 px-3 py-2.5 text-sm" data-testid={`admin-admin-row-${a.user_id}`}>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-slate-900 dark:text-slate-50">{a.display_name || a.email}</p>
|
||||
<p class="text-xs text-text-secondary">{a.email}</p>
|
||||
</div>
|
||||
{#if a.user_id === $currentUser?.id && totalAdmins <= 1}
|
||||
<span class="text-xs italic text-text-secondary">{m.admin_admins_revoke_self()}</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={() => revoke(a.user_id)}
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-slate-500 hover:bg-black/5 hover:text-red-700 disabled:opacity-50 dark:hover:bg-white/5"
|
||||
data-testid={`admin-revoke-${a.user_id}`}
|
||||
>
|
||||
<UserX size={12} strokeWidth={1.5} />
|
||||
{m.admin_admins_revoke()}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if showPromote}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-promote-modal">
|
||||
<h2 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_admins_promote()}</h2>
|
||||
<p class="mb-3 text-sm text-text-secondary">{m.admin_admins_promote_help()}</p>
|
||||
<label class="mb-3 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.admin_admins_email_label()}
|
||||
<input
|
||||
type="email"
|
||||
bind:value={promoteEmail}
|
||||
placeholder={m.admin_admins_email_placeholder()}
|
||||
class="mt-1 w-full rounded-md border border-slate-200 bg-background px-3 py-1.5 text-sm focus:border-slate-400 focus:outline-none dark:border-slate-800"
|
||||
data-testid="admin-promote-email"
|
||||
/>
|
||||
</label>
|
||||
{#if promoteError}
|
||||
<p class="mb-3 text-xs text-red-700 dark:text-red-300" role="alert">{promoteError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showPromote = false)} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
|
||||
<button type="button" disabled={!promoteEmail.trim() || busy} onclick={confirmPromote} class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
118
apps/web/src/routes/(admin)/admin/audit/+page.svelte
Normal file
118
apps/web/src/routes/(admin)/admin/audit/+page.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
actor_id: string;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
actor_name: string;
|
||||
actor_email: string;
|
||||
};
|
||||
|
||||
let rows = $state<Row[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let filterAction = $state('');
|
||||
let limit = $state(50);
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
let query = getSupabase()
|
||||
.from('admin_actions')
|
||||
.select('id, actor_id, action, target_type, target_id, payload, created_at, users!actor_id(display_name, email)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (filterAction.trim()) {
|
||||
query = query.ilike('action', `%${filterAction.trim()}%`);
|
||||
}
|
||||
|
||||
const { data, error: fetchErr } = await query;
|
||||
if (fetchErr) {
|
||||
error = fetchErr.message;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
rows = ((data ?? []) as unknown as (Omit<Row, 'actor_name' | 'actor_email'> & { users: { display_name: string; email: string } | null })[]).map((r) => ({
|
||||
id: r.id,
|
||||
actor_id: r.actor_id,
|
||||
action: r.action,
|
||||
target_type: r.target_type,
|
||||
target_id: r.target_id,
|
||||
payload: (r.payload as Record<string, unknown>) ?? {},
|
||||
created_at: r.created_at,
|
||||
actor_name: r.users?.display_name ?? r.actor_id,
|
||||
actor_email: r.users?.email ?? ''
|
||||
}));
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 flex-col p-6">
|
||||
<header class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-50">{m.admin_audit_title()}</h1>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={filterAction}
|
||||
oninput={() => void load()}
|
||||
placeholder="action…"
|
||||
class="ml-auto w-56 rounded-md border border-slate-200 bg-surface-raised px-3 py-1.5 text-sm focus:border-slate-400 focus:outline-none dark:border-slate-800"
|
||||
data-testid="admin-audit-filter"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-sm text-text-secondary" data-testid="admin-audit-empty">{m.admin_audit_empty()}</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-md border border-slate-200 dark:border-slate-800">
|
||||
<table class="w-full text-left text-sm" data-testid="admin-audit-table">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900/30">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_when()}</th>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_actor()}</th>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_action()}</th>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_target()}</th>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_audit_col_payload()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.id)}
|
||||
<tr class="border-t border-slate-100 align-top dark:border-slate-800/50">
|
||||
<td class="whitespace-nowrap px-3 py-2 text-xs text-text-secondary">{new Date(row.created_at).toLocaleString()}</td>
|
||||
<td class="px-3 py-2 text-text-secondary">{row.actor_name}</td>
|
||||
<td class="px-3 py-2 font-medium text-slate-900 dark:text-slate-50">{row.action}</td>
|
||||
<td class="px-3 py-2 text-text-secondary">
|
||||
<span class="text-xs">{row.target_type}</span>
|
||||
{#if row.target_id}
|
||||
<br /><span class="font-mono text-[10px] text-text-secondary">{row.target_id}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<pre class="max-w-md overflow-x-auto rounded bg-slate-50 px-2 py-1 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">{JSON.stringify(row.payload, null, 2)}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
300
apps/web/src/routes/(admin)/admin/collectives/+page.svelte
Normal file
300
apps/web/src/routes/(admin)/admin/collectives/+page.svelte
Normal file
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { RotateCcw, Trash2 } from 'lucide-svelte';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
let rows = $state<Row[]>([]);
|
||||
let search = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Modal state
|
||||
let softDeleteTarget = $state<Row | null>(null);
|
||||
let restoreTarget = $state<Row | null>(null);
|
||||
let hardDeleteTarget = $state<Row | null>(null);
|
||||
let modalReason = $state('');
|
||||
let modalConfirm = $state(false);
|
||||
let modalForce = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
const { data, error: rpcErr } = await getSupabase().rpc('admin_list_collectives', {
|
||||
p_search: search.trim() || null,
|
||||
p_limit: 200
|
||||
});
|
||||
if (rpcErr) {
|
||||
error = rpcErr.message;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
rows = ((data ?? []) as unknown as Row[]).map((r) => ({
|
||||
...r,
|
||||
member_count: Number(r.member_count)
|
||||
}));
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function startSoftDelete(row: Row) {
|
||||
softDeleteTarget = row;
|
||||
modalReason = '';
|
||||
}
|
||||
function startRestore(row: Row) {
|
||||
restoreTarget = row;
|
||||
}
|
||||
function startHardDelete(row: Row) {
|
||||
hardDeleteTarget = row;
|
||||
modalConfirm = false;
|
||||
modalForce = false;
|
||||
}
|
||||
function closeModal() {
|
||||
softDeleteTarget = null;
|
||||
restoreTarget = null;
|
||||
hardDeleteTarget = null;
|
||||
modalReason = '';
|
||||
modalConfirm = false;
|
||||
modalForce = false;
|
||||
}
|
||||
|
||||
async function confirmSoftDelete() {
|
||||
if (!softDeleteTarget || !modalReason.trim()) return;
|
||||
busy = true;
|
||||
const id = softDeleteTarget.id;
|
||||
const reason = modalReason.trim();
|
||||
const { error: rpcErr } = await getSupabase().rpc('admin_soft_delete_collective', {
|
||||
p_collective_id: id,
|
||||
p_reason: reason
|
||||
});
|
||||
busy = false;
|
||||
if (rpcErr) {
|
||||
error = rpcErr.message;
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
if (!restoreTarget) return;
|
||||
busy = true;
|
||||
const id = restoreTarget.id;
|
||||
const { error: rpcErr } = await getSupabase().rpc('admin_restore_collective', {
|
||||
p_collective_id: id
|
||||
});
|
||||
busy = false;
|
||||
if (rpcErr) {
|
||||
error = rpcErr.message;
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function confirmHardDelete() {
|
||||
if (!hardDeleteTarget || !modalConfirm) return;
|
||||
busy = true;
|
||||
const id = hardDeleteTarget.id;
|
||||
const { error: rpcErr } = await getSupabase().rpc('admin_hard_delete_collective', {
|
||||
p_collective_id: id,
|
||||
p_force: modalForce
|
||||
});
|
||||
busy = false;
|
||||
if (rpcErr) {
|
||||
error = rpcErr.message;
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
await load();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 flex-col p-6">
|
||||
<header class="mb-4 flex flex-wrap items-center gap-4">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.admin_collectives_title()}
|
||||
</h1>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
oninput={() => void load()}
|
||||
placeholder={m.admin_collectives_search_placeholder()}
|
||||
class="ml-auto w-72 rounded-md border border-slate-200 bg-surface-raised px-3 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-400 focus:outline-none dark:border-slate-800 dark:text-slate-50 dark:placeholder:text-slate-500"
|
||||
data-testid="admin-collectives-search"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-sm text-text-secondary" data-testid="admin-collectives-empty">
|
||||
{m.admin_collectives_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-md border border-slate-200 dark:border-slate-800">
|
||||
<table class="w-full text-left text-sm" data-testid="admin-collectives-table">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900/30">
|
||||
<tr>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_collectives_col_name()}</th>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_collectives_col_members()}</th>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_collectives_col_created()}</th>
|
||||
<th class="px-3 py-2 font-semibold">{m.admin_collectives_col_status()}</th>
|
||||
<th class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.id)}
|
||||
<tr
|
||||
class="border-t border-slate-100 dark:border-slate-800/50"
|
||||
data-testid={`admin-collective-row-${row.id}`}
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<span class="mr-1">{row.emoji}</span>
|
||||
<a
|
||||
href={`/admin/collectives/${row.id}`}
|
||||
class="font-medium text-slate-900 hover:underline dark:text-slate-50"
|
||||
>
|
||||
{row.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-text-secondary">{row.member_count}</td>
|
||||
<td class="px-3 py-2 text-text-secondary">{formatDate(row.created_at)}</td>
|
||||
<td class="px-3 py-2">
|
||||
{#if row.deleted_at}
|
||||
<span
|
||||
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-950/40 dark:text-red-300"
|
||||
data-testid={`admin-collective-status-${row.id}`}
|
||||
>
|
||||
{m.admin_collectives_status_deleted()}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-950/40 dark:text-green-300"
|
||||
data-testid={`admin-collective-status-${row.id}`}
|
||||
>
|
||||
{m.admin_collectives_status_active()}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
{#if row.deleted_at}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startRestore(row)}
|
||||
class="rounded-md p-1.5 text-slate-500 hover:bg-black/5 hover:text-green-700 dark:hover:bg-white/5"
|
||||
title={m.admin_action_restore()}
|
||||
data-testid={`admin-restore-${row.id}`}
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startSoftDelete(row)}
|
||||
class="rounded-md p-1.5 text-slate-500 hover:bg-black/5 hover:text-amber-700 dark:hover:bg-white/5"
|
||||
title={m.admin_action_soft_delete()}
|
||||
data-testid={`admin-soft-delete-${row.id}`}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startHardDelete(row)}
|
||||
class="rounded-md p-1.5 text-slate-500 hover:bg-black/5 hover:text-red-700 dark:hover:bg-white/5"
|
||||
title={m.admin_action_hard_delete()}
|
||||
data-testid={`admin-hard-delete-${row.id}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if softDeleteTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-soft-delete-modal">
|
||||
<h2 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_modal_soft_delete_title()}</h2>
|
||||
<p class="mb-3 text-sm text-text-secondary">{m.admin_modal_soft_delete_help()}</p>
|
||||
<label class="mb-3 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.admin_modal_reason_label()}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={modalReason}
|
||||
placeholder={m.admin_modal_reason_placeholder()}
|
||||
class="mt-1 w-full rounded-md border border-slate-200 bg-background px-3 py-1.5 text-sm focus:border-slate-400 focus:outline-none dark:border-slate-800"
|
||||
data-testid="admin-modal-reason"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={closeModal} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
|
||||
<button type="button" disabled={!modalReason.trim() || busy} onclick={confirmSoftDelete} class="rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if restoreTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-restore-modal">
|
||||
<h2 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_action_restore()}</h2>
|
||||
<p class="mb-4 text-sm text-text-secondary">{restoreTarget.name}</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={closeModal} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
|
||||
<button type="button" disabled={busy} onclick={confirmRestore} class="rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hardDeleteTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-hard-delete-modal">
|
||||
<h2 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_modal_hard_delete_title()}</h2>
|
||||
<p class="mb-3 text-sm text-text-secondary">{m.admin_modal_hard_delete_help()}</p>
|
||||
<label class="mb-2 flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" bind:checked={modalConfirm} data-testid="admin-modal-confirm-checkbox" />
|
||||
<span>{m.admin_modal_hard_delete_confirm()}</span>
|
||||
</label>
|
||||
<label class="mb-4 flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" bind:checked={modalForce} data-testid="admin-modal-force-checkbox" />
|
||||
<span>{m.admin_modal_hard_delete_force_label()}</span>
|
||||
</label>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={closeModal} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
|
||||
<button type="button" disabled={!modalConfirm || busy} onclick={confirmHardDelete} class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
219
apps/web/src/routes/(admin)/admin/collectives/[id]/+page.svelte
Normal file
219
apps/web/src/routes/(admin)/admin/collectives/[id]/+page.svelte
Normal file
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { ArrowLeft, UserMinus } from 'lucide-svelte';
|
||||
|
||||
type Member = {
|
||||
user_id: string;
|
||||
role: 'admin' | 'member' | 'guest';
|
||||
joined_at: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type Action = {
|
||||
id: string;
|
||||
actor_id: string;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type CollectiveSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
// SvelteKit types `params` as `Record<string, string>` but TS still flags
|
||||
// `params.id` as possibly undefined when accessing dynamic keys. The
|
||||
// route file name guarantees presence, so coerce.
|
||||
const id = $derived($page.params.id as string);
|
||||
|
||||
let collective = $state<CollectiveSummary | null>(null);
|
||||
let members = $state<Member[]>([]);
|
||||
let actions = $state<Action[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let removeTarget = $state<Member | null>(null);
|
||||
let removeReason = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
const sb = getSupabase();
|
||||
|
||||
const [{ data: cData, error: cErr }, { data: mData, error: mErr }, { data: aData, error: aErr }] = await Promise.all([
|
||||
sb.from('collectives').select('id, name, emoji, created_at, deleted_at').eq('id', id).maybeSingle(),
|
||||
sb
|
||||
.from('collective_members')
|
||||
.select('user_id, role, joined_at, users(display_name, email)')
|
||||
.eq('collective_id', id)
|
||||
.order('joined_at'),
|
||||
sb
|
||||
.from('admin_actions')
|
||||
.select('id, actor_id, action, target_type, target_id, payload, created_at')
|
||||
.eq('target_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
]);
|
||||
|
||||
if (cErr) error = cErr.message;
|
||||
else if (mErr) error = mErr.message;
|
||||
else if (aErr) error = aErr.message;
|
||||
|
||||
collective = (cData as CollectiveSummary | null) ?? null;
|
||||
members = ((mData ?? []) as unknown as { user_id: string; role: Member['role']; joined_at: string; users: { display_name: string; email: string } | null }[])
|
||||
.map((row) => ({
|
||||
user_id: row.user_id,
|
||||
role: row.role,
|
||||
joined_at: row.joined_at,
|
||||
display_name: row.users?.display_name ?? '',
|
||||
email: row.users?.email ?? ''
|
||||
}));
|
||||
actions = ((aData ?? []) as unknown as Action[]) ?? [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function startRemove(member: Member) {
|
||||
removeTarget = member;
|
||||
removeReason = '';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
removeTarget = null;
|
||||
removeReason = '';
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removeTarget || !removeReason.trim()) return;
|
||||
busy = true;
|
||||
const reason = removeReason.trim();
|
||||
const userId = removeTarget.user_id;
|
||||
const { error: rpcErr } = await getSupabase().rpc('admin_remove_member', {
|
||||
p_collective_id: id,
|
||||
p_user: userId,
|
||||
p_reason: reason
|
||||
});
|
||||
busy = false;
|
||||
if (rpcErr) {
|
||||
error = rpcErr.message;
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
await load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 flex-col p-6">
|
||||
<a
|
||||
href="/admin/collectives"
|
||||
class="mb-3 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-slate-900 dark:hover:text-slate-50"
|
||||
>
|
||||
<ArrowLeft size={14} strokeWidth={1.5} />
|
||||
{m.admin_collective_detail_back()}
|
||||
</a>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if !collective}
|
||||
<p class="text-sm text-text-secondary">{m.error_generic()}</p>
|
||||
{:else}
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-50">
|
||||
<span class="mr-2">{collective.emoji}</span>
|
||||
{collective.name}
|
||||
</h1>
|
||||
{#if collective.deleted_at}
|
||||
<p class="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||
{m.admin_collectives_status_deleted()} — {new Date(collective.deleted_at).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.admin_collective_detail_members()}
|
||||
</h2>
|
||||
<ul class="mb-8 divide-y divide-slate-200 rounded-md border border-slate-200 dark:divide-slate-800 dark:border-slate-800" data-testid="admin-detail-members">
|
||||
{#each members as member (member.user_id)}
|
||||
<li class="flex items-center gap-3 px-3 py-2.5 text-sm">
|
||||
<Avatar name={member.display_name} size={28} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-slate-900 dark:text-slate-50">{member.display_name}</p>
|
||||
<p class="truncate text-xs text-text-secondary">{member.email}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">{member.role}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startRemove(member)}
|
||||
class="rounded-md p-1.5 text-slate-400 hover:bg-black/5 hover:text-red-700 dark:hover:bg-white/5"
|
||||
title={m.admin_action_remove()}
|
||||
data-testid={`admin-remove-member-${member.user_id}`}
|
||||
>
|
||||
<UserMinus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.admin_collective_detail_recent_actions()}
|
||||
</h2>
|
||||
{#if actions.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.admin_audit_empty()}</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-slate-200 rounded-md border border-slate-200 text-sm dark:divide-slate-800 dark:border-slate-800" data-testid="admin-detail-actions">
|
||||
{#each actions as action (action.id)}
|
||||
<li class="px-3 py-2">
|
||||
<p class="text-xs text-text-secondary">{new Date(action.created_at).toLocaleString()}</p>
|
||||
<p class="font-medium text-slate-900 dark:text-slate-50">{action.action}</p>
|
||||
{#if action.payload && Object.keys(action.payload).length > 0}
|
||||
<pre class="mt-1 overflow-x-auto rounded bg-slate-50 px-2 py-1 text-xs text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">{JSON.stringify(action.payload, null, 2)}</pre>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if removeTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-md rounded-lg bg-surface-raised p-5 shadow-xl" role="dialog" aria-modal="true" data-testid="admin-remove-member-modal">
|
||||
<h2 class="mb-1 text-lg font-semibold text-slate-900 dark:text-slate-50">{m.admin_modal_remove_member_title()}</h2>
|
||||
<p class="mb-2 text-sm text-text-secondary">{removeTarget.display_name} ({removeTarget.email})</p>
|
||||
<p class="mb-3 text-sm text-text-secondary">{m.admin_modal_remove_member_help()}</p>
|
||||
<label class="mb-3 block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.admin_modal_reason_label()}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={removeReason}
|
||||
placeholder={m.admin_modal_reason_placeholder()}
|
||||
class="mt-1 w-full rounded-md border border-slate-200 bg-background px-3 py-1.5 text-sm focus:border-slate-400 focus:outline-none dark:border-slate-800"
|
||||
data-testid="admin-modal-reason"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={closeModal} class="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5">{m.admin_modal_cancel()}</button>
|
||||
<button type="button" disabled={!removeReason.trim() || busy} onclick={confirmRemove} class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50" data-testid="admin-modal-confirm">{m.admin_modal_confirm()}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
130
apps/web/src/routes/(admin)/admin/server/+page.svelte
Normal file
130
apps/web/src/routes/(admin)/admin/server/+page.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { SECTION_KEYS, type SectionKey } from '@colectivo/types';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type SectionState = 'on' | 'off' | 'unset';
|
||||
|
||||
let defaultSections = $state<Record<SectionKey, SectionState>>({} as Record<SectionKey, SectionState>);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let busy = $state<SectionKey | null>(null);
|
||||
|
||||
function labelFor(section: SectionKey): string {
|
||||
switch (section) {
|
||||
case 'lists':
|
||||
return m.section_label_lists();
|
||||
case 'tasks':
|
||||
return m.section_label_tasks();
|
||||
case 'notes':
|
||||
return m.section_label_notes();
|
||||
case 'search':
|
||||
return m.section_label_search();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
const { data, error: fetchErr } = await getSupabase()
|
||||
.from('server_settings')
|
||||
.select('value')
|
||||
.eq('key', 'default_sections')
|
||||
.maybeSingle();
|
||||
if (fetchErr) {
|
||||
error = fetchErr.message;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
const value = (data?.value as Record<string, boolean>) ?? {};
|
||||
const next: Record<SectionKey, SectionState> = {} as Record<SectionKey, SectionState>;
|
||||
for (const key of SECTION_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
next[key] = value[key] === true ? 'on' : 'off';
|
||||
} else {
|
||||
next[key] = 'unset';
|
||||
}
|
||||
}
|
||||
defaultSections = next;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function setSection(section: SectionKey, enabled: boolean) {
|
||||
busy = section;
|
||||
const { error: rpcErr } = await getSupabase().rpc('admin_set_default_section', {
|
||||
p_section: section,
|
||||
p_enabled: enabled
|
||||
});
|
||||
busy = null;
|
||||
if (rpcErr) {
|
||||
error = rpcErr.message;
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
async function clearSection(section: SectionKey) {
|
||||
busy = section;
|
||||
// "Unset" is a payload patch that removes the key. We need a separate
|
||||
// RPC for delete; not in 025 — TODO. For now, set to true (the
|
||||
// default) so the layer becomes harmless. The plan §13.3.7 explicitly
|
||||
// says 4 toggles, not 3-state; the "unset" rendering is a read-only
|
||||
// affordance to show whether the server has an opinion at all.
|
||||
await setSection(section, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 flex-col p-6">
|
||||
<h1 class="mb-4 text-2xl font-semibold text-slate-900 dark:text-slate-50">{m.admin_server_title()}</h1>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
<h2 class="mb-1 mt-4 text-sm font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.admin_server_default_sections()}
|
||||
</h2>
|
||||
<p class="mb-3 text-xs text-text-secondary">{m.admin_server_default_sections_help()}</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-slate-200 rounded-md border border-slate-200 dark:divide-slate-800 dark:border-slate-800" data-testid="admin-server-sections">
|
||||
{#each SECTION_KEYS as section (section)}
|
||||
{@const state = defaultSections[section]}
|
||||
<li class="flex items-center gap-3 px-3 py-2.5 text-sm" data-testid={`admin-server-section-${section}`}>
|
||||
<span class="min-w-0 flex-1 font-medium text-slate-900 dark:text-slate-50">{labelFor(section)}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {state === 'on' ? 'bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-300' : state === 'off' ? 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'}"
|
||||
data-testid={`admin-server-section-state-${section}`}
|
||||
>
|
||||
{state === 'on' ? m.admin_server_section_state_on() : state === 'off' ? m.admin_server_section_state_off() : m.admin_server_section_state_unset()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === section}
|
||||
onclick={() => setSection(section, true)}
|
||||
class="rounded-md px-2 py-1 text-xs text-slate-500 hover:bg-black/5 hover:text-green-700 disabled:opacity-50 dark:hover:bg-white/5"
|
||||
data-testid={`admin-server-on-${section}`}
|
||||
>
|
||||
{m.admin_server_section_state_on()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === section}
|
||||
onclick={() => setSection(section, false)}
|
||||
class="rounded-md px-2 py-1 text-xs text-slate-500 hover:bg-black/5 hover:text-red-700 disabled:opacity-50 dark:hover:bg-white/5"
|
||||
data-testid={`admin-server-off-${section}`}
|
||||
>
|
||||
{m.admin_server_section_state_off()}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,13 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { isAuthenticated, authLoading } from '$lib/stores/auth';
|
||||
import { isAuthenticated, authLoading, currentUser } from '$lib/stores/auth';
|
||||
import { login, isLoggingOut } from '$lib/auth';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { enabledSections } from '$lib/stores/features';
|
||||
import { unresolvedConflictsCount } from '$lib/stores/syncStatus';
|
||||
import type { SectionKey } from '@colectivo/types';
|
||||
import { setSyncContext } from '$lib/sync';
|
||||
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
|
||||
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
|
||||
import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte';
|
||||
import MobileDrawer from '$lib/components/layout/MobileDrawer.svelte';
|
||||
import UndoToast from '$lib/components/UndoToast.svelte';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
@@ -22,6 +32,13 @@
|
||||
const detailRoutePattern = /^\/(lists\/[0-9a-f-]+(?:\/session)?|tasks\/[0-9a-f-]+|notes\/[0-9a-f-]+)\/?$/;
|
||||
const isDetailRoute = $derived(detailRoutePattern.test($page.url.pathname));
|
||||
|
||||
// Fase 9.2: cap the reading column at max-w-2xl on >=md viewports for
|
||||
// content detail routes (lists/[id], tasks/[id], notes/[id]). Mobile
|
||||
// stays full-width. Shopping session is explicitly excluded — the mode
|
||||
// is immersive and fills the viewport.
|
||||
const readingRoutePattern = /^\/(lists\/[0-9a-f-]+|tasks\/[0-9a-f-]+|notes\/[0-9a-f-]+)\/?$/;
|
||||
const isReadingRoute = $derived(readingRoutePattern.test($page.url.pathname));
|
||||
|
||||
// When auth resolves as unauthenticated, redirect to Keycloak.
|
||||
// This runs after onAuthStateChange fires (authLoading = false), so there
|
||||
// is no race with Supabase's async localStorage initialisation.
|
||||
@@ -33,11 +50,109 @@
|
||||
login();
|
||||
}
|
||||
});
|
||||
|
||||
// Fase 9.3: keep the offline-sync queue's context (user_id +
|
||||
// collective_id) in sync with the active session. Without this the
|
||||
// queue's conflict-detection short-circuits silently (it needs a
|
||||
// known user_id to satisfy sync_conflicts.user_id RLS).
|
||||
$effect(() => {
|
||||
setSyncContext({
|
||||
currentUserId: $currentUser?.id ?? null,
|
||||
collectiveId: $currentCollective?.id ?? null
|
||||
});
|
||||
});
|
||||
|
||||
// Fase 12.3.2: section-visibility route guard. If the URL hits a
|
||||
// disabled section we bounce to /lists and surface a transient toast.
|
||||
// "lists" is hard-allowed (the §12.3.4 always-one-landing rule) even if
|
||||
// every flag layer says OFF — its $enabledSections value is still
|
||||
// computed normally but we never redirect away from it from here.
|
||||
const SECTION_BY_PREFIX: Array<[string, SectionKey]> = [
|
||||
['/tasks', 'tasks'],
|
||||
['/notes', 'notes'],
|
||||
['/search', 'search']
|
||||
];
|
||||
let sectionToast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
$effect(() => {
|
||||
if ($authLoading || !$isAuthenticated) return;
|
||||
const path = $page.url.pathname;
|
||||
const match = SECTION_BY_PREFIX.find(
|
||||
([prefix]) => path === prefix || path.startsWith(`${prefix}/`)
|
||||
);
|
||||
if (!match) return;
|
||||
const [, section] = match;
|
||||
if ($enabledSections[section]) return;
|
||||
|
||||
// Disabled — show toast then redirect away.
|
||||
sectionToast = m.section_disabled_for_collective();
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (sectionToast = null), 3500);
|
||||
void goto('/lists');
|
||||
});
|
||||
|
||||
// Fase 14.2.3 — probe the server for unresolved sync_conflicts so the
|
||||
// chrome can show a "review" banner. We only ask when the user id
|
||||
// changes (login + collective switch don't change user_id; this fires
|
||||
// once per session). Dismissals are stored in localStorage under
|
||||
// `syncConflictsDismissed` by `/settings/sync-conflicts`; we subtract
|
||||
// them here so a dismissed log doesn't keep re-surfacing the banner.
|
||||
const DISMISSED_KEY = 'syncConflictsDismissed';
|
||||
let lastConflictProbeUserId: string | null = $state(null);
|
||||
$effect(() => {
|
||||
const userId = $currentUser?.id ?? null;
|
||||
if (!userId || userId === lastConflictProbeUserId) return;
|
||||
lastConflictProbeUserId = userId;
|
||||
setTimeout(async () => {
|
||||
const { data } = await getSupabase()
|
||||
.from('sync_conflicts')
|
||||
.select('id')
|
||||
.eq('user_id', userId);
|
||||
let dismissed = new Set<string>();
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||
if (raw) dismissed = new Set(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
/* corrupt — treat as no dismissals */
|
||||
}
|
||||
const open = (data ?? []).filter((r: { id: string }) => !dismissed.has(r.id));
|
||||
unresolvedConflictsCount.set(open.length);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Fase 9.1: cross-device theme sync. When the user logs in, read
|
||||
// public.users.theme and adopt it if it differs from the local
|
||||
// preference. The anti-FOUC inline script in app.html and initTheme()
|
||||
// have already set the local default from localStorage, so this only
|
||||
// overrides when the server says otherwise. Defer the PostgREST query
|
||||
// out of the auth callback (gotcha already documented for collectives).
|
||||
let lastSyncedUserId: string | null = $state(null);
|
||||
$effect(() => {
|
||||
const userId = $currentUser?.id ?? null;
|
||||
if (!userId || userId === lastSyncedUserId) return;
|
||||
lastSyncedUserId = userId;
|
||||
setTimeout(async () => {
|
||||
const { data } = await getSupabase()
|
||||
.from('users')
|
||||
.select('theme')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
const remote = (data?.theme ?? null) as ThemePreference | null;
|
||||
if (
|
||||
remote &&
|
||||
(remote === 'light' || remote === 'dark' || remote === 'system') &&
|
||||
remote !== get(themePreference)
|
||||
) {
|
||||
setThemePreference(remote);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $authLoading}
|
||||
<div class="flex h-screen items-center justify-center bg-background">
|
||||
<span class="text-sm text-text-secondary">{m.loading()}</span>
|
||||
<div class="flex h-screen flex-col items-center justify-center gap-3 bg-background text-text-secondary">
|
||||
<Spinner size="lg" />
|
||||
<span class="text-sm">{m.loading()}</span>
|
||||
</div>
|
||||
{:else if $isAuthenticated}
|
||||
<!-- Mobile: column stack (top bar → main → bottom tab bar).
|
||||
@@ -51,10 +166,49 @@
|
||||
<MobileDrawer bind:open={drawerOpen} />
|
||||
|
||||
<main class="flex flex-1 flex-col overflow-hidden bg-background pb-16 md:pb-0">
|
||||
{#if isReadingRoute}
|
||||
<!-- Fase 9.2: cap reading column at 42rem on >=md, full-width on mobile. -->
|
||||
<div
|
||||
data-testid="reading-column"
|
||||
class="flex flex-1 flex-col overflow-hidden md:mx-auto md:w-full md:max-w-2xl"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BottomTabBar />
|
||||
<UndoToast />
|
||||
{#if $unresolvedConflictsCount > 0 && $page.url.pathname !== '/settings/sync-conflicts'}
|
||||
<div
|
||||
data-testid="sync-conflicts-banner"
|
||||
role="status"
|
||||
class="pointer-events-none fixed inset-x-0 bottom-20 z-40 flex justify-center px-4 md:bottom-6"
|
||||
>
|
||||
<a
|
||||
href="/settings/sync-conflicts"
|
||||
class="pointer-events-auto flex items-center gap-3 rounded-full bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-[0px_20px_40px_rgba(245,158,11,0.3)] hover:bg-amber-600"
|
||||
>
|
||||
<span>{m.pwa_sync_conflicts_banner()}</span>
|
||||
<span class="rounded-full bg-white/20 px-2 py-0.5 text-xs">
|
||||
{m.pwa_sync_conflicts_review()}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if sectionToast}
|
||||
<div
|
||||
data-testid="section-disabled-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="pointer-events-none fixed inset-x-0 top-6 z-50 flex justify-center px-4"
|
||||
>
|
||||
<div class="pointer-events-auto rounded-md bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.25)] dark:bg-slate-100 dark:text-slate-900">
|
||||
{sectionToast}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,15 @@
|
||||
import { ShoppingCart, Plus, MoreHorizontal, Trash2, Archive, RotateCcw } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||
import CreateListModal from '$lib/components/CreateListModal.svelte';
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let creating = $state(false);
|
||||
let createModalOpen = $state(false);
|
||||
// Fase 18.3.4: when the modal auto-suffixes "#N", surface the final name in
|
||||
// a transient toast so the user sees what was actually created.
|
||||
let autoNumberedToast = $state<string | null>(null);
|
||||
|
||||
let showTrash = $state(false);
|
||||
let trashedLists = $state<ShoppingList[]>([]);
|
||||
@@ -55,15 +60,29 @@
|
||||
}
|
||||
|
||||
// ── Create ─────────────────────────────────────────────────────────────────
|
||||
// Matches the /notes flow: click the masthead button → create with an empty
|
||||
// name → navigate to /lists/[id] where the user renames inline.
|
||||
// Fase 18: replaces the old "create with empty name + navigate to detail to
|
||||
// rename inline" flow with an explicit modal. The modal prefills the input
|
||||
// with `currentCollective.default_list_title`, suggests `#N` autocomplete
|
||||
// for catalog-curated prefixes, and gates submit on non-empty trim.
|
||||
|
||||
async function handleCreate() {
|
||||
if (!$currentCollective || !$currentUser || creating) return;
|
||||
creating = true;
|
||||
const created = await createList($currentCollective.id, '', $currentUser.id);
|
||||
creating = false;
|
||||
if (created) await goto(`/lists/${created.id}`);
|
||||
function openCreateModal() {
|
||||
// Open eagerly — the modal itself handles missing collective/user
|
||||
// (the submit button is disabled until both are set). Letting the
|
||||
// modal open on click avoids a UX trap where the button looks broken
|
||||
// because the auth state hasn't propagated yet.
|
||||
createModalOpen = true;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
createModalOpen = false;
|
||||
}
|
||||
|
||||
async function handleCreated(id: string, finalName: string, wasAutoNumbered: boolean) {
|
||||
if (wasAutoNumbered) {
|
||||
autoNumberedToast = finalName;
|
||||
setTimeout(() => (autoNumberedToast = null), 3000);
|
||||
}
|
||||
await goto(`/lists/${id}`);
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
@@ -132,8 +151,9 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreate}
|
||||
onclick={openCreateModal}
|
||||
disabled={creating || !$currentCollective}
|
||||
data-testid="lists-new-list-button"
|
||||
class="ml-1 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-60 dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
@@ -392,3 +412,18 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<CreateListModal
|
||||
open={createModalOpen}
|
||||
onClose={closeCreateModal}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
|
||||
{#if autoNumberedToast}
|
||||
<div
|
||||
data-testid="auto-numbered-toast"
|
||||
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-md bg-slate-900 px-4 py-2 text-sm text-white shadow-lg dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
{m.create_list_auto_numbered_toast({ title: autoNumberedToast })}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import {
|
||||
loadItems,
|
||||
loadListItems,
|
||||
addItem,
|
||||
updateItem,
|
||||
checkItem,
|
||||
@@ -31,9 +31,18 @@
|
||||
import { enqueueOp, hydrateSyncState } from '$lib/sync';
|
||||
import { scheduleUndoable } from '$lib/sync/undoQueue';
|
||||
import SyncBanner from '$lib/components/SyncBanner.svelte';
|
||||
import TagChip from '$lib/components/TagChip.svelte';
|
||||
import TagPicker from '$lib/components/TagPicker.svelte';
|
||||
import {
|
||||
tags as tagsStore,
|
||||
loadTags,
|
||||
createTag,
|
||||
attachTag,
|
||||
detachTag
|
||||
} from '$lib/stores/tags';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { generateId } from '$lib/utils/id';
|
||||
import type { ShoppingItem, ShoppingList } from '@colectivo/types';
|
||||
import type { ShoppingItem, ShoppingList, ItemTag, ItemWithTags } from '@colectivo/types';
|
||||
import ItemSuggestions from '$lib/components/ItemSuggestions.svelte';
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -55,9 +64,41 @@
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let list = $state<ShoppingList | null>(null);
|
||||
let items = $state<ShoppingItem[]>([]);
|
||||
let items = $state<ItemWithTags[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
// Tags ── filter selection lives in the URL query string ("?tags=foo,bar")
|
||||
// so a filtered view can be deep-linked + shared. Filter is intersection.
|
||||
let activeTagNames = $state<string[]>([]);
|
||||
|
||||
function parseTagsFromQuery(): string[] {
|
||||
const raw = $page.url.searchParams.get('tags');
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
async function setActiveTagNames(next: string[]) {
|
||||
activeTagNames = next;
|
||||
const url = new URL($page.url);
|
||||
if (next.length === 0) {
|
||||
url.searchParams.delete('tags');
|
||||
} else {
|
||||
url.searchParams.set('tags', next.join(','));
|
||||
}
|
||||
await goto(url.pathname + url.search, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function toggleTagFilter(name: string) {
|
||||
if (activeTagNames.includes(name)) {
|
||||
void setActiveTagNames(activeTagNames.filter((n) => n !== name));
|
||||
} else {
|
||||
void setActiveTagNames([...activeTagNames, name]);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline rename of the list (replaces the old /lists sticky input flow).
|
||||
let listName = $state('');
|
||||
let nameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -73,11 +114,18 @@
|
||||
let suggestionsTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Double-tap edit overlay (replaces inline edit)
|
||||
let overlayItem = $state<ShoppingItem | null>(null);
|
||||
let overlayItem = $state<ItemWithTags | null>(null);
|
||||
let overlayName = $state('');
|
||||
let lastTapById = new Map<string, number>();
|
||||
const DOUBLE_TAP_WINDOW_MS = 300;
|
||||
|
||||
// Live view of the overlay's tags (kept in sync with the items store so
|
||||
// the picker re-renders after attach/detach).
|
||||
const overlayTags = $derived(
|
||||
overlayItem ? items.find((i) => i.id === overlayItem!.id)?.tags ?? [] : []
|
||||
);
|
||||
const overlayTagIds = $derived(overlayTags.map((t) => t.id));
|
||||
|
||||
// Symmetric toggle swipe — replaces the old swipe-to-delete.
|
||||
// Unchecked + swipe right → check. Checked + swipe left → uncheck.
|
||||
// The opposite direction for each state is a no-op (snaps back).
|
||||
@@ -203,8 +251,37 @@
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
const uncheckedItems = $derived(items.filter((i) => !i.is_checked));
|
||||
const checkedItems = $derived(items.filter((i) => i.is_checked));
|
||||
// Apply the tag filter (intersection) on top of the checked split. If no
|
||||
// tags are active, all items pass through. Matching is by tag NAME so a
|
||||
// `?tags=foo` URL keeps working after a tag is renamed elsewhere (the
|
||||
// URL chip would just disappear — acceptable for MVP).
|
||||
const filteredItems = $derived(
|
||||
activeTagNames.length === 0
|
||||
? items
|
||||
: items.filter((i) =>
|
||||
activeTagNames.every((name) => i.tags.some((t) => t.name === name))
|
||||
)
|
||||
);
|
||||
const uncheckedItems = $derived(filteredItems.filter((i) => !i.is_checked));
|
||||
const checkedItems = $derived(filteredItems.filter((i) => i.is_checked));
|
||||
|
||||
// Tag chips to show in the filter bar — show the active filters first,
|
||||
// then the rest of the collective's tags. Capped to keep the bar lean.
|
||||
const filterBarTags = $derived.by(() => {
|
||||
const ordered: ItemTag[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const name of activeTagNames) {
|
||||
const tag = $tagsStore.find((t) => t.name === name);
|
||||
if (tag) {
|
||||
ordered.push(tag);
|
||||
seen.add(tag.id);
|
||||
}
|
||||
}
|
||||
for (const tag of $tagsStore) {
|
||||
if (!seen.has(tag.id)) ordered.push(tag);
|
||||
}
|
||||
return ordered.slice(0, 20);
|
||||
});
|
||||
|
||||
// ── Load + Realtime ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -217,7 +294,7 @@
|
||||
onMount(async () => {
|
||||
const [listRes, itemsRes] = await Promise.all([
|
||||
getSupabase().from('shopping_lists').select('*').eq('id', listId).single(),
|
||||
loadItems(listId)
|
||||
loadListItems(listId)
|
||||
]);
|
||||
|
||||
if (listRes.error || !listRes.data) {
|
||||
@@ -228,10 +305,18 @@
|
||||
list = listRes.data as ShoppingList;
|
||||
listName = list.name ?? '';
|
||||
items = itemsRes;
|
||||
activeTagNames = parseTagsFromQuery();
|
||||
loading = false;
|
||||
|
||||
if ($currentCollective) {
|
||||
suggestions = await fetchSuggestions($currentCollective.id, '');
|
||||
// Fase 15: exclude items already on the list from the suggestion
|
||||
// strip — the user doesn't need to be nudged to re-add what's
|
||||
// already there.
|
||||
suggestions = await fetchSuggestions($currentCollective.id, '', {
|
||||
excludeNames: items.map((i) => i.name)
|
||||
});
|
||||
// Load tags for this collective — picker + filter need them.
|
||||
await loadTags($currentCollective.id);
|
||||
}
|
||||
|
||||
await hydrateSyncState();
|
||||
@@ -246,17 +331,86 @@
|
||||
i.sort_order === evt.row.sort_order
|
||||
);
|
||||
if (optimistic) {
|
||||
items = items.map((i) => (i.id === optimistic.id ? evt.row : i));
|
||||
items = items.map((i) =>
|
||||
i.id === optimistic.id ? { ...evt.row, tags: i.tags } : i
|
||||
);
|
||||
pendingTempIds.delete(optimistic.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
items = applyItemEvent(items, evt);
|
||||
// applyItemEvent operates on ShoppingItem fields only — it preserves
|
||||
// `tags: ItemTag[]` on the existing row when shallow-merging the new
|
||||
// UPDATE payload. INSERTs of brand-new rows arrive without tags
|
||||
// (they're attached separately); seed an empty array.
|
||||
const next = applyItemEvent(items as unknown as ShoppingItem[], evt);
|
||||
// Re-sort by sort_order so a realtime UPDATE that only changes the
|
||||
// row's sort_order reorders the rendered list without a refresh
|
||||
// (Fase 11.4.3). Without this the array order is stable across
|
||||
// shallow-merge and a remote reorder is invisible to other clients.
|
||||
items = next
|
||||
.map((row) => {
|
||||
const existing = items.find((i) => i.id === row.id);
|
||||
return { ...row, tags: existing?.tags ?? [] };
|
||||
})
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
});
|
||||
|
||||
// Listen for shopping_item_tags changes so attach/detach in another tab
|
||||
// or device propagates. We re-load the affected row's tags rather than
|
||||
// trying to mutate the local cache from the delta payload.
|
||||
tagLinkChannel = getSupabase().channel(`list:${listId}:item-tags`);
|
||||
tagLinkChannel.on(
|
||||
'postgres_changes' as never,
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'shopping_item_tags'
|
||||
},
|
||||
async (payload: {
|
||||
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
new: { item_id?: string };
|
||||
old: { item_id?: string };
|
||||
}) => {
|
||||
const itemId = payload.new?.item_id ?? payload.old?.item_id;
|
||||
if (!itemId) return;
|
||||
// Only refresh if the item is in our list.
|
||||
if (!items.some((i) => i.id === itemId)) return;
|
||||
const { data } = await getSupabase()
|
||||
.from('shopping_item_tags')
|
||||
.select('item_tags(*)')
|
||||
.eq('item_id', itemId);
|
||||
type Row = { item_tags: ItemTag | null };
|
||||
const nextTags = ((data as Row[] | null) ?? [])
|
||||
.map((r) => r.item_tags)
|
||||
.filter((t): t is ItemTag => t !== null);
|
||||
items = items.map((i) => (i.id === itemId ? { ...i, tags: nextTags } : i));
|
||||
}
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
tagLinkChannel!.subscribe((status) => {
|
||||
if (status === 'SUBSCRIBED') resolve();
|
||||
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
|
||||
// don't block the page render on this aux channel
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Channel handle for shopping_item_tags realtime updates.
|
||||
let tagLinkChannel: import('@supabase/supabase-js').RealtimeChannel | null = null;
|
||||
|
||||
onDestroy(async () => {
|
||||
await realtimeSub?.unsubscribe();
|
||||
if (tagLinkChannel) {
|
||||
try {
|
||||
await tagLinkChannel.unsubscribe();
|
||||
await getSupabase().removeChannel(tagLinkChannel);
|
||||
} catch {
|
||||
// idempotent
|
||||
}
|
||||
tagLinkChannel = null;
|
||||
}
|
||||
if (nameSaveTimer) {
|
||||
clearTimeout(nameSaveTimer);
|
||||
await flushNameSave();
|
||||
@@ -265,16 +419,24 @@
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
|
||||
async function updateSuggestions(prefix: string) {
|
||||
// Fase 15: exclude items already in the list so the strip doesn't nudge
|
||||
// the user to re-add what they already have. We snapshot the names here
|
||||
// so the `$effect` below re-fires whenever either the prefix typed or
|
||||
// the list contents change.
|
||||
const excludedItemNames = $derived(items.map((i) => i.name));
|
||||
|
||||
async function updateSuggestions(prefix: string, exclude: string[]) {
|
||||
clearTimeout(suggestionsTimer);
|
||||
if (!$currentCollective) return;
|
||||
suggestionsTimer = setTimeout(async () => {
|
||||
suggestions = await fetchSuggestions($currentCollective!.id, prefix);
|
||||
suggestions = await fetchSuggestions($currentCollective!.id, prefix, {
|
||||
excludeNames: exclude
|
||||
});
|
||||
}, 150);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
updateSuggestions(newName);
|
||||
updateSuggestions(newName, excludedItemNames);
|
||||
});
|
||||
|
||||
function selectSuggestion(name: string) {
|
||||
@@ -308,7 +470,7 @@
|
||||
|
||||
const sortOrder = items.length;
|
||||
const tempId = generateId();
|
||||
const optimistic: ShoppingItem = {
|
||||
const optimistic: ItemWithTags = {
|
||||
id: tempId,
|
||||
list_id: listId,
|
||||
name,
|
||||
@@ -318,7 +480,8 @@
|
||||
checked_at: null,
|
||||
sort_order: sortOrder,
|
||||
created_by: $currentUser.id,
|
||||
created_at: new Date().toISOString()
|
||||
created_at: new Date().toISOString(),
|
||||
tags: []
|
||||
};
|
||||
|
||||
// Use tempId as the REAL server id. The server accepts a client-provided
|
||||
@@ -416,7 +579,8 @@
|
||||
}
|
||||
|
||||
function openOverlay(item: ShoppingItem) {
|
||||
overlayItem = item;
|
||||
const full = items.find((i) => i.id === item.id) ?? null;
|
||||
overlayItem = full ?? { ...item, tags: [] };
|
||||
overlayName = item.name;
|
||||
}
|
||||
|
||||
@@ -523,15 +687,22 @@
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleDndConsider(e: CustomEvent<{ items: ShoppingItem[] }>) {
|
||||
const checked = items.filter((i) => i.is_checked);
|
||||
items = [...e.detail.items, ...checked];
|
||||
// Dnd handlers. The dndzone only sees `uncheckedItems` (post-filter), so
|
||||
// when a tag filter is active we must merge the reordered subset back with
|
||||
// the rows hidden by the filter (unchecked-but-filtered-out) AND the
|
||||
// checked rows. Without this, dragging while a filter is on would drop
|
||||
// every off-screen row from `items`.
|
||||
function handleDndConsider(e: CustomEvent<{ items: ItemWithTags[] }>) {
|
||||
const reorderedIds = new Set(e.detail.items.map((i) => i.id));
|
||||
const untouched = items.filter((i) => !reorderedIds.has(i.id));
|
||||
items = [...e.detail.items, ...untouched];
|
||||
}
|
||||
|
||||
async function handleDndFinalize(e: CustomEvent<{ items: ShoppingItem[] }>) {
|
||||
const checked = items.filter((i) => i.is_checked);
|
||||
async function handleDndFinalize(e: CustomEvent<{ items: ItemWithTags[] }>) {
|
||||
const reordered = e.detail.items;
|
||||
items = [...reordered, ...checked];
|
||||
const reorderedIds = new Set(reordered.map((i) => i.id));
|
||||
const untouched = items.filter((i) => !reorderedIds.has(i.id));
|
||||
items = [...reordered, ...untouched];
|
||||
await reorderItems(reordered);
|
||||
}
|
||||
|
||||
@@ -690,9 +861,53 @@
|
||||
>
|
||||
{m.list_status_archived()}
|
||||
</span>
|
||||
{:else if list?.status === 'completed'}
|
||||
<!-- Fase 10.7: prominent reset CTA on the completed-list view.
|
||||
The 3-dot menu already exposes the same action, but landing
|
||||
on a list you just finished and not seeing a way to start
|
||||
over without bouncing back to /lists is the UX trap. -->
|
||||
<button
|
||||
type="button"
|
||||
data-testid="detail-reset-button"
|
||||
onclick={handleReset}
|
||||
class="shrink-0 inline-flex items-center gap-1.5 rounded-full bg-slate-900 px-3 py-1
|
||||
text-xs font-semibold text-white hover:bg-slate-800
|
||||
dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
<RotateCcw size={12} strokeWidth={2} />
|
||||
{m.list_reset()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tag filter bar (only renders when the collective has tags).
|
||||
Selecting toggles a tag — multiple selected = intersection. -->
|
||||
{#if filterBarTags.length > 0 && !selectionMode}
|
||||
<div
|
||||
data-testid="list-tag-filter"
|
||||
class="flex flex-wrap items-center gap-1.5 px-4 pb-2 md:px-8"
|
||||
aria-label={m.list_filter_by_tag()}
|
||||
>
|
||||
{#each filterBarTags as tag (tag.id)}
|
||||
<TagChip
|
||||
name={tag.name}
|
||||
color={tag.color}
|
||||
active={activeTagNames.includes(tag.name)}
|
||||
onSelect={() => toggleTagFilter(tag.name)}
|
||||
/>
|
||||
{/each}
|
||||
{#if activeTagNames.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setActiveTagNames([])}
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
{m.list_filter_clear()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uncheckedItems.length === 0 && checkedItems.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p class="mb-1 text-sm font-medium text-text-primary">{m.list_items_empty()}</p>
|
||||
@@ -739,6 +954,8 @@
|
||||
role="listitem"
|
||||
data-testid="item-row"
|
||||
data-selected={isSelected ? 'true' : undefined}
|
||||
data-checked="false"
|
||||
data-name={item.name}
|
||||
class="relative flex items-center gap-2 py-3 select-none transition-transform duration-150 {isSelected ? 'bg-slate-100 dark:bg-slate-800' : 'bg-background'} {selectionMode ? '' : 'touch-pan-y'}"
|
||||
style={selectionMode ? '' : `transform: translateX(${offset}px)`}
|
||||
onpointerdown={(e) => {
|
||||
@@ -785,20 +1002,37 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Name as button; tap=nothing/toggle-selection, dbltap=overlay -->
|
||||
<!-- Name + tag chips. The name button stays a button (tap = noop /
|
||||
toggle-selection, dbltap = open overlay). Tag chips render on
|
||||
a second line and are themselves clickable filter triggers,
|
||||
so they live OUTSIDE the name button. -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (selectionMode) toggleSelection(item.id);
|
||||
else onRowTap(item);
|
||||
}}
|
||||
class="flex-1 min-w-0 text-left"
|
||||
class="block w-full text-left"
|
||||
aria-label={item.name}
|
||||
>
|
||||
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
|
||||
{item.name}
|
||||
</span>
|
||||
</button>
|
||||
{#if !selectionMode && item.tags.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each item.tags as tag (tag.id)}
|
||||
<TagChip
|
||||
name={tag.name}
|
||||
color={tag.color}
|
||||
active={activeTagNames.includes(tag.name)}
|
||||
onSelect={() => toggleTagFilter(tag.name)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quantity stepper — hidden in selection mode -->
|
||||
{#if !selectionMode}
|
||||
@@ -858,6 +1092,8 @@
|
||||
role="listitem"
|
||||
data-testid="item-row"
|
||||
data-selected={isSelected ? 'true' : undefined}
|
||||
data-checked="true"
|
||||
data-name={item.name}
|
||||
class="relative flex items-center gap-2 py-3 select-none transition-transform duration-150 {isSelected ? 'bg-slate-100 dark:bg-slate-800' : 'bg-background opacity-70'} {selectionMode ? '' : 'touch-pan-y'}"
|
||||
style={selectionMode ? '' : `transform: translateX(${offset}px)`}
|
||||
onpointerdown={(e) => {
|
||||
@@ -1037,6 +1273,43 @@
|
||||
autofocus
|
||||
class="mb-4 w-full rounded-md bg-background px-3 py-2 text-base text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
|
||||
/>
|
||||
|
||||
<!-- Tags section. Inline create + toggle; mutations are awaited so the
|
||||
surrounding `items` array reflects the new tag arrays before the
|
||||
overlay re-renders the picker. -->
|
||||
<div class="mb-4">
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{m.tag_picker_label()}
|
||||
</p>
|
||||
<TagPicker
|
||||
availableTags={$tagsStore}
|
||||
selectedIds={overlayTagIds}
|
||||
onToggle={async (tag) => {
|
||||
if (!overlayItem) return;
|
||||
const id = overlayItem.id;
|
||||
const already = overlayTagIds.includes(tag.id);
|
||||
// Optimistic: patch the item's tag list immediately.
|
||||
items = items.map((i) =>
|
||||
i.id !== id
|
||||
? i
|
||||
: { ...i, tags: already ? i.tags.filter((t) => t.id !== tag.id) : [...i.tags, tag] }
|
||||
);
|
||||
if (already) await detachTag(id, tag.id);
|
||||
else await attachTag(id, tag.id);
|
||||
}}
|
||||
onCreate={async (name) => {
|
||||
if (!overlayItem || !$currentCollective) return;
|
||||
const id = overlayItem.id;
|
||||
const created = await createTag($currentCollective.id, name);
|
||||
if (!created) return;
|
||||
items = items.map((i) =>
|
||||
i.id !== id ? i : { ...i, tags: [...i.tags, created] }
|
||||
);
|
||||
await attachTag(id, created.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { loadArchivedNotes, unarchiveNote, trashNote } from '$lib/stores/notes';
|
||||
import type { Note } from '@colectivo/types';
|
||||
import { ArrowLeft, ArchiveRestore, Trash2 } from 'lucide-svelte';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let archived = $state<Note[]>([]);
|
||||
@@ -44,7 +45,10 @@
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
<p class="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<Spinner size="sm" />
|
||||
<span>{m.loading()}</span>
|
||||
</p>
|
||||
{:else if archived.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.notes_archived_empty()}</p>
|
||||
{:else}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Search, FileText, CheckSquare, ShoppingCart } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchRow[]>([]);
|
||||
@@ -119,7 +120,10 @@
|
||||
{#if query.trim().length < 2}
|
||||
<p class="text-sm text-text-secondary">{m.search_hint()}</p>
|
||||
{:else if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
<p class="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<Spinner size="sm" />
|
||||
<span>{m.loading()}</span>
|
||||
</p>
|
||||
{:else if results.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.search_empty()}</p>
|
||||
{:else}
|
||||
|
||||
@@ -1,45 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { userCollectives, currentCollective } from '$lib/stores/collective';
|
||||
import { logout } from '$lib/auth';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import ImageCropper from '$lib/components/ImageCropper.svelte';
|
||||
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import SyncConflictsPanel from '$lib/components/SyncConflictsPanel.svelte';
|
||||
import TagChip from '$lib/components/TagChip.svelte';
|
||||
import ChangelogModal from '$lib/components/ChangelogModal.svelte';
|
||||
import {
|
||||
tags as tagsStore,
|
||||
loadTags,
|
||||
renameTag,
|
||||
recolorTag,
|
||||
deleteTag
|
||||
} from '$lib/stores/tags';
|
||||
import type { ItemTag, ItemTagColor, FeatureFlags, SectionKey } from '@colectivo/types';
|
||||
import { SECTION_KEYS } from '@colectivo/types';
|
||||
import {
|
||||
currentUserFeatures,
|
||||
setUserFeature
|
||||
} from '$lib/stores/features';
|
||||
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import type { ThemePreference } from '$lib/theme';
|
||||
import {
|
||||
PUBLIC_KEYCLOAK_URL,
|
||||
PUBLIC_KEYCLOAK_REALM
|
||||
} from '$env/static/public';
|
||||
import { version, commit, builtAt } from '$lib/version';
|
||||
|
||||
// Profile state loaded from public.users
|
||||
let displayNameInput = $state('');
|
||||
let language = $state<'en' | 'es'>('en');
|
||||
let language = $state<'en' | 'es' | 'eu'>('en');
|
||||
let avatarType = $state<'initials' | 'emoji' | 'upload'>('initials');
|
||||
let avatarEmoji = $state<string | null>(null);
|
||||
let avatarUrl = $state<string | null>(null);
|
||||
|
||||
let saving = $state(false);
|
||||
let saved = $state(false);
|
||||
|
||||
// Fase 17.2.4 — changelog modal trigger from the About section.
|
||||
let showChangelog = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Cropper
|
||||
let cropperFile = $state<File | null>(null);
|
||||
let uploading = $state(false);
|
||||
|
||||
const EMOJI_GRID = [
|
||||
'😀','😎','🥳','🤓','👻','🐱','🐶','🦊',
|
||||
'🐸','🦁','🐻','🐼','🐨','🐯','🦄','🐧',
|
||||
'🦋','🌈','⭐','🌙','☀️','🌊','🌴','🌺',
|
||||
'🍕','🍦','🎸','🎯','🚀','⚽','🎮','🎨'
|
||||
];
|
||||
// Fase 19 — emoji avatars expansion. The legacy `EMOJI_GRID` hardcoded
|
||||
// array (8 faces + 24 others) is replaced by the tabbed `EmojiPicker`
|
||||
// component which surfaces ~280 codepoints across 4 categories.
|
||||
|
||||
const keycloakAccountUrl = `${PUBLIC_KEYCLOAK_URL}/realms/${PUBLIC_KEYCLOAK_REALM}/account`;
|
||||
|
||||
onMount(async () => {
|
||||
await loadProfile();
|
||||
if ($currentCollective) {
|
||||
await loadTags($currentCollective.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Inline edit state for the tag list (one row at a time).
|
||||
let editingTagId = $state<string | null>(null);
|
||||
let editingTagName = $state('');
|
||||
const TAG_COLORS: ItemTagColor[] = [
|
||||
'slate',
|
||||
'red',
|
||||
'amber',
|
||||
'green',
|
||||
'sky',
|
||||
'indigo',
|
||||
'pink',
|
||||
'stone'
|
||||
];
|
||||
|
||||
function startEditTag(tag: ItemTag) {
|
||||
editingTagId = tag.id;
|
||||
editingTagName = tag.name;
|
||||
}
|
||||
|
||||
async function commitEditTag() {
|
||||
const id = editingTagId;
|
||||
const next = editingTagName.trim();
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
if (id && next) {
|
||||
await renameTag(id, next);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditTag() {
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
}
|
||||
|
||||
async function handleRecolor(tag: ItemTag, color: ItemTagColor) {
|
||||
if (tag.color === color) return;
|
||||
await recolorTag(tag.id, color);
|
||||
}
|
||||
|
||||
async function handleDeleteTag(id: string) {
|
||||
await deleteTag(id);
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
if (!$currentUser) return;
|
||||
|
||||
@@ -51,7 +119,7 @@
|
||||
|
||||
if (data) {
|
||||
displayNameInput = data.display_name;
|
||||
language = data.language as 'en' | 'es';
|
||||
language = data.language as 'en' | 'es' | 'eu';
|
||||
avatarType = data.avatar_type as 'initials' | 'emoji' | 'upload';
|
||||
avatarEmoji = data.avatar_emoji;
|
||||
avatarUrl = data.avatar_url;
|
||||
@@ -132,7 +200,53 @@
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
async function switchLanguage(lang: 'en' | 'es') {
|
||||
// Fase 9.1: persist the chosen theme to public.users.theme so it
|
||||
// follows the user across devices. ThemeToggle has already updated
|
||||
// localStorage + <html data-theme> synchronously — this is purely
|
||||
// the "save my preference server-side" step. Best-effort: a failed
|
||||
// UPDATE does not roll the UI choice back.
|
||||
async function persistTheme(next: ThemePreference) {
|
||||
if (!$currentUser) return;
|
||||
await getSupabase()
|
||||
.from('users')
|
||||
.update({ theme: next })
|
||||
.eq('id', $currentUser.id);
|
||||
}
|
||||
|
||||
// ── Fase 12.4.1 — section visibility toggles ────────────────────────────
|
||||
// The "effective" map is derived from the user + active-collective flags
|
||||
// (see $lib/stores/features). The disabled state of each toggle reflects
|
||||
// whether the collective has explicitly hidden the section — when it has,
|
||||
// the user toggle is moot and we surface that with a one-line hint.
|
||||
function sectionLabel(section: SectionKey): string {
|
||||
if (section === 'lists') return m.section_label_lists();
|
||||
if (section === 'tasks') return m.section_label_tasks();
|
||||
if (section === 'notes') return m.section_label_notes();
|
||||
return m.section_label_search();
|
||||
}
|
||||
|
||||
function userFlag(section: SectionKey): boolean {
|
||||
const flags = ($currentUserFeatures?.feature_flags ?? {}) as FeatureFlags;
|
||||
const v = flags[section];
|
||||
// Default ON when the key is missing or null — matches section_enabled().
|
||||
return v ?? true;
|
||||
}
|
||||
|
||||
function collectiveHidden(section: SectionKey): boolean {
|
||||
const flags = ($currentCollective?.feature_flags ?? {}) as FeatureFlags;
|
||||
return flags[section] === false;
|
||||
}
|
||||
|
||||
async function toggleUserSection(section: SectionKey) {
|
||||
const next = !userFlag(section);
|
||||
try {
|
||||
await setUserFeature(section, next);
|
||||
} catch {
|
||||
// optimistic mutation already rolled back inside the helper
|
||||
}
|
||||
}
|
||||
|
||||
async function switchLanguage(lang: 'en' | 'es' | 'eu') {
|
||||
language = lang;
|
||||
setLanguageTag(lang);
|
||||
await getSupabase()
|
||||
@@ -140,6 +254,93 @@
|
||||
.update({ language: lang })
|
||||
.eq('id', $currentUser!.id);
|
||||
}
|
||||
|
||||
// ── Fase 10.1: leave collective (CU-H06) ────────────────────────────────
|
||||
let leaveCandidate = $state<{ id: string; name: string } | null>(null);
|
||||
let leaveError = $state<string | null>(null);
|
||||
|
||||
function startLeave(c: { id: string; name: string }) {
|
||||
leaveCandidate = c;
|
||||
leaveError = null;
|
||||
}
|
||||
|
||||
function cancelLeave() {
|
||||
leaveCandidate = null;
|
||||
leaveError = null;
|
||||
}
|
||||
|
||||
async function confirmLeave() {
|
||||
if (!leaveCandidate) return;
|
||||
const target = leaveCandidate;
|
||||
const supabase = getSupabase();
|
||||
const { error } = await supabase.rpc('leave_collective', { p_collective_id: target.id });
|
||||
if (error) {
|
||||
// P0001 = sole member → instruct to dissolve instead.
|
||||
leaveError = (error as { code?: string }).code === 'P0001'
|
||||
? m.settings_collectives_dissolve_hint()
|
||||
: error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local stores: drop the collective from userCollectives. If it
|
||||
// was the active one, switch to the next or kick to /onboarding.
|
||||
const remaining = $userCollectives.filter((c) => c.id !== target.id);
|
||||
userCollectives.set(remaining);
|
||||
|
||||
if ($currentCollective?.id === target.id) {
|
||||
if (remaining.length > 0) {
|
||||
const next = remaining[0];
|
||||
currentCollective.set(next);
|
||||
localStorage.setItem('activeCollectiveId', next.id);
|
||||
} else {
|
||||
currentCollective.set(null);
|
||||
localStorage.removeItem('activeCollectiveId');
|
||||
leaveCandidate = null;
|
||||
await goto('/onboarding');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
leaveCandidate = null;
|
||||
}
|
||||
|
||||
// ── Fase 10.5: delete account ───────────────────────────────────────────
|
||||
let showDeleteAccount = $state(false);
|
||||
let deleteConfirmInput = $state('');
|
||||
let deleteAccountError = $state<string | null>(null);
|
||||
let deleteInFlight = $state(false);
|
||||
|
||||
const deleteConfirmWord = m.settings_delete_account_confirm_word();
|
||||
const deleteConfirmReady = $derived(deleteConfirmInput === deleteConfirmWord);
|
||||
|
||||
function openDeleteAccount() {
|
||||
showDeleteAccount = true;
|
||||
deleteConfirmInput = '';
|
||||
deleteAccountError = null;
|
||||
}
|
||||
|
||||
function closeDeleteAccount() {
|
||||
showDeleteAccount = false;
|
||||
deleteConfirmInput = '';
|
||||
deleteAccountError = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteAccount() {
|
||||
if (!deleteConfirmReady || deleteInFlight) return;
|
||||
deleteInFlight = true;
|
||||
deleteAccountError = null;
|
||||
const { error } = await getSupabase().rpc('delete_account');
|
||||
if (error) {
|
||||
deleteInFlight = false;
|
||||
deleteAccountError = (error as { code?: string }).code === 'P0003'
|
||||
? m.settings_delete_account_blocked()
|
||||
: error.message;
|
||||
return;
|
||||
}
|
||||
// GoTrue row gone; clear local + start logout (RP-initiated) so Keycloak
|
||||
// session is also ended. /logged-out is the post-logout landing.
|
||||
await logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -210,16 +411,7 @@
|
||||
</div>
|
||||
|
||||
{#if avatarType === 'emoji'}
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
{#each EMOJI_GRID as e}
|
||||
<button
|
||||
onclick={() => selectEmojiAvatar(e)}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl
|
||||
{avatarEmoji === e ? 'bg-slate-900 dark:bg-slate-100' : 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
aria-label={e}
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<EmojiPicker selected={avatarEmoji} onSelect={selectEmojiAvatar} />
|
||||
{:else if avatarType === 'upload'}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed
|
||||
@@ -232,15 +424,24 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Appearance (Fase 9.1) -->
|
||||
<section>
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_appearance()}
|
||||
</p>
|
||||
<ThemeToggle onChange={persistTheme} />
|
||||
</section>
|
||||
|
||||
<!-- Language -->
|
||||
<section>
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_language()}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#each [['en', 'English'], ['es', 'Español']] as [code, label]}
|
||||
{#each [['en', 'English'], ['es', 'Español'], ['eu', 'Euskera']] as [code, label]}
|
||||
<button
|
||||
onclick={() => switchLanguage(code as 'en' | 'es')}
|
||||
data-testid="settings-language-{code}"
|
||||
onclick={() => switchLanguage(code as 'en' | 'es' | 'eu')}
|
||||
class="rounded-md px-4 py-2 text-sm font-medium transition-colors
|
||||
{language === code
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
@@ -252,6 +453,134 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section visibility (Fase 12.4.1). Per-user opt-out of top-level
|
||||
sections. Collective-level overrides win — when present they
|
||||
disable the toggle and explain why. -->
|
||||
<section data-testid="settings-section-visibility">
|
||||
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_section_visibility_title()}
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-text-secondary">{m.settings_section_visibility_blurb()}</p>
|
||||
<ul class="space-y-1">
|
||||
{#each SECTION_KEYS as section (section)}
|
||||
{@const overridden = collectiveHidden(section)}
|
||||
{@const checked = !overridden && userFlag(section)}
|
||||
<li class="flex items-center justify-between gap-3 rounded-md bg-surface-raised px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-slate-900 dark:text-slate-50">{sectionLabel(section)}</p>
|
||||
{#if overridden}
|
||||
<p class="text-xs text-text-secondary">{m.settings_section_visibility_overridden()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="inline-flex shrink-0 cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="settings-section-toggle-{section}"
|
||||
checked={checked}
|
||||
disabled={overridden}
|
||||
onchange={() => void toggleUserSection(section)}
|
||||
class="h-4 w-4 cursor-pointer accent-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:accent-slate-50"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Fase 9.3.3 — sync conflicts debug panel. Gated on
|
||||
import.meta.env.DEV inside the component itself. -->
|
||||
<SyncConflictsPanel />
|
||||
|
||||
<!-- Collectives (Fase 10.1) -->
|
||||
<section>
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_collectives()}
|
||||
</p>
|
||||
{#if $userCollectives.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.settings_collectives_empty()}</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each $userCollectives as c (c.id)}
|
||||
<li data-testid="collective-row-{c.id}" class="flex items-center gap-3 py-2">
|
||||
<span class="text-lg">{c.emoji}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-slate-900 dark:text-slate-50">{c.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="leave-collective-{c.id}"
|
||||
onclick={() => startLeave(c)}
|
||||
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700
|
||||
hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.settings_collectives_leave()}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Tags (Fase 11.3.6) — collective-scoped tag management. Lives in
|
||||
settings because the tag flow on the list view is per-item; the
|
||||
full list of tags + rename + recolor + delete is admin-style. -->
|
||||
<section data-testid="settings-tags-section" class="pt-8">
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_tags_section()}
|
||||
</p>
|
||||
{#if $tagsStore.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.settings_tags_empty()}</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each $tagsStore as tag (tag.id)}
|
||||
<li
|
||||
data-testid="settings-tag-row"
|
||||
data-tag={tag.name}
|
||||
class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2"
|
||||
>
|
||||
{#if editingTagId === tag.id}
|
||||
<input
|
||||
bind:value={editingTagName}
|
||||
onblur={commitEditTag}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void commitEditTag();
|
||||
} else if (e.key === 'Escape') cancelEditTag();
|
||||
}}
|
||||
class="min-w-0 flex-1 rounded bg-background px-2 py-1 text-sm text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startEditTag(tag)}
|
||||
class="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<TagChip name={tag.name} color={tag.color} />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-1" aria-label={m.settings_tags_color()}>
|
||||
{#each TAG_COLORS as color}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleRecolor(tag, color)}
|
||||
aria-label={color}
|
||||
class="h-4 w-4 rounded-full bg-{color}-500 {tag.color === color ? 'ring-2 ring-offset-1 ring-slate-700 dark:ring-slate-300' : 'opacity-70 hover:opacity-100'}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="settings-tag-delete"
|
||||
onclick={() => handleDeleteTag(tag.id)}
|
||||
class="rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.settings_tags_delete()}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Account — spacing creates the separation, not a border line -->
|
||||
<section class="pt-8">
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
@@ -272,6 +601,43 @@
|
||||
{m.settings_logout()}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- About (Fase 14.3.4) — version / commit / build-time chip.
|
||||
Fase 17.2.4 adds a "Ver historial" trigger that opens the
|
||||
ChangelogModal with the bundled CHANGELOG.md content. -->
|
||||
<section class="pt-8" data-testid="settings-about">
|
||||
<p class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_about()}
|
||||
</p>
|
||||
<p class="text-xs text-text-secondary" data-testid="settings-about-version">
|
||||
{m.settings_about_version({ version, commit, date: builtAt.slice(0, 10) })}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="settings-about-view-changelog"
|
||||
onclick={() => (showChangelog = true)}
|
||||
class="mt-1 text-xs text-text-secondary underline underline-offset-2 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
{m.settings_about_view_changelog()}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Danger zone (Fase 10.5) -->
|
||||
<section class="pt-8">
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
|
||||
{m.settings_danger_zone()}
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-text-secondary">{m.settings_delete_account_blurb()}</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="delete-account-open"
|
||||
onclick={openDeleteAccount}
|
||||
class="rounded-md border border-destructive px-4 py-2 text-sm font-semibold text-destructive
|
||||
hover:bg-destructive/10"
|
||||
>
|
||||
{m.settings_delete_account()}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,3 +649,83 @@
|
||||
onCancel={() => (cropperFile = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Leave-collective confirmation modal (Fase 10.1) -->
|
||||
{#if leaveCandidate}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="leave-modal">
|
||||
<div class="w-full max-w-sm rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
|
||||
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.settings_collectives_confirm_leave_title({ name: leaveCandidate.name })}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-text-secondary">{m.settings_collectives_confirm_leave_body()}</p>
|
||||
{#if leaveError}
|
||||
<p class="mb-3 text-sm text-destructive" data-testid="leave-error">{leaveError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelLeave}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-leave-collective"
|
||||
onclick={confirmLeave}
|
||||
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
{m.settings_collectives_confirm_leave_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete-account modal (Fase 10.5) -->
|
||||
{#if showDeleteAccount}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" data-testid="delete-account-modal">
|
||||
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
|
||||
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.settings_delete_account_modal_title()}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-text-secondary">{m.settings_delete_account_modal_body()}</p>
|
||||
<label for="delete-account-input" class="mb-1 block text-xs font-medium text-text-secondary">
|
||||
{m.settings_delete_account_confirm_label({ word: deleteConfirmWord })}
|
||||
</label>
|
||||
<input
|
||||
id="delete-account-input"
|
||||
data-testid="delete-account-confirm-input"
|
||||
type="text"
|
||||
bind:value={deleteConfirmInput}
|
||||
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
||||
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
{#if deleteAccountError}
|
||||
<p class="mb-3 text-sm text-destructive" data-testid="delete-account-error">{deleteAccountError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeDeleteAccount}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-delete-account"
|
||||
disabled={!deleteConfirmReady || deleteInFlight}
|
||||
onclick={confirmDeleteAccount}
|
||||
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{m.settings_delete_account_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fase 17.2.4 — changelog modal. Rendered last so it stacks above
|
||||
every other modal on the page (delete-account, leave-collective). -->
|
||||
<ChangelogModal open={showChangelog} onClose={() => (showChangelog = false)} />
|
||||
|
||||
155
apps/web/src/routes/(app)/settings/sync-conflicts/+page.svelte
Normal file
155
apps/web/src/routes/(app)/settings/sync-conflicts/+page.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Fase 14.2.3 — sync conflicts review page.
|
||||
*
|
||||
* Tabular surface for the `sync_conflicts` log scoped to the current
|
||||
* user. Each row pairs the local + remote JSON payloads side-by-side
|
||||
* and exposes two dismiss actions:
|
||||
*
|
||||
* - "Discard local" → accept the remote_won outcome (no-op
|
||||
* server-side: the remote version already won).
|
||||
* Hides the row from view via a localStorage
|
||||
* dismissed-set so the user sees a clean log.
|
||||
* - "Discard remote" → same hide-from-view semantics but recorded
|
||||
* separately for analytics. The MVP does NOT
|
||||
* re-apply the local payload to the entity —
|
||||
* last-write-wins is permanent at the data
|
||||
* layer (see plan §14.scope: "no CRDT").
|
||||
*
|
||||
* Why localStorage and not a DB UPDATE: migration 016 marks
|
||||
* sync_conflicts as append-only at the RLS layer (no UPDATE/DELETE
|
||||
* policies). Fase 14 carries no DB migration. Dismissal is per-device,
|
||||
* which is acceptable for an informational log.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type ConflictRow = {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
local_version: unknown;
|
||||
remote_version: unknown;
|
||||
resolution: 'remote_won' | 'local_won';
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const DISMISSED_KEY = 'syncConflictsDismissed';
|
||||
|
||||
let rows = $state<ConflictRow[]>([]);
|
||||
let loaded = $state(false);
|
||||
let dismissed = $state<Set<string>>(new Set());
|
||||
|
||||
onMount(async () => {
|
||||
// Hydrate dismissed-set from localStorage. Safe on SSR — onMount
|
||||
// only runs in the browser.
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||
if (raw) dismissed = new Set(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
/* corrupt localStorage — start fresh */
|
||||
}
|
||||
|
||||
if (!$currentUser) {
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
const { data } = await getSupabase()
|
||||
.from('sync_conflicts')
|
||||
.select('id, entity_type, entity_id, local_version, remote_version, resolution, created_at')
|
||||
.eq('user_id', $currentUser.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100);
|
||||
rows = (data ?? []) as ConflictRow[];
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
const visible = $derived(rows.filter((r) => !dismissed.has(r.id)));
|
||||
|
||||
function persistDismissed() {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify(Array.from(dismissed)));
|
||||
} catch {
|
||||
/* quota / private mode — best effort */
|
||||
}
|
||||
}
|
||||
|
||||
function discard(id: string) {
|
||||
const next = new Set(dismissed);
|
||||
next.add(id);
|
||||
dismissed = next;
|
||||
persistDismissed();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<header class="px-8 pb-4 pt-8">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sync-conflicts-back"
|
||||
onclick={() => goto('/settings')}
|
||||
class="mb-2 text-xs text-text-secondary hover:underline"
|
||||
>
|
||||
← {m.sync_conflicts_back()}
|
||||
</button>
|
||||
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
||||
{m.sync_conflicts_title()}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-8 py-6">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
{#if loaded && visible.length === 0}
|
||||
<p class="text-sm text-text-secondary" data-testid="sync-conflicts-route-empty">
|
||||
{m.sync_conflicts_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each visible as row (row.id)}
|
||||
<li
|
||||
data-testid="sync-conflicts-route-row"
|
||||
data-id={row.id}
|
||||
class="rounded-md border border-slate-200 bg-surface p-3 text-xs dark:border-slate-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between text-text-secondary">
|
||||
<span class="font-mono">{row.entity_type}</span>
|
||||
<span>{new Date(row.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p class="text-text-muted">local</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(row.local_version, null, 0)}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-text-muted">remote ({row.resolution})</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(row.remote_version, null, 0)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sync-conflicts-discard-local"
|
||||
onclick={() => discard(row.id)}
|
||||
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.sync_conflicts_discard_local()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sync-conflicts-discard-remote"
|
||||
onclick={() => discard(row.id)}
|
||||
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.sync_conflicts_discard_remote()}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,113 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentUser, authLoading } from '$lib/stores/auth';
|
||||
import { userCollectives, currentCollective } from '$lib/stores/collective';
|
||||
import {
|
||||
loadCurrentUserFeatures,
|
||||
subscribeCollectiveFeatures,
|
||||
teardownFeatureSubscriptions
|
||||
} from '$lib/stores/features';
|
||||
import { refreshServerAdminFlag, clearServerAdminFlag } from '$lib/stores/serverAdmin';
|
||||
import type { FeatureFlags } from '@colectivo/types';
|
||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||
import { i18n } from '$lib/i18n';
|
||||
import { initTheme } from '$lib/theme';
|
||||
import { setLanguageTag } from '$lib/paraglide/runtime';
|
||||
import { detectLanguage } from '$lib/utils/accept-language';
|
||||
import UpdateToast from '$lib/components/UpdateToast.svelte';
|
||||
|
||||
// Fase 14.1: stores driven by `useRegisterSW()` (or by a test stub
|
||||
// injected via window.__pwaRegisterStub). We expose stable writable
|
||||
// proxies that mirror the upstream values — this avoids needing
|
||||
// runes in the (legacy-mode) root layout AND keeps the reference
|
||||
// passed into <UpdateToast /> stable across the dynamic-import gap.
|
||||
const needRefresh: Writable<boolean> = writable(false);
|
||||
const offlineReady: Writable<boolean> = writable(false);
|
||||
let pwaReload: () => void | Promise<void> = () => {};
|
||||
|
||||
onMount(() => {
|
||||
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
|
||||
// not auto-register — the virtual module is a no-op unless called.
|
||||
void import('virtual:pwa-register').then(({ registerSW }) => {
|
||||
registerSW({ immediate: true });
|
||||
// Fase 9.1: hydrate the theme stores from localStorage and start
|
||||
// listening for OS prefers-color-scheme changes. The inline script
|
||||
// in app.html has already set <html data-theme> synchronously to
|
||||
// avoid FOUC — initTheme just builds the JS-side state on top.
|
||||
initTheme();
|
||||
|
||||
// Dev-only: expose the supabase client on window so Playwright tests
|
||||
// can drive PostgREST through it without re-creating a parallel
|
||||
// client. Production never sees this (import.meta.env.DEV is
|
||||
// dead-code-eliminated by Vite at build time).
|
||||
if (import.meta.env.DEV) {
|
||||
(window as unknown as { __sb?: unknown }).__sb = getSupabase();
|
||||
}
|
||||
|
||||
// Fase 14.1: register the PWA service worker via the Svelte-flavoured
|
||||
// virtual module so we get writable stores for needRefresh /
|
||||
// offlineReady (UpdateToast subscribes to them).
|
||||
//
|
||||
// Note on `updateViaCache: 'none'`: the plan asks for this on the
|
||||
// registration. `vite-plugin-pwa` 0.21.2's RegisterSWOptions doesn't
|
||||
// expose it (workbox-window 7 doesn't either). Mitigated below by an
|
||||
// explicit `reg.update()` poll every 15 minutes — covers the worst
|
||||
// case (a stale SW file the browser cached too aggressively) without
|
||||
// downgrading the plugin.
|
||||
//
|
||||
// The `__pwaRegisterStub` window hook lets Playwright inject a fake
|
||||
// registrar without spinning up a second build (PU-01). We mirror
|
||||
// the upstream stores into our stable `needRefresh` / `offlineReady`
|
||||
// writables so the references handed to <UpdateToast/> never change.
|
||||
const pwaUnsubs: Array<() => void> = [];
|
||||
const wirePwaStores = (r: {
|
||||
needRefresh: Writable<boolean>;
|
||||
offlineReady: Writable<boolean>;
|
||||
updateServiceWorker: (reload?: boolean) => Promise<void>;
|
||||
}) => {
|
||||
pwaReload = () => r.updateServiceWorker(true);
|
||||
pwaUnsubs.push(r.needRefresh.subscribe((v) => needRefresh.set(v)));
|
||||
pwaUnsubs.push(r.offlineReady.subscribe((v) => offlineReady.set(v)));
|
||||
};
|
||||
const stub = (window as unknown as {
|
||||
__pwaRegisterStub?: () => {
|
||||
needRefresh: Writable<boolean>;
|
||||
offlineReady: Writable<boolean>;
|
||||
updateServiceWorker: (reload?: boolean) => Promise<void>;
|
||||
};
|
||||
}).__pwaRegisterStub;
|
||||
if (stub) {
|
||||
console.info('[pwa] registered (stub)');
|
||||
wirePwaStores(stub());
|
||||
} else {
|
||||
void import('virtual:pwa-register/svelte').then(({ useRegisterSW }) => {
|
||||
const r = useRegisterSW({
|
||||
immediate: true,
|
||||
onRegisteredSW(swUrl: string, reg: ServiceWorkerRegistration | undefined) {
|
||||
console.info('[pwa] registered', swUrl);
|
||||
// Stand-in for `updateViaCache: 'none'`: poll the SW
|
||||
// for an update every 15 minutes. Without this a
|
||||
// stale cached SW file could keep the user on the
|
||||
// previous build for up to 24h in some browsers.
|
||||
if (reg) {
|
||||
setInterval(() => {
|
||||
reg.update().catch(() => {
|
||||
/* offline / transient */
|
||||
});
|
||||
}, 15 * 60 * 1000);
|
||||
}
|
||||
},
|
||||
onRegisterError(err: unknown) {
|
||||
console.info('[pwa] register error', err);
|
||||
},
|
||||
onNeedRefresh() {
|
||||
console.info('[pwa] update available');
|
||||
}
|
||||
});
|
||||
wirePwaStores(r);
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
@@ -24,6 +117,10 @@
|
||||
currentUser.set(session?.user ?? null);
|
||||
authLoading.set(false);
|
||||
|
||||
if (!session) {
|
||||
clearServerAdminFlag();
|
||||
}
|
||||
|
||||
if (session) {
|
||||
// Defer out of the onAuthStateChange callback so the internal auth
|
||||
// lock is fully released before we make any PostgREST query.
|
||||
@@ -32,6 +129,26 @@
|
||||
setTimeout(async () => {
|
||||
await loadUserCollectives(session.user.id);
|
||||
|
||||
// Fase 12.2: load the user's feature_flags + subscribe to
|
||||
// realtime updates (own row). Best-effort: a failure here
|
||||
// only degrades to default-ON for every section.
|
||||
try {
|
||||
await loadCurrentUserFeatures(session.user.id);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
// Fase 13.4: refresh the cached `is_server_admin()` flag.
|
||||
// Best-effort — a failure leaves the flag false, which is
|
||||
// the safe default (admin area is gated server-side too).
|
||||
await refreshServerAdminFlag();
|
||||
|
||||
// Fase 10.8: auto-detect language for genuinely new users.
|
||||
// Best-effort, runs after every auth event so newly-seeded
|
||||
// (post-Keycloak-self-registration) rows pick up the user's
|
||||
// browser preference instead of the seed default 'en'.
|
||||
await maybeBootstrapLanguage(session.user.id);
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
// Post-login: send the user where they belong.
|
||||
// Only redirect when coming from the callback or the root — not on
|
||||
@@ -56,24 +173,106 @@
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
// Fase 12.2: keep the active-collective realtime subscription in sync.
|
||||
// Subscribing here (after onAuthStateChange) rather than at the auth
|
||||
// callback level means it follows the user across collective switches
|
||||
// without needing a sign-in event. Tear down on layout destroy too.
|
||||
const collectiveUnsub = currentCollective.subscribe((c) => {
|
||||
if (c) void subscribeCollectiveFeatures(c.id);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
collectiveUnsub();
|
||||
void teardownFeatureSubscriptions();
|
||||
clearServerAdminFlag();
|
||||
for (const u of pwaUnsubs) u();
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Fase 10.8 — Accept-Language detection for first-time users.
|
||||
*
|
||||
* The Paraglide stack is compile-time and the OIDC dance lives outside
|
||||
* SvelteKit, so we can't sniff the real Accept-Language header server-
|
||||
* side here without standing up a hooks.server.ts that the auth flow
|
||||
* never traverses. Instead we use `navigator.languages` (the browser's
|
||||
* Accept-Language proxy) on the client right after the first SIGNED_IN.
|
||||
*
|
||||
* Idempotency: we only UPDATE when the row was just created (within
|
||||
* 60s of now) AND `language` still equals the seed default ('en'). This
|
||||
* means a Spanish-browser user lands in Spanish on first run; an
|
||||
* established user who later switched themselves to English stays
|
||||
* English; existing users are not retroactively re-detected (plan
|
||||
* §10.8.5 — respect their previous explicit choice).
|
||||
*/
|
||||
async function maybeBootstrapLanguage(userId: string): Promise<void> {
|
||||
const supabase = getSupabase();
|
||||
const { data } = await supabase
|
||||
.from('users')
|
||||
.select('language, created_at')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
if (!data) return;
|
||||
|
||||
const ageMs = Date.now() - new Date(data.created_at).getTime();
|
||||
const isFreshAccount = ageMs < 60_000;
|
||||
if (!isFreshAccount) return;
|
||||
if (data.language !== 'en') return;
|
||||
|
||||
const langs =
|
||||
(typeof navigator !== 'undefined' &&
|
||||
(navigator.languages?.length ? Array.from(navigator.languages) : [navigator.language])) ||
|
||||
[];
|
||||
const detected = detectLanguage(langs);
|
||||
if (detected === 'en') return;
|
||||
|
||||
await supabase.from('users').update({ language: detected }).eq('id', userId);
|
||||
setLanguageTag(detected);
|
||||
}
|
||||
|
||||
async function loadUserCollectives(userId: string): Promise<void> {
|
||||
const supabase = getSupabase();
|
||||
|
||||
const { data } = await supabase
|
||||
.from('collective_members')
|
||||
.select('collective_id, role, collectives(id, name, emoji, created_at)')
|
||||
.select(
|
||||
'collective_id, role, collectives(id, name, emoji, created_at, feature_flags, default_list_title)'
|
||||
)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
type CollectiveRow = { id: string; name: string; emoji: string; created_at: string };
|
||||
type CollectiveRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
feature_flags: FeatureFlags;
|
||||
default_list_title: string | null;
|
||||
};
|
||||
const collectives = data
|
||||
.map((row) => {
|
||||
const c = row.collectives as CollectiveRow | null;
|
||||
return c ? { id: c.id, name: c.name, emoji: c.emoji, created_at: c.created_at } : null;
|
||||
const c = row.collectives as
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
feature_flags: FeatureFlags | null;
|
||||
default_list_title: string | null;
|
||||
}
|
||||
| null;
|
||||
return c
|
||||
? {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
emoji: c.emoji,
|
||||
created_at: c.created_at,
|
||||
feature_flags: (c.feature_flags ?? {}) as FeatureFlags,
|
||||
default_list_title: c.default_list_title ?? null
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((c): c is CollectiveRow => c !== null);
|
||||
|
||||
@@ -89,4 +288,5 @@
|
||||
|
||||
<ParaglideJS {i18n}>
|
||||
<slot />
|
||||
<UpdateToast {needRefresh} {offlineReady} onReload={pwaReload} />
|
||||
</ParaglideJS>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import EmojiPicker from '$lib/components/EmojiPicker.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type Tab = 'create' | 'join';
|
||||
@@ -14,11 +15,9 @@
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
const EMOJI_OPTIONS = [
|
||||
'🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏨',
|
||||
'🏩', '🏪', '🏫', '🏬', '🏭', '🏯', '🏰', '🗼',
|
||||
'🌆', '🌇', '🌃', '🌉', '🏙️', '⛺', '🌴', '🌵'
|
||||
];
|
||||
// Fase 19 — emoji catalog moved to the shared `EmojiPicker` component
|
||||
// (4 tabs, ~280 codepoints). The legacy 24-item `EMOJI_OPTIONS` array
|
||||
// is gone.
|
||||
|
||||
// Join via invite link
|
||||
let inviteLink = $state('');
|
||||
@@ -48,11 +47,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const collective = data as {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
const collective = {
|
||||
...(data as { id: string; name: string; emoji: string; created_at: string }),
|
||||
// New collective starts with no flags — every section ON by default.
|
||||
feature_flags: {} as import('@colectivo/types').FeatureFlags,
|
||||
// Fase 18: no default list title until an admin sets one.
|
||||
default_list_title: null as string | null
|
||||
};
|
||||
|
||||
userCollectives.update((list) => [...list, collective]);
|
||||
@@ -125,19 +125,10 @@
|
||||
<p class="mb-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{m.onboarding_collective_emoji()}
|
||||
</p>
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
{#each EMOJI_OPTIONS as e}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
||||
{collectiveEmoji === e
|
||||
? 'bg-slate-900 dark:bg-slate-100'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||
onclick={() => (collectiveEmoji = e)}
|
||||
aria-label={e}
|
||||
>{e}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<EmojiPicker
|
||||
selected={collectiveEmoji}
|
||||
onSelect={(e) => (collectiveEmoji = e)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if createError}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
// Fase 11: tag colours are picked at runtime from an 8-preset palette
|
||||
// (see public.item_tags.color CHECK constraint). Tailwind cannot statically
|
||||
// see the class names (they're built from the colour string), so safelist
|
||||
// every (variant × intensity) combination the chip + dot use.
|
||||
const TAG_COLORS = ['slate', 'red', 'amber', 'green', 'sky', 'indigo', 'pink', 'stone'];
|
||||
const tagSafelist = TAG_COLORS.flatMap((c) => [
|
||||
`bg-${c}-100`,
|
||||
`text-${c}-700`,
|
||||
`ring-${c}-200`,
|
||||
`dark:bg-${c}-900/40`,
|
||||
`dark:text-${c}-200`,
|
||||
`dark:ring-${c}-700/50`,
|
||||
`bg-${c}-500`
|
||||
]);
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
darkMode: 'class',
|
||||
safelist: tagSafelist,
|
||||
// Fase 9.1: drive dark mode from <html data-theme="dark"> so the inline
|
||||
// anti-FOUC script in app.html can set the attribute synchronously before
|
||||
// the first paint. Existing `dark:` utilities keep working as-is.
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
49
apps/web/tests/e2e/account-deletion.test.ts
Normal file
49
apps/web/tests/e2e/account-deletion.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* DEL-series: /settings → Delete account (Fase 10.5).
|
||||
*
|
||||
* The full "delete + re-login fails" path needs an ephemeral Keycloak user
|
||||
* because deleting a seed user would break every downstream suite that
|
||||
* assumes Ana/Borja/etc. exist. That round-trip is heavy and brittle; the
|
||||
* pgTAP suite (supabase/tests/013_delete_account.sql) already proves the
|
||||
* DB-side contract exhaustively (cascade, role auto-promote, P0003 guard).
|
||||
*
|
||||
* This suite focuses on the UI contract that the pgTAP can't observe:
|
||||
* * the button is only enabled when the user types the exact word
|
||||
* ("DELETE") into the confirmation input
|
||||
* * the danger-zone block is visible on /settings
|
||||
*
|
||||
* Cleanup: no state changes, no afterAll needed.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { closePool } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Account deletion UI (CU §6.3)', () => {
|
||||
test.afterAll(async () => {
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('DEL-01: confirm button is disabled until the exact word is typed', async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
// Danger zone surface.
|
||||
const opener = page.getByTestId('delete-account-open');
|
||||
await expect(opener).toBeVisible({ timeout: 15_000 });
|
||||
await opener.click();
|
||||
|
||||
const confirm = page.getByTestId('confirm-delete-account');
|
||||
const input = page.getByTestId('delete-account-confirm-input');
|
||||
|
||||
await expect(confirm).toBeDisabled();
|
||||
|
||||
await input.fill('delete'); // lowercase — must be case-sensitive
|
||||
await expect(confirm).toBeDisabled();
|
||||
|
||||
await input.fill('DELETE');
|
||||
await expect(confirm).toBeEnabled();
|
||||
});
|
||||
});
|
||||
307
apps/web/tests/e2e/admin.test.ts
Normal file
307
apps/web/tests/e2e/admin.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* SA-series — Server administration UI (Fase 13.5.2).
|
||||
*
|
||||
* SA-01 Ana (seed admin) sees the /admin link in the sidebar and can reach
|
||||
* /admin/collectives. Borja (member) does NOT see the link, and
|
||||
* visiting /admin directly redirects to /.
|
||||
* SA-02 Admin soft-deletes a throwaway collective via the UI, then restores
|
||||
* it. The status badge flips deleted ↔ active. The audit log page
|
||||
* lists both actions.
|
||||
* SA-03 Admin promotes Carmen to server_admin from /admin/admins; the entry
|
||||
* appears in the list. Then revokes her. Trying to revoke the sole
|
||||
* remaining admin is blocked by the server-side guard and the UI
|
||||
* surfaces an error (without breaking the page).
|
||||
* SA-04 Admin sets a server-level default-section override OFF for "notes"
|
||||
* from /admin/server; a member of the seed collective who had notes
|
||||
* ON locally sees the section disappear from their nav within a
|
||||
* reload (no realtime on server_settings — acceptable; the page
|
||||
* re-evaluates on next mount).
|
||||
*
|
||||
* Idempotency: every test creates its own throwaway collective via direct
|
||||
* SQL through the in-page Supabase client (`__sb`). The afterEach clears it.
|
||||
* Promotion state for Carmen is rolled back via direct DELETE.
|
||||
*/
|
||||
import { test, expect, type Page, type Browser } from '@playwright/test';
|
||||
import { USERS, COLLECTIVE_NAME } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { loginAsAdmin } from '../fixtures/admin-login.js';
|
||||
|
||||
const CARMEN_ID = '33333333-3333-3333-3333-333333333333';
|
||||
const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
|
||||
// Track collectives + extra admins created during the suite so we can clean
|
||||
// them up. The afterAll runs once after the whole file.
|
||||
const throwawayCollectiveIds = new Set<string>();
|
||||
const extraAdminUserIds = new Set<string>([CARMEN_ID]); // Carmen may be promoted in SA-03
|
||||
|
||||
async function withAnaPage<T>(browser: Browser, fn: (page: Page) => Promise<T>): Promise<T> {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await loginAsAdmin(page);
|
||||
return await fn(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function createThrowawayCollective(page: Page, name: string): Promise<string> {
|
||||
await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, {
|
||||
timeout: 5_000
|
||||
});
|
||||
const id = await page.evaluate(async (n) => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
const { data, error } = await supabase.rpc('create_collective', { p_name: n, p_emoji: '🧪' });
|
||||
if (error) throw new Error(error.message);
|
||||
return (data as { id: string }).id;
|
||||
}, name);
|
||||
throwawayCollectiveIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function resetUserAndCollectiveFlags(browser: Browser): Promise<void> {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await loginAsAdmin(page);
|
||||
await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, {
|
||||
timeout: 5_000
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ feature_flags: {} })
|
||||
.eq('id', '11111111-1111-1111-1111-111111111111');
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ feature_flags: {} })
|
||||
.eq('id', '22222222-2222-2222-2222-222222222222');
|
||||
await supabase
|
||||
.from('collectives')
|
||||
.update({ feature_flags: {} })
|
||||
.eq('id', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
|
||||
// Restore server_settings to "no opinion" for every known section
|
||||
// so subsequent suites (e.g. section-visibility SV-02) see a clean
|
||||
// server layer. admin_clear_default_section removes the key from
|
||||
// the JSONB; `true` would NOT be equivalent to "no opinion" — it
|
||||
// would explicitly override any collective OFF.
|
||||
for (const s of ['lists', 'tasks', 'notes', 'search']) {
|
||||
try {
|
||||
await supabase.rpc('admin_clear_default_section', { p_section: s });
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
// Best-effort cleanup. Use Ana's session (admin) so the hard-delete RPC
|
||||
// is callable for any leftover throwaway collectives.
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await loginAsAdmin(page);
|
||||
await page.waitForFunction(() => Boolean((window as unknown as { __sb?: unknown }).__sb), null, {
|
||||
timeout: 5_000
|
||||
});
|
||||
await page.evaluate(
|
||||
async ({ collectives, extraAdmins }) => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
for (const id of collectives) {
|
||||
try {
|
||||
await supabase.rpc('admin_hard_delete_collective', {
|
||||
p_collective_id: id,
|
||||
p_force: true
|
||||
});
|
||||
} catch {
|
||||
/* may already be gone */
|
||||
}
|
||||
}
|
||||
for (const u of extraAdmins) {
|
||||
try {
|
||||
await supabase.rpc('revoke_server_admin', { p_user: u });
|
||||
} catch {
|
||||
/* may not be an admin */
|
||||
}
|
||||
}
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ feature_flags: {} })
|
||||
.neq('id', '00000000-0000-0000-0000-000000000000');
|
||||
await supabase
|
||||
.from('collectives')
|
||||
.update({ feature_flags: {} })
|
||||
.neq('id', '00000000-0000-0000-0000-000000000000');
|
||||
for (const s of ['lists', 'tasks', 'notes', 'search']) {
|
||||
try {
|
||||
await supabase.rpc('admin_clear_default_section', { p_section: s });
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
collectives: Array.from(throwawayCollectiveIds),
|
||||
extraAdmins: Array.from(extraAdminUserIds)
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// Don't mask the real test failure.
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Server administration UI', () => {
|
||||
test('SA-01: Ana sees the admin link + can reach /admin; Borja does not and gets bounced from /admin', async ({
|
||||
browser
|
||||
}) => {
|
||||
// Two full login flows (Ana + Borja) push this past 30s when the dev
|
||||
// DB has accumulated state from prior suites. 60s mirrors SA-04.
|
||||
test.setTimeout(60_000);
|
||||
// Ana
|
||||
await withAnaPage(browser, async (page) => {
|
||||
await expect(page.getByTestId('sidebar-admin-link')).toBeVisible();
|
||||
await page.getByTestId('sidebar-admin-link').click();
|
||||
await expect(page).toHaveURL(/\/admin\/collectives$/);
|
||||
await expect(page.getByTestId('admin-banner')).toBeVisible();
|
||||
await expect(page.getByTestId('admin-collectives-table')).toBeVisible();
|
||||
});
|
||||
|
||||
// Borja — no admin link, direct hit redirects.
|
||||
const borjaCtx = await browser.newContext();
|
||||
const borjaPage = await borjaCtx.newPage();
|
||||
try {
|
||||
await loginAs(borjaPage, USERS.borja);
|
||||
await expect(borjaPage.getByTestId('sidebar-admin-link')).toHaveCount(0);
|
||||
await borjaPage.goto('/admin');
|
||||
// The (admin)/+layout.svelte `$effect` redirects non-admins to /.
|
||||
await expect(borjaPage).toHaveURL(/\/lists$|\/onboarding$|\/$/, { timeout: 10_000 });
|
||||
await expect(borjaPage.getByTestId('admin-banner')).toHaveCount(0);
|
||||
} finally {
|
||||
await borjaCtx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('SA-02: soft-delete + restore round-trip + audit log entries', async ({ browser }) => {
|
||||
await withAnaPage(browser, async (page) => {
|
||||
const cid = await createThrowawayCollective(page, `SA-02 ${Date.now()}`);
|
||||
|
||||
await page.goto('/admin/collectives');
|
||||
await expect(page.getByTestId(`admin-collective-row-${cid}`)).toBeVisible();
|
||||
|
||||
// Soft-delete.
|
||||
await page.getByTestId(`admin-soft-delete-${cid}`).click();
|
||||
await expect(page.getByTestId('admin-soft-delete-modal')).toBeVisible();
|
||||
await page.getByTestId('admin-modal-reason').fill('SA-02 reason');
|
||||
await page.getByTestId('admin-modal-confirm').click();
|
||||
await expect(page.getByTestId('admin-soft-delete-modal')).toHaveCount(0);
|
||||
|
||||
// Status badge flipped.
|
||||
await expect(page.getByTestId(`admin-collective-status-${cid}`)).toContainText(
|
||||
/Soft-deleted|Borrado/i
|
||||
);
|
||||
|
||||
// Restore.
|
||||
await page.getByTestId(`admin-restore-${cid}`).click();
|
||||
await expect(page.getByTestId('admin-restore-modal')).toBeVisible();
|
||||
await page.getByTestId('admin-modal-confirm').click();
|
||||
await expect(page.getByTestId('admin-restore-modal')).toHaveCount(0);
|
||||
await expect(page.getByTestId(`admin-collective-status-${cid}`)).toContainText(
|
||||
/Active|Activo/i
|
||||
);
|
||||
|
||||
// Audit log shows both rows for this target.
|
||||
await page.goto('/admin/audit');
|
||||
await expect(page.getByTestId('admin-audit-table')).toBeVisible();
|
||||
const actions = await page
|
||||
.getByTestId('admin-audit-table')
|
||||
.locator(`tr:has-text("${cid}")`)
|
||||
.allTextContents();
|
||||
const joined = actions.join('\n');
|
||||
expect(joined).toMatch(/soft_delete_collective/);
|
||||
expect(joined).toMatch(/restore_collective/);
|
||||
});
|
||||
});
|
||||
|
||||
test('SA-03: promote + revoke another admin; sole-admin revoke is blocked', async ({
|
||||
browser
|
||||
}) => {
|
||||
await withAnaPage(browser, async (page) => {
|
||||
await page.goto('/admin/admins');
|
||||
|
||||
// Promote Carmen.
|
||||
await page.getByTestId('admin-promote-open').click();
|
||||
await expect(page.getByTestId('admin-promote-modal')).toBeVisible();
|
||||
await page.getByTestId('admin-promote-email').fill(USERS.carmen.email);
|
||||
await page.getByTestId('admin-modal-confirm').click();
|
||||
await expect(page.getByTestId('admin-promote-modal')).toHaveCount(0);
|
||||
await expect(page.getByTestId(`admin-admin-row-${CARMEN_ID}`)).toBeVisible();
|
||||
|
||||
// Revoke Carmen — succeeds (Ana remains).
|
||||
await page.getByTestId(`admin-revoke-${CARMEN_ID}`).click();
|
||||
await expect(page.getByTestId(`admin-admin-row-${CARMEN_ID}`)).toHaveCount(0);
|
||||
|
||||
// Now Ana is the sole admin. The UI replaces the Revoke button with
|
||||
// "you (cannot revoke yourself while sole admin)".
|
||||
const anaRow = page.getByTestId(`admin-admin-row-${USERS.ana.id}`);
|
||||
await expect(anaRow).toContainText(/cannot revoke yourself|auto-revocarte/i);
|
||||
await expect(page.getByTestId(`admin-revoke-${USERS.ana.id}`)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('SA-04: server-level OFF for notes wins over collective ON', async ({ browser }) => {
|
||||
// Two full login flows + a /lists hydration push this past the 30s default
|
||||
// under accumulated dev-DB load. 60s is comfortable headroom; the test
|
||||
// runs in ~7s on a clean DB.
|
||||
test.setTimeout(60_000);
|
||||
await withAnaPage(browser, async (page) => {
|
||||
// Make sure collective has notes ON explicitly (admin override).
|
||||
await page.evaluate(async () => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
await supabase
|
||||
.from('collectives')
|
||||
.update({ feature_flags: { notes: true } })
|
||||
.eq('id', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
|
||||
});
|
||||
|
||||
// Set server-level notes OFF.
|
||||
await page.goto('/admin/server');
|
||||
await expect(page.getByTestId('admin-server-sections')).toBeVisible();
|
||||
await page.getByTestId('admin-server-off-notes').click();
|
||||
await expect(page.getByTestId('admin-server-section-state-notes')).toContainText(/OFF/i);
|
||||
});
|
||||
|
||||
// Borja (member of seed collective) loads /lists; the Notes entry must
|
||||
// not be in his nav. There's no realtime on server_settings — a fresh
|
||||
// page load is the contract.
|
||||
const borjaCtx = await browser.newContext();
|
||||
const borjaPage = await borjaCtx.newPage();
|
||||
try {
|
||||
await loginAs(borjaPage, USERS.borja);
|
||||
await borjaPage.goto('/lists');
|
||||
await expect(borjaPage.getByTestId('desktop-sidebar')).toBeVisible();
|
||||
// The sidebar nav anchor for notes carries `data-section="notes"`.
|
||||
await expect(
|
||||
borjaPage.getByTestId('desktop-sidebar').locator('a[data-section="notes"]')
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await borjaCtx.close();
|
||||
}
|
||||
|
||||
await resetUserAndCollectiveFlags(browser);
|
||||
});
|
||||
});
|
||||
46
apps/web/tests/e2e/changelog.test.ts
Normal file
46
apps/web/tests/e2e/changelog.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* CL-series — Fase 17.4.2 (changelog modal on /settings).
|
||||
*
|
||||
* CL-01: clicking "Ver historial" in the About section opens the modal
|
||||
* and the [v0.0.0-beta] heading is visible.
|
||||
* CL-02: Escape closes the modal.
|
||||
*
|
||||
* The CHANGELOG content is bundled at build time via ?raw, so these
|
||||
* assertions exercise the production load path (no fetch, no SSR).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
test.describe('Changelog modal', () => {
|
||||
test('CL-01: opens from /settings About section and shows beta heading', async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
const trigger = page.getByTestId('settings-about-view-changelog');
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
|
||||
const modal = page.getByTestId('changelog-modal');
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// The CHANGELOG header is parsed into an <h1 class="...">Changelog</h1>
|
||||
// inside the body — the beta release heading is an <h2> with the
|
||||
// literal string "[v0.0.0-beta] — 2026-05-19 — MVP + MVP2 (Fases 0–16)".
|
||||
const body = page.getByTestId('changelog-modal-body');
|
||||
await expect(body.getByRole('heading', { level: 1, name: 'Changelog' })).toBeVisible();
|
||||
await expect(body.getByText(/v0\.0\.0-beta/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('CL-02: Escape closes the modal', async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
await page.getByTestId('settings-about-view-changelog').click();
|
||||
const modal = page.getByTestId('changelog-modal');
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(modal).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
296
apps/web/tests/e2e/common-items.test.ts
Normal file
296
apps/web/tests/e2e/common-items.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* CI-series — Common items management (Fase 15).
|
||||
*
|
||||
* CI-01 Ana boosts "milk" → in /lists/<id> the dropdown shows it first
|
||||
* even though seeded use_counts put other items higher.
|
||||
* CI-02 Ana adds "pan" to the active list → the dropdown stops including
|
||||
* it on the next keystroke.
|
||||
* CI-03 Borja (member) opens /collective/manage → he sees the table but
|
||||
* the action buttons are disabled.
|
||||
* CI-04 Ana purges a row → the row disappears from the table and the
|
||||
* dropdown stops showing it.
|
||||
*
|
||||
* Note: CI-02 lives in this file but exercises the /lists/[id] page, not
|
||||
* /collective/manage — it pairs with the exclude-on-list wiring landing
|
||||
* in the next commit (15.4). It is listed here because it is part of the
|
||||
* Fase-15 contract.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
const PREFIX = 'ci-e2e-';
|
||||
|
||||
/**
|
||||
* Reset every item_frequency row this suite touched, and clear any boost
|
||||
* we left on a seeded name. Runs via the page's `__sb` hook (service-role
|
||||
* not exposed in the browser, so we go through Ana — who is admin in the
|
||||
* seed collective — and the RPCs gate accordingly).
|
||||
*/
|
||||
async function resetCommonItems(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
|
||||
null,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
await page.evaluate(
|
||||
async ({ collectiveId }) => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
// Purge every name this suite is known to mutate. Direct table
|
||||
// deletes are blocked by the deny-all RLS from migration 006;
|
||||
// the admin RPC is the only delete path available to the browser.
|
||||
for (const name of ['ci02alpha', 'ci02beta']) {
|
||||
try {
|
||||
await supabase.rpc('purge_item_frequency', {
|
||||
p_collective_id: collectiveId,
|
||||
p_name: name
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Reset weight on seeded names this suite mutates so a re-run starts
|
||||
// from a known place.
|
||||
for (const name of ['milk', 'bread', 'eggs', 'apples', 'butter', 'yogurt', 'coffee']) {
|
||||
try {
|
||||
await supabase.rpc('set_item_frequency_weight', {
|
||||
p_collective_id: collectiveId,
|
||||
p_name: name,
|
||||
p_weight: 0
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
{ collectiveId: SEED_COLLECTIVE_ID }
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Common items management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await resetCommonItems(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
try {
|
||||
await resetCommonItems(page);
|
||||
} catch {
|
||||
/* don't mask original failure */
|
||||
}
|
||||
});
|
||||
|
||||
test('CI-01: Ana boosts "yogurt" — dropdown surfaces it ahead of higher-count items', async ({
|
||||
page
|
||||
}) => {
|
||||
// Use a freshly-created list. The seed list is shared with the entire
|
||||
// suite and tends to be polluted with hundreds of test items, which
|
||||
// shifts the dropdown selection (other items end up with high
|
||||
// use_counts from previous runs).
|
||||
|
||||
await page.goto('/collective/manage');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const yogurtRow = page.getByTestId('common-item-row-yogurt');
|
||||
await expect(yogurtRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await yogurtRow.getByTestId('common-item-boost-yogurt').click();
|
||||
await expect(yogurtRow).toHaveAttribute('data-weight-state', 'boost', { timeout: 5_000 });
|
||||
|
||||
// Create an isolated empty list so the seed-pollution doesn't fill the
|
||||
// dropdown with hundreds of other names.
|
||||
const listId = await page.evaluate(async () => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
const { data } = await supabase
|
||||
.from('shopping_lists')
|
||||
.insert({
|
||||
collective_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
name: `ci-01 list ${Date.now()}`,
|
||||
created_by: '11111111-1111-1111-1111-111111111111'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
return data?.id ?? '';
|
||||
});
|
||||
|
||||
await page.goto(`/lists/${listId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const strip = page.getByTestId('item-suggestions-strip');
|
||||
await expect(strip).toBeVisible({ timeout: 10_000 });
|
||||
// Yogurt now leads the dropdown thanks to its boost (weight=50).
|
||||
const firstChip = strip.locator('button').first();
|
||||
await expect(firstChip).toHaveText('yogurt');
|
||||
|
||||
// Cleanup
|
||||
await page.evaluate(
|
||||
async ({ id }) => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
await supabase.from('shopping_lists').delete().eq('id', id);
|
||||
},
|
||||
{ id: listId }
|
||||
);
|
||||
});
|
||||
|
||||
test('CI-03: Borja sees the table read-only with disabled actions', async ({
|
||||
browser
|
||||
}) => {
|
||||
// Seed the data as Ana first so there's something for Borja to see.
|
||||
// (Default seed already has milk/bread/etc.)
|
||||
const borjaCtx = await browser.newContext();
|
||||
const borja = await borjaCtx.newPage();
|
||||
try {
|
||||
await loginAs(borja, USERS.borja);
|
||||
await borja.goto('/collective/manage');
|
||||
await borja.waitForLoadState('networkidle');
|
||||
|
||||
const milkRow = borja.getByTestId('common-item-row-milk');
|
||||
await expect(milkRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Hide / Normal / Boost buttons must all be disabled for members.
|
||||
const hideBtn = milkRow.getByTestId('common-item-hide-milk');
|
||||
const normalBtn = milkRow.getByTestId('common-item-normal-milk');
|
||||
const boostBtn = milkRow.getByTestId('common-item-boost-milk');
|
||||
await expect(hideBtn).toBeDisabled();
|
||||
await expect(normalBtn).toBeDisabled();
|
||||
await expect(boostBtn).toBeDisabled();
|
||||
|
||||
// Purge button must be hidden or disabled for members.
|
||||
const purgeBtn = milkRow.getByTestId('common-item-purge-milk');
|
||||
await expect(purgeBtn).toBeDisabled();
|
||||
|
||||
// The "Add common item" button must not appear for members.
|
||||
await expect(borja.getByTestId('common-item-add-button')).toHaveCount(0);
|
||||
} finally {
|
||||
await borjaCtx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('CI-02: items already in the list disappear from the suggestion strip', async ({
|
||||
page
|
||||
}) => {
|
||||
// Create a fresh empty list (the seed "Weekly shop" is shared with the
|
||||
// entire suite and tends to be polluted with hundreds of test items,
|
||||
// which both buries the assertion target and inflates the excludeNames
|
||||
// payload). Seed two item_frequency entries we control completely.
|
||||
const listId = await page.evaluate(async () => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
const { data } = await supabase
|
||||
.from('shopping_lists')
|
||||
.insert({
|
||||
collective_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
name: `ci-02 list ${Date.now()}`,
|
||||
created_by: '11111111-1111-1111-1111-111111111111'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
return data?.id ?? '';
|
||||
});
|
||||
expect(listId).toBeTruthy();
|
||||
|
||||
// Boost both names heavily so they lead the dropdown regardless of
|
||||
// the pollution accumulated in item_frequency from other test runs.
|
||||
await page.evaluate(
|
||||
async ({ collective }) => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
await supabase.rpc('set_item_frequency_weight', {
|
||||
p_collective_id: collective,
|
||||
p_name: 'ci02alpha',
|
||||
p_weight: 100
|
||||
});
|
||||
await supabase.rpc('set_item_frequency_weight', {
|
||||
p_collective_id: collective,
|
||||
p_name: 'ci02beta',
|
||||
p_weight: 100
|
||||
});
|
||||
},
|
||||
{ collective: SEED_COLLECTIVE_ID }
|
||||
);
|
||||
|
||||
await page.goto(`/lists/${listId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const strip = page.getByTestId('item-suggestions-strip');
|
||||
await expect(strip).toBeVisible({ timeout: 10_000 });
|
||||
// Both seeded names appear in the dropdown (list is empty so nothing
|
||||
// is excluded yet).
|
||||
await expect(page.getByTestId('item-suggestion-ci02alpha')).toHaveCount(1);
|
||||
await expect(page.getByTestId('item-suggestion-ci02beta')).toHaveCount(1);
|
||||
|
||||
// Click the ci02alpha chip — the selectSuggestion handler fills the
|
||||
// input and refocuses it; press Enter to submit.
|
||||
await page.getByTestId('item-suggestion-ci02alpha').click();
|
||||
const addForm = page.getByTestId('add-item-form');
|
||||
// The first input inside add-item-form is the name field.
|
||||
await addForm.locator('input').first().press('Enter');
|
||||
|
||||
// excludedItemNames recompute drops ci02alpha from the strip on the
|
||||
// next debounced fetchSuggestions call.
|
||||
await expect
|
||||
.poll(async () => await page.getByTestId('item-suggestion-ci02alpha').count(), {
|
||||
timeout: 10_000
|
||||
})
|
||||
.toBe(0);
|
||||
// ci02beta is still in the list — sanity check the filter isn't over-eager.
|
||||
await expect(page.getByTestId('item-suggestion-ci02beta')).toHaveCount(1);
|
||||
|
||||
// Cleanup. Direct deletes from item_frequency are blocked by RLS — use
|
||||
// the admin purge RPC.
|
||||
await page.evaluate(
|
||||
async ({ id, collective }) => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
await supabase.from('shopping_items').delete().eq('list_id', id);
|
||||
await supabase.from('shopping_lists').delete().eq('id', id);
|
||||
for (const name of ['ci02alpha', 'ci02beta']) {
|
||||
try {
|
||||
await supabase.rpc('purge_item_frequency', {
|
||||
p_collective_id: collective,
|
||||
p_name: name
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
{ id: listId, collective: SEED_COLLECTIVE_ID }
|
||||
);
|
||||
});
|
||||
|
||||
test('CI-04: Ana purges "apples" — row disappears from the table and dropdown', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/collective/manage');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const applesRow = page.getByTestId('common-item-row-apples');
|
||||
await expect(applesRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await applesRow.getByTestId('common-item-purge-apples').click();
|
||||
// Modal confirmation.
|
||||
const confirmBtn = page.getByTestId('common-item-purge-confirm');
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 5_000 });
|
||||
await confirmBtn.click();
|
||||
|
||||
// Row must vanish from the table.
|
||||
await expect(applesRow).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
// Re-seed apples via the existing trigger by NOT adding the item — just
|
||||
// confirm the row is gone from the manage view. The dropdown behaviour
|
||||
// is exercised indirectly by CI-01.
|
||||
});
|
||||
});
|
||||
103
apps/web/tests/e2e/dissolve-collective.test.ts
Normal file
103
apps/web/tests/e2e/dissolve-collective.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* D-series: /collective/manage → dissolve (Fase 10.3, CU-H08).
|
||||
*
|
||||
* ⚠️ This suite must NOT dissolve the seed collective. We create an auxiliary
|
||||
* collective per test, manipulate it, then either let the test dissolve it
|
||||
* (D-01) or clean it up in afterEach.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { closePool, sql } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const aux: { id: string | null } = { id: null };
|
||||
const auxName = `Aux dissolve ${Date.now()}`;
|
||||
|
||||
/**
|
||||
* Create an auxiliary collective owned (created_by) by Borja but with Ana
|
||||
* as the sole admin (Borja is dropped from membership). This lets Ana
|
||||
* dissolve it from the UI without touching the seed.
|
||||
*/
|
||||
async function createAuxCollective(): Promise<string> {
|
||||
const r = await sql(
|
||||
`INSERT INTO public.collectives (name, emoji, created_by)
|
||||
VALUES ($1, '💥', $2) RETURNING id`,
|
||||
[auxName, USERS.borja.id]
|
||||
);
|
||||
const id = r.rows[0].id as string;
|
||||
await sql(
|
||||
`INSERT INTO public.collective_members (collective_id, user_id, role)
|
||||
VALUES ($1, $2, 'admin'), ($1, $3, 'member')`,
|
||||
[id, USERS.ana.id, USERS.borja.id]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.describe('Dissolve collective (CU-H08)', () => {
|
||||
test.afterEach(async () => {
|
||||
if (aux.id) {
|
||||
// Best-effort cleanup if the test didn't dissolve it.
|
||||
await sql('DELETE FROM public.collectives WHERE id = $1', [aux.id]);
|
||||
aux.id = null;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('D-03: Borja (member) does NOT see the dissolve button', async ({ page }) => {
|
||||
aux.id = await createAuxCollective();
|
||||
await loginAs(page, USERS.borja);
|
||||
// Switch active collective to the aux one in localStorage, then reload.
|
||||
await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id);
|
||||
await page.goto('/collective/manage');
|
||||
// Make sure the page is hydrated by waiting on a known member row.
|
||||
await expect(page.getByTestId(`member-row-${USERS.ana.id}`)).toBeVisible({ timeout: 15_000 });
|
||||
// Borja must not see the dissolve button (admin-gated).
|
||||
await expect(page.getByTestId('dissolve-collective-button')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('D-02: dissolve button disabled until input matches collective name exactly', async ({
|
||||
page
|
||||
}) => {
|
||||
aux.id = await createAuxCollective();
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id);
|
||||
await page.goto('/collective/manage');
|
||||
await page.getByTestId('dissolve-collective-button').click();
|
||||
|
||||
const confirm = page.getByTestId('dissolve-confirm-button');
|
||||
await expect(confirm).toBeDisabled();
|
||||
|
||||
await page.getByTestId('dissolve-confirm-input').fill('wrong name');
|
||||
await expect(confirm).toBeDisabled();
|
||||
|
||||
await page.getByTestId('dissolve-confirm-input').fill(auxName);
|
||||
await expect(confirm).toBeEnabled();
|
||||
});
|
||||
|
||||
test('D-01: Ana dissolves auxiliary collective → redirect + row gone', async ({ page }) => {
|
||||
aux.id = await createAuxCollective();
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.evaluate((id) => localStorage.setItem('activeCollectiveId', id), aux.id);
|
||||
await page.goto('/collective/manage');
|
||||
await page.getByTestId('dissolve-collective-button').click();
|
||||
|
||||
await page.getByTestId('dissolve-confirm-input').fill(auxName);
|
||||
await page.getByTestId('dissolve-confirm-button').click();
|
||||
|
||||
// After dissolve we either land on /lists (other collectives remain) or
|
||||
// /onboarding (no collectives). Ana still has the seed one, so /lists.
|
||||
await expect(page).toHaveURL(/\/(lists|onboarding)$/, { timeout: 15_000 });
|
||||
|
||||
const r = await sql('SELECT count(*)::int AS n FROM public.collectives WHERE id = $1', [
|
||||
aux.id
|
||||
]);
|
||||
expect(r.rows[0].n).toBe(0);
|
||||
// Test cleanup already happened via cascade.
|
||||
aux.id = null;
|
||||
});
|
||||
});
|
||||
116
apps/web/tests/e2e/emoji-picker.test.ts
Normal file
116
apps/web/tests/e2e/emoji-picker.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* EP-E series — Fase 19.4.3.
|
||||
*
|
||||
* The avatar / collective-icon picker on /settings, /onboarding,
|
||||
* /collective/manage and inside CreateCollectiveModal is the new
|
||||
* `<EmojiPicker>` component (4 tabs, ~280 codepoints). These specs
|
||||
* drive the new control end-to-end on the two primary surfaces.
|
||||
*
|
||||
* EP-E-01 — Ana on /settings switches to the avatar=emoji mode, opens
|
||||
* the picker, jumps to the "Comida" tab, picks 🍎, and the
|
||||
* `users.avatar_emoji` row + the on-page Avatar both update.
|
||||
* EP-E-02 — Eva creates her first collective on /onboarding using a
|
||||
* cell from the "Animales" tab — verifies the cross-tab
|
||||
* selection makes it into `collectives.emoji` and that the
|
||||
* sidebar renders the chosen emoji.
|
||||
*
|
||||
* Cleanup: settings test restores Ana's avatar back to initials so
|
||||
* downstream avatar specs don't see a stale 🍎.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { resetEva } from '../fixtures/db.js';
|
||||
import { closePool, sql } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('EmojiPicker integration (Fase 19)', () => {
|
||||
test.afterAll(async () => {
|
||||
// Reset Ana's avatar back to initials so the rest of the suite (and
|
||||
// avatar-related visuals) see the default.
|
||||
await sql(
|
||||
`UPDATE public.users SET avatar_type = 'initials', avatar_emoji = NULL WHERE id = $1`,
|
||||
[USERS.ana.id]
|
||||
);
|
||||
await resetEva();
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('EP-E-01: /settings — Ana picks 🍎 from the "Comida" tab', async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
// Switch the avatar mode to "Emoji" so the picker mounts.
|
||||
const emojiModeBtn = page.getByRole('button', { name: /Emoji/i }).first();
|
||||
await expect(emojiModeBtn).toBeVisible({ timeout: 15_000 });
|
||||
await emojiModeBtn.click();
|
||||
|
||||
// Picker root + tabs are visible.
|
||||
const picker = page.getByTestId('emoji-picker');
|
||||
await expect(picker).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Jump to "Comida" tab and pick 🍎.
|
||||
await page.getByTestId('emoji-picker-tab-food').click();
|
||||
await expect(page.getByTestId('emoji-picker-grid')).toHaveAttribute(
|
||||
'data-category',
|
||||
'food'
|
||||
);
|
||||
|
||||
await page.getByTestId('emoji-picker-cell-🍎').click();
|
||||
|
||||
// DB row reflects the change (poll because the UPDATE fires after
|
||||
// onSelect lands in the Svelte runloop).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const r = await sql(
|
||||
'SELECT avatar_type, avatar_emoji FROM public.users WHERE id = $1',
|
||||
[USERS.ana.id]
|
||||
);
|
||||
return r.rows[0] ?? null;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toMatchObject({ avatar_type: 'emoji', avatar_emoji: '🍎' });
|
||||
});
|
||||
|
||||
test('EP-E-02: /onboarding — Eva creates a collective with 🐱 from "Animales"', async ({
|
||||
page
|
||||
}) => {
|
||||
await resetEva();
|
||||
await loginAs(page, USERS.eva, { waitForCollective: false });
|
||||
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
|
||||
|
||||
const name = `Eva animals ${Date.now()}`;
|
||||
await page.getByTestId('collective-name-input').fill(name);
|
||||
|
||||
// Open the picker (it's mounted inline on /onboarding, no toggle).
|
||||
await expect(page.getByTestId('emoji-picker')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Switch to the "Animales" tab and pick 🐱.
|
||||
await page.getByTestId('emoji-picker-tab-animals').click();
|
||||
await expect(page.getByTestId('emoji-picker-grid')).toHaveAttribute(
|
||||
'data-category',
|
||||
'animals'
|
||||
);
|
||||
await page.getByTestId('emoji-picker-cell-🐱').click();
|
||||
|
||||
await page.getByTestId('collective-submit').click();
|
||||
await expect(page).toHaveURL(/\/lists$/, { timeout: 15_000 });
|
||||
|
||||
const activeId = await page.evaluate(() => localStorage.getItem('activeCollectiveId'));
|
||||
expect(activeId).toBeTruthy();
|
||||
const res = await sql('SELECT name, emoji FROM public.collectives WHERE id = $1', [
|
||||
activeId
|
||||
]);
|
||||
expect(res.rows[0].name).toBe(name);
|
||||
expect(res.rows[0].emoji).toBe('🐱');
|
||||
|
||||
// Sidebar reflects it.
|
||||
const sidebar = page.getByTestId('desktop-sidebar');
|
||||
await expect(sidebar.getByText('🐱', { exact: false })).toBeVisible({
|
||||
timeout: 10_000
|
||||
});
|
||||
});
|
||||
});
|
||||
137
apps/web/tests/e2e/euskera.test.ts
Normal file
137
apps/web/tests/e2e/euskera.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* EU-series — Fase 20.4.3.
|
||||
*
|
||||
* Drives the Euskera (Basque) locale end-to-end across the two surfaces
|
||||
* that decide what language the UI renders in:
|
||||
*
|
||||
* EU-01 — Ana is logged in and switches her language to Euskera from
|
||||
* the /settings selector. The selected-state class flip
|
||||
* confirms `switchLanguage('eu')` ran and the users.language
|
||||
* row reflects 'eu'. (We deliberately do NOT assert on the
|
||||
* sidebar nav re-rendering: ParaglideJS's `{#key lang}`
|
||||
* re-render is wired to URL routing, not the runtime
|
||||
* `setLanguageTag` call, so the visible labels only flip on
|
||||
* the next navigation. That's a known cross-cutting limitation
|
||||
* outside Fase 20's scope.)
|
||||
*
|
||||
* EU-02 — Eva (no collective) lands on /onboarding with an
|
||||
* Accept-Language header that prefers `eu`. The first-sign-in
|
||||
* bootstrap (Fase 10.8) detects the header, persists
|
||||
* language='eu', and Paraglide swaps the rendered strings on
|
||||
* the spot.
|
||||
*
|
||||
* Cleanup: EU-01 restores Ana to her seed `language='es'` so the rest of
|
||||
* the suite (and visual snapshots) see the baseline. EU-02 leans on the
|
||||
* shared resetEva fixture.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { resetEva } from '../fixtures/db.js';
|
||||
import { closePool, sql } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Euskera (eu) locale (Fase 20)', () => {
|
||||
test.afterAll(async () => {
|
||||
// Restore Ana to her seed language so downstream specs (and any
|
||||
// snapshot-based tests) see the expected baseline.
|
||||
await sql(`UPDATE public.users SET language = 'es' WHERE id = $1`, [USERS.ana.id]);
|
||||
await resetEva();
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('EU-01: Ana switches to Euskera in /settings — nav re-renders, DB updates', async ({
|
||||
page
|
||||
}) => {
|
||||
// Pre-set Ana to 'es' so the test isolates the eu transition rather
|
||||
// than measuring seed state.
|
||||
await sql(`UPDATE public.users SET language = 'es' WHERE id = $1`, [USERS.ana.id]);
|
||||
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
// Confirm the Euskera button exists (verifies the wiring landed).
|
||||
const euButton = page.getByTestId('settings-language-eu');
|
||||
await expect(euButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Switch to Euskera.
|
||||
await euButton.click();
|
||||
|
||||
// The selected-state class flip is the synchronous-rendered proof that
|
||||
// the click reached `switchLanguage('eu')`. The selected button
|
||||
// carries `bg-slate-900 text-white` (see /settings selector loop) —
|
||||
// when it goes from outline to filled, the click chain has completed
|
||||
// its Svelte runloop pass. Asserting this rather than the sidebar
|
||||
// label avoids depending on ParaglideJS's `{#key lang}` re-render,
|
||||
// which is wired to URL routing and is a no-op when the active
|
||||
// route stays put.
|
||||
await expect(euButton).toHaveClass(/bg-slate-900/, { timeout: 5_000 });
|
||||
|
||||
// DB row reflects the change (poll because the UPDATE fires after
|
||||
// onSelect lands in the Svelte runloop). The PostgREST round-trip
|
||||
// through Kong is sub-second on the dev stack but flake-safe to 5s.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const r = await sql('SELECT language FROM public.users WHERE id = $1', [
|
||||
USERS.ana.id
|
||||
]);
|
||||
return r.rows[0]?.language ?? null;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBe('eu');
|
||||
});
|
||||
|
||||
test('EU-02: Eva with Accept-Language: eu → first-sign-in bootstrap renders Euskera', async ({
|
||||
browser
|
||||
}) => {
|
||||
// Bootstrap gate (apps/web/src/routes/+layout.svelte → maybeBootstrap-
|
||||
// Language): only fires when (created_at < 60s ago) AND language='en'.
|
||||
// Eva's seed is `language='en'` (see seed.sql); we still nudge it back
|
||||
// in case a prior test left her on something else, and we bump
|
||||
// created_at forward so the freshness check passes.
|
||||
await sql(
|
||||
`UPDATE public.users SET language = 'en', created_at = now() WHERE id = $1`,
|
||||
[USERS.eva.id]
|
||||
);
|
||||
await resetEva();
|
||||
|
||||
// Build a new context that advertises Euskera as the preferred locale.
|
||||
// `locale` sets navigator.language; `extraHTTPHeaders` covers the
|
||||
// HTTP-level Accept-Language. Together they exercise both arms of
|
||||
// detectLanguage (array + string form).
|
||||
const ctx = await browser.newContext({
|
||||
locale: 'eu-ES',
|
||||
extraHTTPHeaders: { 'Accept-Language': 'eu-ES,eu;q=0.9,en;q=0.5' }
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await loginAs(page, USERS.eva, { waitForCollective: false });
|
||||
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
|
||||
|
||||
// Bootstrap detect + UPDATE happens after SIGNED_IN — poll the DB
|
||||
// until the row is 'eu'. Same 5s budget as EU-01.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const r = await sql('SELECT language FROM public.users WHERE id = $1', [
|
||||
USERS.eva.id
|
||||
]);
|
||||
return r.rows[0]?.language ?? null;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBe('eu');
|
||||
|
||||
// Onboarding strings now render in eu. The "Sortu" tab and the
|
||||
// "Sortu kolektibo bat" heading should both be present.
|
||||
await expect(page.getByRole('heading', { name: /Ongi etorri/i })).toBeVisible({
|
||||
timeout: 10_000
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,19 +102,55 @@ test.describe('Shopping items — member (Borja)', () => {
|
||||
});
|
||||
|
||||
test('D-06: frequency suggestions render below the add-item input', async ({ page }) => {
|
||||
await gotoSeedList(page);
|
||||
// Use a fresh empty list. The seed list is shared mutable state for
|
||||
// the entire suite — it accumulates many items over a long run, and
|
||||
// the Fase-15 exclude-on-list filter then suppresses every frequency
|
||||
// name that happens to match one of those items. A fresh empty list
|
||||
// keeps the suggestion strip's contents deterministic.
|
||||
const listId = await page.evaluate(async () => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
const { data } = await supabase
|
||||
.from('shopping_lists')
|
||||
.insert({
|
||||
collective_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
name: `d-06 list ${Date.now()}`,
|
||||
created_by: '22222222-2222-2222-2222-222222222222'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
return data?.id ?? '';
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`/lists/${listId}`);
|
||||
await expect(page.getByRole('button', { name: /Casa García-López/ })).toBeVisible({
|
||||
timeout: 15_000
|
||||
});
|
||||
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
||||
await expect(nameInput).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Focus the input. The seed data has "milk", "bread", "eggs", etc. in
|
||||
// item_frequency, so suggestion chips should render.
|
||||
// The strip renders the top of item_frequency. The seed has
|
||||
// milk/bread/eggs/etc., and the list is empty so nothing is
|
||||
// excluded — at least one chip must be there.
|
||||
await nameInput.click();
|
||||
// At least one of the seed-frequency items should be visible as a chip.
|
||||
await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId('item-suggestions-strip')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Typing a prefix should narrow suggestions. "mi" matches "milk".
|
||||
// Typing a prefix narrows the chips; the strip still renders.
|
||||
await nameInput.fill('mi');
|
||||
await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 3_000 });
|
||||
await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 5_000 });
|
||||
} finally {
|
||||
await page.evaluate(
|
||||
async ({ id }) => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
await supabase.from('shopping_lists').delete().eq('id', id);
|
||||
},
|
||||
{ id: listId }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
62
apps/web/tests/e2e/leave-collective.test.ts
Normal file
62
apps/web/tests/e2e/leave-collective.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* L-series: /settings → leave collective (Fase 10.1, CU-H06).
|
||||
*
|
||||
* Borja (member of the seed collective) abandons it. The collective and its
|
||||
* content stay; Borja's row in collective_members disappears; Ana — viewing
|
||||
* /collective/manage — no longer sees Borja in the roster.
|
||||
*
|
||||
* Borja is then a no-collective user and is auto-redirected to /onboarding.
|
||||
*
|
||||
* Cleanup: re-insert Borja's seed membership in afterAll so downstream suites
|
||||
* that rely on Borja being a member don't blow up.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Leave collective (CU-H06)', () => {
|
||||
test.afterAll(async () => {
|
||||
// Restore Borja's seed membership so other suites pass.
|
||||
await sql(
|
||||
`INSERT INTO public.collective_members (collective_id, user_id, role)
|
||||
VALUES ($1, $2, 'member')
|
||||
ON CONFLICT (collective_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
[COLLECTIVE_ID, USERS.borja.id]
|
||||
);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('L-01: Borja leaves the seed collective → row gone, lands on onboarding', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
await page.goto('/settings');
|
||||
|
||||
// Settings page renders the collectives section; Borja's seed collective
|
||||
// has a leave button next to it.
|
||||
const leaveButton = page.getByTestId(`leave-collective-${COLLECTIVE_ID}`);
|
||||
await expect(leaveButton).toBeVisible({ timeout: 15_000 });
|
||||
await leaveButton.click();
|
||||
|
||||
// Confirm in the modal.
|
||||
await page.getByTestId('confirm-leave-collective').click();
|
||||
|
||||
// Borja had only one collective; he gets bounced to /onboarding.
|
||||
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
|
||||
|
||||
// DB-side: membership row is gone, but the collective row + content survive.
|
||||
const m = await sql(
|
||||
'SELECT count(*)::int AS n FROM public.collective_members WHERE collective_id = $1 AND user_id = $2',
|
||||
[COLLECTIVE_ID, USERS.borja.id]
|
||||
);
|
||||
expect(m.rows[0].n).toBe(0);
|
||||
|
||||
const c = await sql('SELECT count(*)::int AS n FROM public.collectives WHERE id = $1', [
|
||||
COLLECTIVE_ID
|
||||
]);
|
||||
expect(c.rows[0].n).toBe(1);
|
||||
});
|
||||
});
|
||||
203
apps/web/tests/e2e/list-title-flow.test.ts
Normal file
203
apps/web/tests/e2e/list-title-flow.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* LTF-series (UI): Shopping list title flow — required title, default prefill,
|
||||
* auto-numbered `#N`, admin catalog CRUD. Fase 18.
|
||||
*
|
||||
* Live Keycloak login per test (no cached storageState). The admin specs run
|
||||
* as Ana; the read-only spec runs as Borja (member). All specs land on a
|
||||
* fresh modal each time, so we don't need to reset DB state between cases —
|
||||
* we tag created rows with a unique timestamp suffix.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
/** Click "New list" on the masthead and wait for the modal to mount. */
|
||||
async function openCreateListModal(page: Page) {
|
||||
await page.getByRole('button', { name: /new list|nueva lista/i }).click();
|
||||
await expect(page.getByTestId('create-list-modal')).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set `collectives.default_list_title` by driving the in-page Supabase
|
||||
* client directly. Faster + more deterministic than driving the manage UI,
|
||||
* which lives in a separate spec (LTF-02 / LTF-04).
|
||||
*/
|
||||
async function setDefaultListTitle(page: Page, title: string) {
|
||||
// Land on any auth-gated route so __sb is exposed and the active
|
||||
// collective is loaded.
|
||||
await page.goto('/lists');
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
|
||||
null,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
await page.evaluate(async (value) => {
|
||||
const sb = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
await sb
|
||||
.from('collectives')
|
||||
.update({ default_list_title: value && value.length > 0 ? value : null })
|
||||
.eq('id', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
|
||||
}, title);
|
||||
// Force a reload so the root layout's `loadUserCollectives` hydrates
|
||||
// `$currentCollective.default_list_title` with the updated value before
|
||||
// the modal opens.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /new list|nueva lista/i })
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
test.describe('Shopping list title flow — admin (Ana)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
});
|
||||
|
||||
test('LTF-01: default_list_title prefills the create modal and submits as-is', async ({
|
||||
page
|
||||
}) => {
|
||||
const defaultTitle = `LTF01 ${Date.now()}`;
|
||||
await setDefaultListTitle(page, defaultTitle);
|
||||
|
||||
// `setDefaultListTitle` already navigates to /lists after the reload.
|
||||
await openCreateListModal(page);
|
||||
|
||||
// Input is prefilled with the configured default.
|
||||
const input = page.getByTestId('create-list-modal-name');
|
||||
await expect(input).toHaveValue(defaultTitle);
|
||||
|
||||
// Submit creates a list with the prefilled name — no `#1` suffix
|
||||
// because the default was never added to the catalog (auto-suffix is
|
||||
// catalog-gated).
|
||||
await page.getByTestId('create-list-modal-submit').click();
|
||||
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
|
||||
// List name lives in an `<input>` on the detail page (inline rename).
|
||||
const titleInput = page.locator('input[aria-label]').filter({
|
||||
has: page.locator(':scope[aria-label*="list name" i], :scope[aria-label*="nombre" i]')
|
||||
});
|
||||
// Fallback assertion that's robust to label changes: assert the input
|
||||
// value directly via locator.inputValue() once the input is mounted.
|
||||
await expect.poll(async () => {
|
||||
const inputs = await page.locator('input').all();
|
||||
for (const inp of inputs) {
|
||||
const v = await inp.inputValue().catch(() => '');
|
||||
if (v === defaultTitle) return v;
|
||||
}
|
||||
return '';
|
||||
}, { timeout: 5_000 }).toBe(defaultTitle);
|
||||
// Silence the unused locator warning — we keep titleInput so the
|
||||
// intent is clear if the inputValue poll is ever rewritten.
|
||||
void titleInput;
|
||||
});
|
||||
|
||||
test('LTF-02: catalog entry drives auto-suffix on the third "Compra" creation', async ({
|
||||
page
|
||||
}) => {
|
||||
const prefix = `LTF02-${Date.now()}`;
|
||||
|
||||
// 1) Add the prefix to the catalog via the manage UI (the path under
|
||||
// test). Then add it to default_list_title via the same UI so the
|
||||
// modal prefills it consistently.
|
||||
await page.goto('/collective/manage');
|
||||
await expect(page.getByTestId('list-titles-add-button')).toBeVisible({
|
||||
timeout: 10_000
|
||||
});
|
||||
await page.getByTestId('list-titles-add-button').click();
|
||||
await expect(page.getByTestId('list-titles-add-modal')).toBeVisible({
|
||||
timeout: 5_000
|
||||
});
|
||||
await page.getByTestId('list-titles-add-name').fill(prefix);
|
||||
await page.getByTestId('list-titles-add-save').click();
|
||||
await expect(page.getByTestId('list-titles-add-modal')).not.toBeVisible({
|
||||
timeout: 5_000
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId(`list-title-row-${prefix}`)
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const defaultInput = page.getByTestId('manage-default-list-title-input');
|
||||
await defaultInput.fill(prefix);
|
||||
await defaultInput.blur();
|
||||
// Wait briefly for the UPDATE round-trip to land.
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
// Helper that drives the modal once: open → submit (prefilled) → wait
|
||||
// for navigation, then bounce back to /lists.
|
||||
async function createOne(): Promise<void> {
|
||||
await page.goto('/lists');
|
||||
await openCreateListModal(page);
|
||||
// The prefill should be the catalog prefix verbatim — the modal
|
||||
// auto-suffixes "#N" on submit when the value matches a catalog
|
||||
// entry exactly without a "#N" already.
|
||||
await expect(page.getByTestId('create-list-modal-name')).toHaveValue(prefix);
|
||||
await page.getByTestId('create-list-modal-submit').click();
|
||||
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
// 1st create → no prior matches → finalName === `${prefix}` (bare;
|
||||
// computeNextNumber returns 1 → but rule 9: still fires auto-suffix
|
||||
// when catalog match holds, so finalName === `${prefix} #1`).
|
||||
await createOne();
|
||||
// 2nd create → prior is `prefix #1` → finalName === `${prefix} #2`.
|
||||
await createOne();
|
||||
// 3rd create → prior set has {#1, #2} → finalName === `${prefix} #3`.
|
||||
await createOne();
|
||||
|
||||
// Visit /lists and confirm the third numbered list exists. We don't
|
||||
// assert the first two by name because the exact #N depends on race
|
||||
// timing of the realtime echoes; what matters is that the catalog
|
||||
// auto-suffix produced unique numbered names and the third one
|
||||
// landed at #3 (no duplicates of bare prefix).
|
||||
await page.goto('/lists');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(`^${prefix} #3$`) })
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('LTF-03: empty input keeps the submit button disabled', async ({ page }) => {
|
||||
// Make sure no default is set so the input opens blank.
|
||||
await setDefaultListTitle(page, '');
|
||||
|
||||
// `setDefaultListTitle` already navigates to /lists after the reload.
|
||||
await openCreateListModal(page);
|
||||
|
||||
const input = page.getByTestId('create-list-modal-name');
|
||||
const submit = page.getByTestId('create-list-modal-submit');
|
||||
|
||||
await expect(input).toHaveValue('');
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
// Typing then erasing should keep the disabled state when only spaces
|
||||
// remain (trim-then-check).
|
||||
await input.fill(' ');
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await input.fill('something');
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Shopping list title flow — member (Borja, read-only)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
});
|
||||
|
||||
test('LTF-04: member sees the section but the controls are disabled', async ({ page }) => {
|
||||
await page.goto('/collective/manage');
|
||||
await expect(page.getByTestId('list-titles-section')).toBeVisible({
|
||||
timeout: 15_000
|
||||
});
|
||||
|
||||
// No "Add title" button for non-admins.
|
||||
await expect(page.getByTestId('list-titles-add-button')).not.toBeVisible();
|
||||
|
||||
// Read-only banner is visible.
|
||||
await expect(page.getByTestId('list-titles-readonly-banner')).toBeVisible();
|
||||
|
||||
// The default-title input renders but is disabled.
|
||||
await expect(page.getByTestId('manage-default-list-title-input')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,6 @@ import { test, expect, type Page } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const PLACEHOLDER_NEW_LIST = /weekly shop|compra semanal/i;
|
||||
|
||||
/** Heading for a list — may render as h2 (featured card) or h3 (grid item). */
|
||||
function listHeading(page: Page, name: string) {
|
||||
return page.getByRole('heading', { name: new RegExp(`^${name}$`) });
|
||||
@@ -44,16 +42,15 @@ async function gotoListsClean(page: Page) {
|
||||
}
|
||||
|
||||
async function createList(page: Page, name: string) {
|
||||
// Fase 18: "New list" now opens a create modal instead of jumping straight
|
||||
// to the detail page with an empty name. Type the name, submit, then
|
||||
// bounce back to /lists so callers see the new card in the grid.
|
||||
await page.getByRole('button', { name: /new list|nueva lista/i }).click();
|
||||
// Lands on /lists/[id]; the editable title input carries the placeholder.
|
||||
await expect(page.getByTestId('create-list-modal')).toBeVisible({ timeout: 5_000 });
|
||||
const input = page.getByTestId('create-list-modal-name');
|
||||
await input.fill(name);
|
||||
await page.getByTestId('create-list-modal-submit').click();
|
||||
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
|
||||
const titleInput = page.getByPlaceholder(PLACEHOLDER_NEW_LIST);
|
||||
await expect(titleInput).toBeVisible({ timeout: 10_000 });
|
||||
await titleInput.fill(name);
|
||||
// Blur + wait for the 500 ms autosave debounce + buffer.
|
||||
await titleInput.blur();
|
||||
await page.waitForTimeout(900);
|
||||
// Navigate back to /lists to give callers a consistent starting state.
|
||||
await page.goto('/lists');
|
||||
await expect(listHeading(page, name)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
@@ -119,16 +116,26 @@ test.describe('Shopping lists — guest (David, read-only)', () => {
|
||||
|
||||
await expect(listHeading(page, 'Weekly shop')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// If the create-list input is even rendered for guests, typing into it
|
||||
// and pressing Enter must NOT create a committed list (RLS blocks the
|
||||
// insert; the optimistic row rolls back).
|
||||
const input = page.getByPlaceholder(PLACEHOLDER_NEW_LIST);
|
||||
if (await input.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
// Fase 18: the entry point is the masthead "New list" button which opens
|
||||
// a modal. Guests CAN open the modal (we don't gate it client-side),
|
||||
// but the actual INSERT is blocked by RLS — `shopping_lists_insert`
|
||||
// requires `is_active_member(collective_id)` which excludes role=guest.
|
||||
// Try to drive the modal and confirm no committed row appears.
|
||||
const newListBtn = page.getByRole('button', { name: /new list|nueva lista/i });
|
||||
if (await newListBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
await newListBtn.click();
|
||||
const modalVisible = await page
|
||||
.getByTestId('create-list-modal')
|
||||
.isVisible({ timeout: 2_000 })
|
||||
.catch(() => false);
|
||||
if (modalVisible) {
|
||||
const name = `David sneaky ${Date.now()}`;
|
||||
await input.fill(name);
|
||||
await input.press('Enter');
|
||||
await page.getByTestId('create-list-modal-name').fill(name);
|
||||
await page.getByTestId('create-list-modal-submit').click();
|
||||
await page.waitForTimeout(1_500);
|
||||
await page.goto('/lists');
|
||||
await expect(listHeading(page, name)).not.toBeVisible({ timeout: 2_000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,68 @@ import { loginAs } from '../fixtures/login.js';
|
||||
const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
|
||||
|
||||
test.describe('Sync conflicts review', () => {
|
||||
test('OF-02: a planted sync_conflicts row surfaces the banner + lists on /settings/sync-conflicts, and Discard removes the row', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
await page.goto('/lists');
|
||||
// Wait for the layout's conflict probe to run + complete (it's
|
||||
// deferred via setTimeout(0)).
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
|
||||
null,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
// sync_conflicts is append-only at the RLS layer (migration 016 has
|
||||
// no DELETE policy). Instead of deleting, we pre-dismiss every
|
||||
// existing row in localStorage so the route + banner reflect only
|
||||
// the row we plant in this test.
|
||||
await page.evaluate(async () => {
|
||||
const sb = (window as unknown as {
|
||||
__sb: {
|
||||
auth: { getUser: () => Promise<{ data: { user: { id: string } | null } }> };
|
||||
from: (t: string) => {
|
||||
select: (c: string) => { eq: (k: string, v: string) => Promise<{ data: { id: string }[] | null }> };
|
||||
insert: (row: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}).__sb;
|
||||
const u = (await sb.auth.getUser()).data.user;
|
||||
if (!u) throw new Error('no user');
|
||||
const existing = (await sb.from('sync_conflicts').select('id').eq('user_id', u.id)).data ?? [];
|
||||
localStorage.setItem('syncConflictsDismissed', JSON.stringify(existing.map((r) => r.id)));
|
||||
|
||||
await sb.from('sync_conflicts').insert({
|
||||
user_id: u.id,
|
||||
collective_id: null,
|
||||
entity_type: 'shopping_item',
|
||||
entity_id: '00000000-0000-0000-0000-000000000000',
|
||||
local_version: { name: 'OF-02-local' },
|
||||
remote_version: { name: 'OF-02-remote' },
|
||||
resolution: 'remote_won'
|
||||
});
|
||||
});
|
||||
|
||||
// Reload to re-trigger the probe with the planted row in place.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('sync-conflicts-banner')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('sync-conflicts-banner').click();
|
||||
await expect(page).toHaveURL(/\/settings\/sync-conflicts$/);
|
||||
|
||||
const row = page.getByTestId('sync-conflicts-route-row').first();
|
||||
await expect(row).toBeVisible({ timeout: 5_000 });
|
||||
await row.getByTestId('sync-conflicts-discard-local').click();
|
||||
await expect(page.getByTestId('sync-conflicts-route-empty')).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Tests can't DELETE from sync_conflicts (RLS append-only) — the
|
||||
// row stays in the table. Dismissal lives in localStorage so the
|
||||
// next run's pre-dismissal pass keeps it out of view.
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Offline queue + reconnect flush', () => {
|
||||
test('O-01: going offline shows the banner and optimistic adds stay visible', async ({
|
||||
page,
|
||||
@@ -40,6 +102,37 @@ test.describe('Offline queue + reconnect flush', () => {
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('OF-01: going offline shows the persistent chip + a queue badge', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
await page.goto(SEED_LIST_PATH);
|
||||
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Online → chip hidden.
|
||||
await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0);
|
||||
|
||||
await context.setOffline(true);
|
||||
// At least one chip (mobile + desktop both mount; only the visible one
|
||||
// matters but locator-count-based assertions need both).
|
||||
await expect(page.getByTestId('pwa-offline-chip').first()).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
const itemName = `OF-01-${Date.now()}`;
|
||||
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
||||
await input.fill(itemName);
|
||||
await input.press('Enter');
|
||||
|
||||
// pending_ops should now contain at least the INSERT for the new item.
|
||||
await expect(page.getByTestId('pwa-offline-chip-badge').first()).toHaveText(/\d+/, {
|
||||
timeout: 3_000
|
||||
});
|
||||
await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await context.setOffline(false);
|
||||
await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('O-02: going back online flushes the queue to the server', async ({
|
||||
browser,
|
||||
context
|
||||
|
||||
@@ -40,7 +40,9 @@ test.describe('Onboarding', () => {
|
||||
|
||||
const name = `Eva Home ${Date.now()}`;
|
||||
await page.getByTestId('collective-name-input').fill(name);
|
||||
await page.getByRole('button', { name: '🏡', exact: true }).click();
|
||||
// EmojiPicker (Fase 19) — 🏡 lives in the "objects" tab; default is "faces".
|
||||
await page.getByTestId('emoji-picker-tab-objects').click();
|
||||
await page.getByTestId('emoji-picker-cell-🏡').click();
|
||||
await page.getByTestId('collective-submit').click();
|
||||
|
||||
// Landing page after creation.
|
||||
|
||||
102
apps/web/tests/e2e/pwa-update.test.ts
Normal file
102
apps/web/tests/e2e/pwa-update.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* PU-series — Fase 14.1 PWA auto-update toast.
|
||||
*
|
||||
* The real Workbox `needRefresh` event is hard to drive from a Playwright
|
||||
* test (you need a second build pushed to the dev server) so we stub the
|
||||
* virtual:pwa-register/svelte module via `addInitScript`. The stub pretends
|
||||
* a new SW was detected by setting `needRefresh = true` on the writable
|
||||
* store returned by `useRegisterSW` as soon as the layout calls it.
|
||||
*
|
||||
* Asserts:
|
||||
* - UpdateToast appears with the "new version" copy
|
||||
* - the "Recargar" button is wired (we intercept window.location.reload
|
||||
* because Workbox's updateServiceWorker(true) ultimately reloads)
|
||||
* - the onOfflineReady toast also surfaces when its store flips true
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
test.describe('PWA auto-update toast', () => {
|
||||
test('PU-01: needRefresh = true surfaces the update toast and Recargar invokes updateSW', async ({
|
||||
page
|
||||
}) => {
|
||||
// Stub the virtual module BEFORE any app code runs. The layout
|
||||
// imports it inside onMount(), so this init script must define
|
||||
// the module on the window before that dynamic import resolves.
|
||||
await page.addInitScript(() => {
|
||||
// Track whether the user clicked "Recargar".
|
||||
(window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated = false;
|
||||
|
||||
// Minimal Svelte-store-shaped objects: { subscribe, set, update }.
|
||||
// The real store is `writable<boolean>`. The component only does
|
||||
// `$needRefresh` / `$offlineReady` reads so a tiny shim suffices.
|
||||
const makeStore = (initial: boolean) => {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: boolean) => void>();
|
||||
return {
|
||||
subscribe(fn: (v: boolean) => void) {
|
||||
subs.add(fn);
|
||||
fn(value);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
set(v: boolean) {
|
||||
value = v;
|
||||
for (const s of subs) s(v);
|
||||
},
|
||||
update(fn: (v: boolean) => boolean) {
|
||||
this.set(fn(value));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const needRefresh = makeStore(false);
|
||||
const offlineReady = makeStore(false);
|
||||
(window as unknown as { __pwaStores?: unknown }).__pwaStores = {
|
||||
needRefresh,
|
||||
offlineReady
|
||||
};
|
||||
|
||||
// Intercept the dynamic import in the layout. Vite hot-modules
|
||||
// resolve through a global resolver in dev — easier path is to
|
||||
// patch the global module cache used by SvelteKit's runtime.
|
||||
// We install a property on window that the layout checks first
|
||||
// (`window.__pwaRegisterStub`); if absent, real path runs.
|
||||
(window as unknown as { __pwaRegisterStub?: unknown }).__pwaRegisterStub = () => ({
|
||||
needRefresh,
|
||||
offlineReady,
|
||||
updateServiceWorker: async () => {
|
||||
(window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await loginAs(page, USERS.borja);
|
||||
await page.goto('/lists');
|
||||
|
||||
// Wait until the layout's onMount has had a chance to wire the stub.
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { __pwaStores?: { needRefresh: { set: (v: boolean) => void } } }).__pwaStores),
|
||||
null,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
// Flip the needRefresh store; the toast should mount.
|
||||
await page.evaluate(() => {
|
||||
const stores = (window as unknown as { __pwaStores: { needRefresh: { set: (v: boolean) => void } } })
|
||||
.__pwaStores;
|
||||
stores.needRefresh.set(true);
|
||||
});
|
||||
|
||||
const toast = page.getByTestId('pwa-update-toast');
|
||||
await expect(toast).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click "Recargar" — should call updateServiceWorker(true).
|
||||
await page.getByTestId('pwa-update-reload').click();
|
||||
await page.waitForFunction(
|
||||
() => (window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated === true,
|
||||
null,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
78
apps/web/tests/e2e/reading-width.test.ts
Normal file
78
apps/web/tests/e2e/reading-width.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* RW-series: Desktop reading-width cap (Fase 9.2).
|
||||
*
|
||||
* RW-01 detail routes (/lists/[id], /tasks/[id], /notes/[id]) cap the
|
||||
* content column at max-w-2xl on >=md viewports — measured by the
|
||||
* main element's bounding box being narrower than a 1280px viewport.
|
||||
* RW-02 mobile viewports (<md) keep the content full-width.
|
||||
* RW-03 shopping session (/lists/[id]/session) stays full-width on desktop
|
||||
* because the mode is immersive.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const DESKTOP = { width: 1280, height: 900 };
|
||||
const MOBILE = { width: 390, height: 800 };
|
||||
|
||||
/** max-w-2xl in Tailwind = 42rem = 672px. */
|
||||
const READING_WIDTH = 672;
|
||||
|
||||
async function firstListId(page: Page): Promise<string> {
|
||||
await page.goto('/lists');
|
||||
// Click any list card to get into a detail route.
|
||||
const card = page.getByRole('heading', { level: 3 }).or(page.getByRole('heading', { level: 2 })).first();
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await card.click();
|
||||
await page.waitForURL(/\/lists\/[0-9a-f-]+\/?$/, { timeout: 10_000 });
|
||||
const url = new URL(page.url());
|
||||
const segments = url.pathname.split('/');
|
||||
return segments[segments.indexOf('lists') + 1];
|
||||
}
|
||||
|
||||
test.describe('Reading width — desktop max-w-2xl cap', () => {
|
||||
test('RW-01: list detail page caps content at max-w-2xl on desktop', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: DESKTOP });
|
||||
const page = await ctx.newPage();
|
||||
await loginAs(page, USERS.ana);
|
||||
await firstListId(page);
|
||||
|
||||
// The detail content should live inside an element whose width is
|
||||
// capped at max-w-2xl (672px). We assert the inner scroll container
|
||||
// reports a width <= 672 + slack for padding.
|
||||
const wrapper = page.locator('[data-testid="reading-column"]').first();
|
||||
await expect(wrapper).toBeVisible({ timeout: 10_000 });
|
||||
const box = await wrapper.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeLessThanOrEqual(READING_WIDTH + 4);
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('RW-02: list detail page is full-width on mobile (<md)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: MOBILE });
|
||||
const page = await ctx.newPage();
|
||||
await loginAs(page, USERS.ana);
|
||||
await firstListId(page);
|
||||
|
||||
const wrapper = page.locator('[data-testid="reading-column"]').first();
|
||||
await expect(wrapper).toBeVisible({ timeout: 10_000 });
|
||||
const box = await wrapper.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
// Mobile viewport is 390 — wrapper should fill it (allow some padding).
|
||||
expect(box!.width).toBeGreaterThan(380);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('RW-03: shopping session stays full-width on desktop (immersive)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: DESKTOP });
|
||||
const page = await ctx.newPage();
|
||||
await loginAs(page, USERS.ana);
|
||||
const id = await firstListId(page);
|
||||
await page.goto(`/lists/${id}/session`);
|
||||
// The session view is allowed to occupy the full viewport. The
|
||||
// reading-column wrapper must NOT be present in this route.
|
||||
await expect(page.locator('[data-testid="reading-column"]')).toHaveCount(0);
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
67
apps/web/tests/e2e/rename-collective.test.ts
Normal file
67
apps/web/tests/e2e/rename-collective.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* MC-01 (rescued from Fase 7) — /collective/manage rename + emoji change.
|
||||
*
|
||||
* Admin (Ana) edits name + emoji inline; the value persists across reload.
|
||||
* Member (Borja) does not see the editable inputs.
|
||||
*
|
||||
* Cleanup: restore the seed name + emoji in afterAll so downstream suites
|
||||
* that hard-match "Casa García-López" / "🏠" don't break.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Rename collective (CU-H07)', () => {
|
||||
test.afterAll(async () => {
|
||||
await sql(`UPDATE public.collectives SET name = $1, emoji = $2 WHERE id = $3`, [
|
||||
'Casa García-López',
|
||||
'🏠',
|
||||
COLLECTIVE_ID
|
||||
]);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('MC-01a: admin renames + changes emoji; value persists across reload', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/collective/manage');
|
||||
|
||||
const newName = `Casa renamed ${Date.now()}`;
|
||||
const nameInput = page.getByTestId('collective-name-edit');
|
||||
await expect(nameInput).toBeVisible({ timeout: 15_000 });
|
||||
await nameInput.fill(newName);
|
||||
await page.getByTestId('collective-name-save').click();
|
||||
|
||||
// Pick a different emoji from the new tabbed EmojiPicker (Fase 19).
|
||||
// 🏡 lives in the "objects" tab; default is "faces".
|
||||
await page.getByTestId('collective-emoji-toggle').click();
|
||||
await page.getByTestId('emoji-picker-tab-objects').click();
|
||||
await page.getByTestId('emoji-picker-cell-🏡').click();
|
||||
|
||||
// Reload and verify the inputs reflect the persisted value.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('collective-name-edit')).toHaveValue(newName, {
|
||||
timeout: 15_000
|
||||
});
|
||||
|
||||
const row = await sql('SELECT name, emoji FROM public.collectives WHERE id = $1', [
|
||||
COLLECTIVE_ID
|
||||
]);
|
||||
expect(row.rows[0].name).toBe(newName);
|
||||
expect(row.rows[0].emoji).toBe('🏡');
|
||||
});
|
||||
|
||||
test('MC-01b: non-admin (Borja) does not see editable inputs', async ({ page }) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
await page.goto('/collective/manage');
|
||||
|
||||
// Members list loads → page is hydrated; editable name input must be absent.
|
||||
await expect(page.getByTestId(`member-row-${USERS.ana.id}`)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId('collective-name-edit')).toHaveCount(0);
|
||||
await expect(page.getByTestId('collective-emoji-toggle')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
204
apps/web/tests/e2e/reorder-items.test.ts
Normal file
204
apps/web/tests/e2e/reorder-items.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* RO-series — Drag-reorder of shopping items (Fase 11.4)
|
||||
*
|
||||
* The list detail view wires `svelte-dnd-action` over the unchecked-items
|
||||
* section via a per-row GripVertical handle, calling reorderItems() on
|
||||
* drop. These tests verify the user-observable contract:
|
||||
*
|
||||
* RO-01: a reorder persists across reload (sort_order is written to DB)
|
||||
* RO-02: Ana's reorder propagates to Borja via realtime UPDATEs on
|
||||
* shopping_items.sort_order without a refresh
|
||||
*
|
||||
* Note on drag emulation: svelte-dnd-action uses native HTML5 DnD which
|
||||
* headless Chromium handles poorly. Rather than fight the engine, both
|
||||
* tests drive the same write path the page uses (reorderItems batch
|
||||
* UPDATE) by issuing the PATCH calls directly against the REST endpoint
|
||||
* using the session token already in localStorage. The realtime
|
||||
* propagation, the SELECT after reload, and the optimistic UI update
|
||||
* paths are unchanged.
|
||||
*/
|
||||
import { test, expect, type Page, type Browser } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
const SEED_LIST_PATH = `/lists/${SEED_LIST_ID}`;
|
||||
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
|
||||
|
||||
// Dev-stack anon key (also in root .env). Playwright doesn't load .env, and
|
||||
// even when it did the LAN-IP supabase URL in there is wrong for headless.
|
||||
// This is the public anon key for the dev demo instance — no secrets here.
|
||||
const DEV_SUPABASE_URL = 'http://localhost:8001';
|
||||
const DEV_SUPABASE_ANON_KEY =
|
||||
process.env.PUBLIC_SUPABASE_ANON_KEY ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjIwOTE0OTEyOTZ9.fK2is86W1xqCPR525AOU_VukQL4CMxncX55ZBLab-dA';
|
||||
|
||||
async function gotoSeedList(page: Page) {
|
||||
await page.goto(SEED_LIST_PATH);
|
||||
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function createItem(page: Page, name: string) {
|
||||
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
||||
await input.fill(name);
|
||||
await input.press('Enter');
|
||||
await expect(page.locator(`[role="listitem"][data-name="${name}"]`).first()).toBeVisible({
|
||||
timeout: 5_000
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
/** Read item rows in document order, filtered by prefix. */
|
||||
async function orderedNames(page: Page, prefix: string): Promise<string[]> {
|
||||
return page.evaluate((p) => {
|
||||
return Array.from(document.querySelectorAll('[role="listitem"][data-checked="false"]'))
|
||||
.map((el) => (el as HTMLElement).dataset.name ?? '')
|
||||
.filter((n) => n.startsWith(p));
|
||||
}, prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder items inside the browser context by PATCHing sort_order directly
|
||||
* to the Supabase REST endpoint via the same access token the SDK uses.
|
||||
* This is what the in-page reorderItems() helper does under the hood
|
||||
* (Promise.all of per-row updates).
|
||||
*/
|
||||
async function reorderByName(page: Page, listId: string, orderedNames: string[]) {
|
||||
await page.evaluate(
|
||||
async ({ listId, orderedNames, supabaseUrl, anonKey }) => {
|
||||
// Find the GoTrue session token written by supabase-js v2.
|
||||
let token = '';
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)!;
|
||||
if (k.startsWith('sb-') && k.endsWith('-auth-token')) {
|
||||
try {
|
||||
const obj = JSON.parse(localStorage.getItem(k)!);
|
||||
token = obj.access_token ?? obj.currentSession?.access_token ?? '';
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
const listRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shopping_items?list_id=eq.${listId}&select=id,name`,
|
||||
{ headers }
|
||||
);
|
||||
const text = await listRes.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`reorderByName: bad JSON from ${supabaseUrl}/rest/v1/shopping_items: ${text.slice(0, 120)}`);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
`reorderByName: expected array, got ${JSON.stringify(parsed).slice(0, 120)} (status ${listRes.status})`
|
||||
);
|
||||
}
|
||||
const items = parsed as { id: string; name: string }[];
|
||||
const ids = orderedNames
|
||||
.map((n) => items.find((i) => i.name === n)?.id)
|
||||
.filter((x): x is string => !!x);
|
||||
|
||||
await Promise.all(
|
||||
ids.map((id, idx) =>
|
||||
fetch(`${supabaseUrl}/rest/v1/shopping_items?id=eq.${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, Prefer: 'return=minimal' },
|
||||
body: JSON.stringify({ sort_order: idx })
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
listId,
|
||||
orderedNames,
|
||||
supabaseUrl: DEV_SUPABASE_URL,
|
||||
anonKey: DEV_SUPABASE_ANON_KEY
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Drag-reorder', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
});
|
||||
|
||||
test('RO-01: reordering persists across reload', async ({ page }) => {
|
||||
await gotoSeedList(page);
|
||||
const stamp = Date.now();
|
||||
const prefix = `RO01-${stamp}-`;
|
||||
const names = [`${prefix}A`, `${prefix}B`, `${prefix}C`];
|
||||
|
||||
for (const n of names) {
|
||||
await createItem(page, n);
|
||||
}
|
||||
|
||||
const initial = await orderedNames(page, prefix);
|
||||
expect(initial).toEqual(names);
|
||||
|
||||
// Move C above A and B.
|
||||
await reorderByName(page, SEED_LIST_ID, [names[2], names[0], names[1]]);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const reordered = await orderedNames(page, prefix);
|
||||
expect(reordered[0]).toBe(names[2]);
|
||||
const aIdx = reordered.indexOf(names[0]);
|
||||
const bIdx = reordered.indexOf(names[1]);
|
||||
expect(aIdx).toBeGreaterThan(0);
|
||||
expect(bIdx).toBeGreaterThan(aIdx);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Drag-reorder — realtime', () => {
|
||||
async function loggedInListPage(browser: Browser, user: (typeof USERS)[keyof typeof USERS]) {
|
||||
const ctx = await browser.newContext();
|
||||
const p = await ctx.newPage();
|
||||
await loginAs(p, user);
|
||||
await p.goto(SEED_LIST_PATH);
|
||||
await expect(p.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
return { ctx, p };
|
||||
}
|
||||
|
||||
test('RO-02: a reorder by Ana propagates to Borja without reload', async ({ browser }) => {
|
||||
const ana = await loggedInListPage(browser, USERS.ana);
|
||||
const borja = await loggedInListPage(browser, USERS.borja);
|
||||
|
||||
try {
|
||||
const stamp = Date.now();
|
||||
const prefix = `RO02-${stamp}-`;
|
||||
const names = [`${prefix}X`, `${prefix}Y`];
|
||||
for (const n of names) {
|
||||
await createItem(ana.p, n);
|
||||
}
|
||||
|
||||
// Both sides see the items.
|
||||
for (const n of names) {
|
||||
await expect(
|
||||
borja.p.locator(`[role="listitem"][data-name="${n}"]`).first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// Reverse the order on Ana's side.
|
||||
await reorderByName(ana.p, SEED_LIST_ID, [names[1], names[0]]);
|
||||
|
||||
// Realtime feeds the new order to Borja within the suite's 10s window.
|
||||
await expect
|
||||
.poll(
|
||||
async () => orderedNames(borja.p, prefix),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual([names[1], names[0]]);
|
||||
} finally {
|
||||
await ana.ctx.close();
|
||||
await borja.ctx.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
72
apps/web/tests/e2e/reset-from-detail.test.ts
Normal file
72
apps/web/tests/e2e/reset-from-detail.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* RST-series: reset list from /lists/[id] (Fase 10.7).
|
||||
*
|
||||
* The completed-list detail view exposes a prominent "Reset list" button
|
||||
* next to the title. Clicking it brings the list back to status='active'
|
||||
* without leaving the page.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { closePool, sql, COLLECTIVE_ID, BORJA_ID } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const created: { id: string | null } = { id: null };
|
||||
|
||||
test.describe('Reset list from detail view (10.7)', () => {
|
||||
test.beforeEach(async () => {
|
||||
// Seed a completed list with one checked item.
|
||||
const r = await sql(
|
||||
`INSERT INTO public.shopping_lists (collective_id, name, status, completed_at, created_by)
|
||||
VALUES ($1, 'Reset-from-detail', 'completed', now(), $2)
|
||||
RETURNING id`,
|
||||
[COLLECTIVE_ID, BORJA_ID]
|
||||
);
|
||||
created.id = r.rows[0].id as string;
|
||||
await sql(
|
||||
`INSERT INTO public.shopping_items (list_id, name, sort_order, created_by, is_checked, checked_by, checked_at)
|
||||
VALUES ($1, 'Milk', 1, $2, true, $2, now())`,
|
||||
[created.id, BORJA_ID]
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (created.id) {
|
||||
await sql('DELETE FROM public.shopping_lists WHERE id = $1', [created.id]);
|
||||
created.id = null;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('RST-01: completed list shows a Reset button that brings it back to active', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
await page.goto(`/lists/${created.id}`);
|
||||
|
||||
const resetBtn = page.getByTestId('detail-reset-button');
|
||||
await expect(resetBtn).toBeVisible({ timeout: 15_000 });
|
||||
await resetBtn.click();
|
||||
|
||||
// Button should disappear once the list is back to active.
|
||||
await expect(resetBtn).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
const row = await sql(
|
||||
'SELECT status::text AS status, completed_at FROM public.shopping_lists WHERE id = $1',
|
||||
[created.id]
|
||||
);
|
||||
expect(row.rows[0].status).toBe('active');
|
||||
expect(row.rows[0].completed_at).toBeNull();
|
||||
|
||||
// Items uncheked.
|
||||
const items = await sql(
|
||||
'SELECT bool_and(NOT is_checked) AS all_unchecked FROM public.shopping_items WHERE list_id = $1',
|
||||
[created.id]
|
||||
);
|
||||
expect(items.rows[0].all_unchecked).toBe(true);
|
||||
});
|
||||
});
|
||||
235
apps/web/tests/e2e/section-visibility.test.ts
Normal file
235
apps/web/tests/e2e/section-visibility.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* SV-series — Section visibility / feature_flags (Fase 12).
|
||||
*
|
||||
* SV-01 Ana hides "Tasks" in /settings → sidebar + bottom tab bar stop
|
||||
* showing it, and visiting /tasks redirects to /lists.
|
||||
* SV-02 Ana (admin) hides "Notes" in /collective/manage → Borja (member of
|
||||
* the same collective) sees the entry disappear without a reload
|
||||
* (realtime).
|
||||
* SV-03 Ana sets a collective-level Tasks override ON in a fresh extra
|
||||
* collective; Borja (already opted out of Tasks at the user layer)
|
||||
* sees Tasks reappear in that collective but stays hidden in others.
|
||||
* Verifies precedence collective > user resolves per-collective.
|
||||
*
|
||||
* Each test resets the seed user/collective JSON columns afterwards so the
|
||||
* suite is idempotent. Reset uses the live PostgREST endpoint via the JWT
|
||||
* already stashed in localStorage by `loginAs`.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { USERS, COLLECTIVE_NAME } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
|
||||
/**
|
||||
* Patch a row via the Supabase client already loaded in the page (exposed
|
||||
* as `window.__sb` in dev — see `+layout.svelte`). Returns the HTTP-ish
|
||||
* status (200 on success, 0 if the client is not ready yet).
|
||||
*/
|
||||
async function patchRow(
|
||||
page: Page,
|
||||
table: 'users' | 'collectives',
|
||||
id: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
// Wait for the dev hook to land — onMount fires after the first paint, so
|
||||
// in fast tests we can race the eval. 5s timeout is plenty.
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
|
||||
null,
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
return await page.evaluate(
|
||||
async ({ table, id, body }) => {
|
||||
const supabase = (
|
||||
window as unknown as {
|
||||
__sb: import('@supabase/supabase-js').SupabaseClient;
|
||||
}
|
||||
).__sb;
|
||||
const { error } = await supabase.from(table).update(body).eq('id', id);
|
||||
return error ? 500 : 200;
|
||||
},
|
||||
{ table, id, body }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab bar entries live inside `[data-testid="bottom-tab-bar"]`. We assert on
|
||||
* the *count* of the matching `[data-section="X"]` so the test does not depend
|
||||
* on the visible-vs-hidden CSS state (the entry is removed from the DOM when
|
||||
* hidden).
|
||||
*/
|
||||
async function tabBarHasSection(page: Page, section: string): Promise<number> {
|
||||
return await page
|
||||
.getByTestId('bottom-tab-bar')
|
||||
.locator(`[data-section="${section}"]`)
|
||||
.count();
|
||||
}
|
||||
|
||||
async function resetAllFlags(browser: import('@playwright/test').Browser): Promise<void> {
|
||||
// Reset the seed rows via Ana (the only user authoritative for both her own
|
||||
// row AND the seed collective row). Patching another user's row from Ana
|
||||
// would be blocked by RLS, so we open a second context for Borja.
|
||||
const anaCtx = await browser.newContext();
|
||||
const anaPage = await anaCtx.newPage();
|
||||
try {
|
||||
await loginAs(anaPage, USERS.ana);
|
||||
await patchRow(anaPage, 'users', USERS.ana.id, { feature_flags: {} });
|
||||
await patchRow(anaPage, 'collectives', SEED_COLLECTIVE_ID, { feature_flags: {} });
|
||||
// Fase 13: also clear the server-layer defaults so a leftover from the
|
||||
// admin suite (which can run earlier in `just test-all`) doesn't break
|
||||
// our precedence assertions. Ana is the seed server_admin so the RPC
|
||||
// passes the gate.
|
||||
await anaPage.evaluate(async () => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
for (const s of ['lists', 'tasks', 'notes', 'search']) {
|
||||
try {
|
||||
await supabase.rpc('admin_clear_default_section', { p_section: s });
|
||||
} catch {
|
||||
/* non-admin: ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* don't mask original failure */
|
||||
} finally {
|
||||
await anaCtx.close();
|
||||
}
|
||||
|
||||
const borjaCtx = await browser.newContext();
|
||||
const borjaPage = await borjaCtx.newPage();
|
||||
try {
|
||||
await loginAs(borjaPage, USERS.borja);
|
||||
await patchRow(borjaPage, 'users', USERS.borja.id, { feature_flags: {} });
|
||||
} catch {
|
||||
/* don't mask original failure */
|
||||
} finally {
|
||||
await borjaCtx.close();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Section visibility', () => {
|
||||
// Reset BOTH before and after every test. The before-each guards against
|
||||
// state leakage from prior suites (the broader `just test-all` run shares
|
||||
// the same Docker database with whatever tasks/lists tests ran before).
|
||||
// The after-each keeps subsequent suites clean.
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
await resetAllFlags(browser);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ browser }) => {
|
||||
await resetAllFlags(browser);
|
||||
});
|
||||
|
||||
test('SV-01: Ana hides Tasks in /settings — sidebar + tab bar drop it, /tasks redirects to /lists', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/settings');
|
||||
|
||||
const toggle = page.getByTestId('settings-section-toggle-tasks');
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
await expect(toggle).toBeChecked();
|
||||
await toggle.click();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
|
||||
const sidebar = page.getByTestId('desktop-sidebar');
|
||||
await expect(sidebar.locator('[data-section="tasks"]')).toHaveCount(0, {
|
||||
timeout: 5_000
|
||||
});
|
||||
const tabBar = page.getByTestId('bottom-tab-bar');
|
||||
await expect(tabBar.locator('[data-section="tasks"]')).toHaveCount(0);
|
||||
await expect(tabBar).toHaveAttribute('data-section-count', '3');
|
||||
|
||||
await page.goto('/tasks');
|
||||
await page.waitForURL(/\/lists/, { timeout: 10_000 });
|
||||
await expect(page.getByTestId('section-disabled-toast')).toBeVisible({
|
||||
timeout: 5_000
|
||||
});
|
||||
});
|
||||
|
||||
test('SV-02: Ana hides Notes for the collective — Borja sees it disappear in realtime', async ({
|
||||
browser
|
||||
}) => {
|
||||
const anaCtx = await browser.newContext();
|
||||
const ana = await anaCtx.newPage();
|
||||
const borjaCtx = await browser.newContext();
|
||||
const borja = await borjaCtx.newPage();
|
||||
|
||||
try {
|
||||
await loginAs(ana, USERS.ana);
|
||||
// Make sure both flag rows are clean before Borja loads.
|
||||
await patchRow(ana, 'collectives', SEED_COLLECTIVE_ID, { feature_flags: {} });
|
||||
await patchRow(ana, 'users', USERS.ana.id, { feature_flags: {} });
|
||||
|
||||
await loginAs(borja, USERS.borja);
|
||||
await patchRow(borja, 'users', USERS.borja.id, { feature_flags: {} });
|
||||
await borja.goto('/lists');
|
||||
// BottomTabBar is `md:hidden` so on Desktop Chrome it lives in the
|
||||
// DOM but is CSS-hidden — assert via locator count instead of
|
||||
// toBeVisible (which would otherwise fail on the visibility check).
|
||||
await expect(borja.getByTestId('bottom-tab-bar')).toHaveCount(1, { timeout: 10_000 });
|
||||
expect(await tabBarHasSection(borja, 'notes')).toBe(1);
|
||||
|
||||
// Ana flips the collective-level Notes toggle off.
|
||||
await ana.goto('/collective/manage');
|
||||
const adminToggle = ana.getByTestId('manage-section-toggle-notes');
|
||||
await expect(adminToggle).toBeVisible({ timeout: 10_000 });
|
||||
await expect(adminToggle).toBeChecked();
|
||||
await adminToggle.click();
|
||||
await expect(adminToggle).not.toBeChecked();
|
||||
|
||||
// Borja's nav loses Notes without a reload — realtime carried the change.
|
||||
await expect
|
||||
.poll(() => tabBarHasSection(borja, 'notes'), { timeout: 15_000 })
|
||||
.toBe(0);
|
||||
} finally {
|
||||
await anaCtx.close();
|
||||
await borjaCtx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('SV-03: collective ON override wins over user OFF — Tasks reappears per-collective', async ({
|
||||
page,
|
||||
browser
|
||||
}) => {
|
||||
// Borja opts out of Tasks at the user layer (in the seed collective).
|
||||
await loginAs(page, USERS.borja);
|
||||
await patchRow(page, 'users', USERS.borja.id, { feature_flags: { tasks: false } });
|
||||
// Force a re-fetch by reloading — the page first loaded with the empty
|
||||
// row, the realtime subscription will also fire, but reload is the most
|
||||
// deterministic way to start from a known state.
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByTestId('bottom-tab-bar')).toHaveCount(1, { timeout: 10_000 });
|
||||
expect(await tabBarHasSection(page, 'tasks')).toBe(0);
|
||||
|
||||
// Ana (admin) explicitly sets Tasks ON at the collective level. Since
|
||||
// the collective layer outranks the user layer, Tasks should reappear
|
||||
// for Borja in this collective without him touching his user toggle.
|
||||
const adminCtx = await browser.newContext();
|
||||
const adminPage = await adminCtx.newPage();
|
||||
try {
|
||||
await loginAs(adminPage, USERS.ana);
|
||||
const status = await patchRow(adminPage, 'collectives', SEED_COLLECTIVE_ID, {
|
||||
feature_flags: { tasks: true }
|
||||
});
|
||||
expect(status).toBeGreaterThanOrEqual(200);
|
||||
expect(status).toBeLessThan(300);
|
||||
} finally {
|
||||
await adminCtx.close();
|
||||
}
|
||||
|
||||
// Borja's BottomTabBar should pick the change up via realtime within
|
||||
// the standard 15s window (matches the Realtime gotcha note).
|
||||
await expect
|
||||
.poll(() => tabBarHasSection(page, 'tasks'), { timeout: 15_000 })
|
||||
.toBe(1);
|
||||
|
||||
// Sanity reference for the COLLECTIVE_NAME import — keeps the
|
||||
// fixture link explicit in case the helper file moves.
|
||||
expect(COLLECTIVE_NAME.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -59,20 +59,16 @@ test.describe('Shopping session — full-screen mode', () => {
|
||||
page
|
||||
}) => {
|
||||
// Need a fresh list so completing it doesn't affect shared seed state.
|
||||
// Use the new big-button create flow: masthead "New list" → lands on
|
||||
// /lists/[id] → fill inline title → autosave → we're already on the
|
||||
// detail view, no need to navigate back.
|
||||
// Fase 18: masthead "New list" → modal opens → fill + submit → lands
|
||||
// on the detail view at /lists/[id].
|
||||
await page.goto('/lists');
|
||||
await page
|
||||
.getByRole('button', { name: /new list|nueva lista/i })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
|
||||
const listName = `S-03-list-${Date.now()}`;
|
||||
const titleInput = page.getByPlaceholder(/weekly shop|compra semanal/i);
|
||||
await expect(titleInput).toBeVisible({ timeout: 10_000 });
|
||||
await titleInput.fill(listName);
|
||||
await titleInput.blur();
|
||||
await page.waitForTimeout(900); // autosave debounce
|
||||
await page.getByTestId('create-list-modal-name').fill(listName);
|
||||
await page.getByTestId('create-list-modal-submit').click();
|
||||
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
|
||||
|
||||
// Open session mode
|
||||
await page.getByTestId('start-session').click();
|
||||
|
||||
65
apps/web/tests/e2e/sidebar-create-collective.test.ts
Normal file
65
apps/web/tests/e2e/sidebar-create-collective.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* O-04 (rescued from Fase 7) — sidebar "+ New collective" entry (Fase 10.6).
|
||||
*
|
||||
* Eva starts onboarded with one collective; she opens the sidebar switcher,
|
||||
* clicks "+ New collective", fills the modal, and ends up on /lists with
|
||||
* both collectives visible in the switcher.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
import { resetEva } from '../fixtures/db.js';
|
||||
import { closePool, sql } from '@colectivo/test-utils';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Sidebar create collective (CU-H02 follow-up)', () => {
|
||||
test.beforeEach(async () => {
|
||||
await resetEva();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await resetEva();
|
||||
await closePool();
|
||||
});
|
||||
|
||||
test('O-04: Eva with one collective creates a second from the sidebar', async ({ page }) => {
|
||||
// Onboard Eva with a first collective so the switcher renders.
|
||||
const firstName = `Eva First ${Date.now()}`;
|
||||
await loginAs(page, USERS.eva, { waitForCollective: false });
|
||||
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 15_000 });
|
||||
await page.getByTestId('collective-name-input').fill(firstName);
|
||||
await page.getByTestId('collective-submit').click();
|
||||
await expect(page).toHaveURL(/\/lists$/, { timeout: 15_000 });
|
||||
|
||||
// Open the sidebar switcher and pick "+ New collective".
|
||||
await page.getByTestId('sidebar-collective-switcher').click();
|
||||
const newEntry = page.getByTestId('sidebar-create-collective');
|
||||
await expect(newEntry).toBeVisible({ timeout: 5_000 });
|
||||
await newEntry.click();
|
||||
|
||||
const secondName = `Eva Second ${Date.now()}`;
|
||||
await page.getByTestId('create-collective-modal-name').fill(secondName);
|
||||
await page.getByTestId('create-collective-modal-submit').click();
|
||||
|
||||
// Landed on /lists with the second collective active.
|
||||
await expect(page).toHaveURL(/\/lists$/, { timeout: 15_000 });
|
||||
|
||||
// Both collectives in the switcher. Use .first() because the switcher
|
||||
// header also renders the active collective name.
|
||||
await page.getByTestId('sidebar-collective-switcher').click();
|
||||
await expect(
|
||||
page.getByTestId('desktop-sidebar').getByText(firstName).first()
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByTestId('desktop-sidebar').getByText(secondName).first()
|
||||
).toBeVisible();
|
||||
|
||||
// DB-side: Eva has two collectives.
|
||||
const r = await sql(
|
||||
'SELECT count(*)::int AS n FROM public.collectives WHERE created_by = $1',
|
||||
[USERS.eva.id]
|
||||
);
|
||||
expect(r.rows[0].n).toBe(2);
|
||||
});
|
||||
});
|
||||
40
apps/web/tests/e2e/sidebar-manage-link.test.ts
Normal file
40
apps/web/tests/e2e/sidebar-manage-link.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* SBM-series — desktop sidebar manage-collective entry
|
||||
*
|
||||
* The DesktopSidebar gained a last-position link to /collective/manage
|
||||
* after the nav loop, preceded by a visual spacer (divider). The mobile
|
||||
* drawer already had this link; desktop did not, leaving the page
|
||||
* unreachable from the chrome.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const DESKTOP = { width: 1280, height: 800 };
|
||||
|
||||
test.describe('Desktop sidebar — manage collective link', () => {
|
||||
test('SBM-01: the manage link is the last nav entry and navigates to /collective/manage', async ({ page }) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/lists');
|
||||
|
||||
const link = page.getByTestId('sidebar-manage-link');
|
||||
await expect(link).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(/\/collective\/manage/);
|
||||
});
|
||||
|
||||
test('SBM-02: a horizontal divider separates the manage link from the section nav', async ({ page }) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await loginAs(page, USERS.ana);
|
||||
await page.goto('/lists');
|
||||
|
||||
// The divider sits inside the same <nav>, immediately before the manage link.
|
||||
const manageLink = page.getByTestId('sidebar-manage-link');
|
||||
await expect(manageLink).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const dividerCount = await page.locator('nav > div[aria-hidden="true"]').count();
|
||||
expect(dividerCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
149
apps/web/tests/e2e/spinner.test.ts
Normal file
149
apps/web/tests/e2e/spinner.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* SP-series — Fase 16 (loading spinner visual feedback).
|
||||
*
|
||||
* The Spinner component itself is unit-tested in
|
||||
* `src/lib/components/Spinner.test.ts` (SP-U-01..05). These E2E specs assert
|
||||
* that the spinner actually shows up at the five integration sites listed in
|
||||
* `plan/fase-16-spinner.md` when the backing async operation is in flight.
|
||||
*
|
||||
* Strategy: every spec uses `page.route(...)` to add ~500ms of artificial
|
||||
* latency to the request that drives the spinner so the assertion window is
|
||||
* deterministic — without the throttle the loopback round-trip resolves in
|
||||
* <30ms and the spinner can flicker past Playwright's auto-wait cadence.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const THROTTLE_MS = 500;
|
||||
|
||||
test.describe('Loading spinner', () => {
|
||||
test('SP-E-01: /search shows the spinner while the search RPC is in flight', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
|
||||
// Throttle the search RPC so the loading window stays open long enough
|
||||
// for the spinner assertion below.
|
||||
await page.route('**/rest/v1/rpc/search_in_collective**', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, THROTTLE_MS));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/search');
|
||||
const input = page.getByPlaceholder(/search|buscar/i);
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
await input.fill('Milk');
|
||||
|
||||
// Debounce is 300ms + 500ms throttle ≈ ~800ms window where the
|
||||
// spinner is mounted in the `{#if loading}` branch of /search.
|
||||
const spinner = page.getByTestId('spinner').first();
|
||||
await expect(spinner).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// And it disappears once the RPC resolves.
|
||||
await expect(spinner).toHaveCount(0, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('SP-E-02: Save button in "Add common item" modal shows the spinner while saving', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
|
||||
// Throttle the set_item_frequency_weight RPC so addSaving stays true
|
||||
// long enough for the spinner to be visible.
|
||||
await page.route(
|
||||
'**/rest/v1/rpc/set_item_frequency_weight**',
|
||||
async (route) => {
|
||||
await new Promise((r) => setTimeout(r, THROTTLE_MS));
|
||||
await route.continue();
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto('/collective/manage');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByTestId('common-item-add-button').click();
|
||||
const nameInput = page.getByTestId('common-item-add-name');
|
||||
await expect(nameInput).toBeVisible({ timeout: 5_000 });
|
||||
await nameInput.fill(`sp-e-02-${Date.now()}`);
|
||||
|
||||
const saveButton = page.getByTestId('common-item-add-save');
|
||||
await saveButton.click();
|
||||
|
||||
// Spinner is scoped inside the Save button so we look for any spinner
|
||||
// that's also a descendant of the button — this avoids matching some
|
||||
// unrelated spinner elsewhere on the page.
|
||||
await expect(saveButton.getByTestId('spinner')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('SP-E-03: auth splash shows the spinner while Supabase resolves the session', async ({
|
||||
browser
|
||||
}) => {
|
||||
// The splash in `(app)/+layout.svelte` is gated on `$authLoading`,
|
||||
// which only flips false once Supabase's `onAuthStateChange` fires
|
||||
// `INITIAL_SESSION`. With no stored session the emit is synchronous
|
||||
// and the splash flashes past Playwright's auto-wait.
|
||||
//
|
||||
// Trick: seed localStorage with an *expired* sb-* auth token so
|
||||
// GoTrue's `_recoverAndRefresh` is forced to hit
|
||||
// `/auth/v1/token?grant_type=refresh_token`. Throttle that endpoint
|
||||
// by 500ms and the splash stays mounted for the assertion window.
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await context.addInitScript(() => {
|
||||
// Synthetic expired Supabase session. We can't sign it correctly,
|
||||
// but GoTrue trusts the stored payload and skips straight to
|
||||
// refresh as soon as it sees expires_at in the past.
|
||||
const payload = {
|
||||
access_token: 'sp-e-03-fake-access',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
expires_at: Math.floor(Date.now() / 1000) - 60,
|
||||
refresh_token: 'sp-e-03-fake-refresh',
|
||||
user: {
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
aud: 'authenticated',
|
||||
role: 'authenticated',
|
||||
email: 'spinner-splash@dev.local',
|
||||
app_metadata: { provider: 'keycloak' },
|
||||
user_metadata: {},
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
// Supabase derives the storage key from the URL hostname (first
|
||||
// label only): `sb-${hostname.split('.')[0]}-auth-token`. Our dev
|
||||
// PUBLIC_SUPABASE_URL is http://192.168.1.167:8001 → key
|
||||
// `sb-192-auth-token`. We populate every plausible key so the
|
||||
// test is resilient to a localhost override.
|
||||
const KEYS = ['sb-192-auth-token', 'sb-localhost-auth-token'];
|
||||
for (const k of KEYS) localStorage.setItem(k, JSON.stringify(payload));
|
||||
});
|
||||
|
||||
// Stall the GoTrue token-refresh endpoint indefinitely. With the
|
||||
// seeded expired session above, `_recoverAndRefresh` is forced to
|
||||
// call POST /auth/v1/token?grant_type=refresh_token before emitting
|
||||
// INITIAL_SESSION. While the call is in flight `authLoading` stays
|
||||
// true and the splash is mounted, which is exactly the window the
|
||||
// assertion needs. We never `route.continue()` — the test asserts
|
||||
// the spinner is visible and then tears the context down.
|
||||
let releaseToken: (() => void) | null = null;
|
||||
const tokenStall = new Promise<void>((resolve) => {
|
||||
releaseToken = resolve;
|
||||
});
|
||||
await page.route('**/auth/v1/token**', async (route) => {
|
||||
await tokenStall;
|
||||
await route.fulfill({ status: 503, body: '{}' });
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto('/lists', { waitUntil: 'commit' });
|
||||
const spinner = page.getByTestId('spinner').first();
|
||||
await expect(spinner).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
// Release the stalled refresh so the context can shut down cleanly.
|
||||
if (releaseToken) releaseToken();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user