Closes the MVP2 cycle (fase 9-14 all green). Documents the three
substantive shipped pieces (14.1 auto-update toast, 14.2 offline
polish + sync_conflicts review route, 14.3 build-time version chip),
the test deltas, cumulative MVP2 totals, and the two plan deviations
(updateViaCache substitute, localStorage-based discard).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`apps/web/vite.config.ts` now injects three build-time constants via
`define`: `__APP_VERSION__` (root package.json), `__APP_COMMIT__`
(GIT_SHA env > local `git rev-parse` > 'dev'), `__BUILD_TIME__` (ISO
timestamp at build). They're declared in `apps/web/src/global.d.ts`
and re-exported through `$lib/version` — components consume the named
exports rather than the magic globals so an accidental tree-shake
would break the test, not the page silently.
`/settings` gains an "About" section at the bottom: a small chip
`v{version} · {commit} · {date}` in muted text. Not interactive,
purely diagnostic — useful when a user reports a bug from a stale
PWA install.
Deploy pipeline:
- `apps/web/Dockerfile` accepts a `GIT_SHA` build arg and exports
it as an env var for the SvelteKit build step.
- `infra/docker-compose.erosi.yml` forwards `${GIT_SHA:-dev}` into
the build args.
- `infra/scripts/deploy-erosi.sh` captures `git rev-parse --short
HEAD` locally (the deploy host has .git; the remote doesn't —
rsync excludes it) and forwards it via `ssh ... env GIT_SHA=…`
so the remote `docker compose build` sees it.
V-01/V-02 vitest: stub the three globals via `vi.stubGlobal` (vitest
doesn't run through the main vite.config so `define` isn't applied),
assert the named exports thread through and never collapse to
undefined or the 'dev' sentinel under a real build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New page `/settings/sync-conflicts` that lists the user's last 100
sync_conflicts rows side-by-side (local vs remote payloads) with two
dismiss actions: "Discard local" and "Discard remote". Both actions
write the row id into a `syncConflictsDismissed` localStorage set —
migration 016 made sync_conflicts append-only at the RLS layer, and
Fase 14 ships no DB migration, so dismissal is per-device and the
underlying log is preserved for operator audits.
`(app)/+layout.svelte` now probes sync_conflicts once per session
(deferred out of the auth callback for the same lock-deadlock reason
the collectives query is) and feeds the count into a new
`unresolvedConflictsCount` store. When non-zero AND the user isn't
already on the review route, an amber pill banner offers a one-tap
"Review" link to the page.
OF-02 e2e plants a sync_conflicts row through the dev-only `__sb`
window client, reloads, asserts the banner, clicks through to the
route, discards the row, and asserts the empty state. Existing rows
are pre-dismissed via localStorage so the test is deterministic
regardless of accumulated history (we can't DELETE from the table).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an OfflineChip surfaced in the MobileTopBar (compact, icon-only)
and at the bottom of the DesktopSidebar (full pill with label). Hidden
while online. When `navigator.onLine` is false:
- The chip mounts with the existing amber palette
- A title tooltip explains the implication ("changes will sync when
you reconnect")
- If `pendingOpsCount > 0`, a small numeric badge is appended so the
user sees the actual backlog size at a glance
`pendingOpsCount` is the existing store (kept in sync by SyncQueue);
nothing new wires up — we only surface what was already tracked.
OF-01 e2e: drives `context.setOffline(true)`, asserts the chip
appears, queues an INSERT, asserts the numeric badge renders, and
verifies the chip disappears when back online. Added alongside the
existing O-01/O-02 (banner-based) so we keep coverage of both surfaces.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migrate from `registerSW({ immediate: true })` to `useRegisterSW` from
`virtual:pwa-register/svelte`, mirror its `needRefresh` / `offlineReady`
stores into stable layout-local writables, and render a new
`UpdateToast` pill (bottom-center) that surfaces:
- "New version available" + a "Reload" button that calls
`updateServiceWorker(true)`
- "Ready to use offline" (auto-dismiss after 4s) on first install
Adds `[pwa] …` console.info telemetry on register / need-refresh /
apply. Stand-in for the unavailable `updateViaCache: 'none'` runtime
option (RegisterSWOptions doesn't expose it in vite-plugin-pwa 0.21.2):
poll `reg.update()` every 15min, which keeps a too-aggressively-cached
SW file from pinning users on the previous build for up to 24h.
`apps/web/src/global.d.ts` declares the virtual module's surface inline
so svelte-check picks it up without a relative path into node_modules;
the same file pre-declares the Fase-14.3 `__APP_VERSION__` triplet.
PU-01 stubs the registrar via `window.__pwaRegisterStub` (the layout
checks for it before calling the real `useRegisterSW`) so the toast
flow can be driven from a Playwright test without a second build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both tests do two full Keycloak login flows + a /lists hydration. Under
accumulated dev-DB load (many leftover lists from prior suites) the
30s default occasionally trips, even though the actual work completes
in ~5–7s on a clean DB. 60s is comfortable headroom and matches the
pattern used elsewhere when a test legitimately needs more.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps Fase 13:
- docs/history/fase-13-server-admin.md captures the full fase
breakdown (model, RPCs, UI, store wiring, tests, verification,
deviations from plan, scope out).
- docs/deployment.md gains a Server administration section with the
SERVER_ADMIN_EMAIL bootstrap recipe + the "first prod boot has no
users yet" gotcha + manual re-run command (with the </dev/null
redirect for the heredoc-stdin gotcha) + last-admin guard note +
audit log query snippet.
- CLAUDE.md "Project Status" updated: MVP2 now 5/6, Fase 13 ✅, test
totals, the one pre-existing SV-02 flake noted.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
13.3 admin UI:
- (admin) route group with own layout + red banner + sidebar (Collectives,
Admins, Audit log, Server). +layout.ts is client-rendered (ssr=false),
same rationale as the (app) group.
- /admin/collectives — paginated list via admin_list_collectives, search
by name, soft-delete / restore / hard-delete modals (hard-delete needs
explicit checkbox; force toggle bypasses the 30-day wait server-side).
- /admin/collectives/[id] — members list with kick action; recent actions
for the collective filtered from admin_actions.
- /admin/admins — list via server_admins join to users (disambiguated by
FK name — there are TWO FKs to users so the embed needs the explicit
server_admins_user_id_fkey); promote modal does an email lookup; revoke
button is replaced by "you (cannot revoke yourself while sole admin)"
when applicable.
- /admin/audit — paginated feed (default 50), action filter.
- /admin/server — server-layer default-section toggles backed by
admin_set_default_section + the new admin_clear_default_section RPC
(added because patching the JSONB to `true` is NOT equivalent to "no
opinion" — `true` explicitly overrides a collective OFF).
13.4 sidebar/drawer entry:
- $isServerAdmin store (writable; +$isServerAdminLoaded for the gate
race) refreshed on every onAuthStateChange in root layout, cleared on
sign-out. Cached so the sidebar tile is synchronous.
- DesktopSidebar + MobileDrawer render the entry only when the store is
true. Distinct red icon so it never blends with normal nav.
Stores wiring:
- features.ts gains serverSectionDefaults writable + enabledSections
derived now reads server > collective > user > default. Loader
piggybacks on loadCurrentUserFeatures so the server layer is fetched
once per sign-in.
- serverAdmin.ts new module — refreshServerAdminFlag() + clearServerAdminFlag().
- Tracks isServerAdminLoaded so the (admin) layout doesn't redirect away
during the millisecond between SIGNED_IN and the RPC resolving (caught
empirically — every hard-load to /admin/* bounced to / without it).
13.5 tests:
- admin.test.ts (SA-01..SA-04) with new loginAsAdmin fixture. Ana is the
seed admin (server_admins seeded via supabase/seed.sql).
- SA-04 + SV-02 reset their server-layer JSONB via the new
admin_clear_default_section RPC so the suites don't leak state into
each other.
Migration 025 adds admin_clear_default_section(text) — same SECURITY DEFINER
+ audit pattern as the rest. pgTAP 017 updated (23 plans, AR-T13b + the
SECURITY DEFINER list now includes the clear RPC).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 025 adds:
- collectives.deleted_at column for soft delete (app-level visibility;
RLS does NOT enforce soft-delete invisibility — admin area surfaces
everything by design).
- server_settings(key text pk, value jsonb) — generic admin-write bag
seeded with an empty 'default_sections' row.
- section_enabled() extended with the server layer as the topmost
coalesce branch. Final precedence: server > collective > user > true.
- Privileged RPCs: grant_/revoke_server_admin (last-admin guard fires
P0003), admin_list_collectives, admin_soft_delete_collective,
admin_restore_collective, admin_hard_delete_collective (30-day or
p_force=true), admin_remove_member, admin_set_default_section
(rejects unknown sections via known_sections() lookup).
- _log_admin_action(): private helper centralising the
audit INSERT, called by every privileged RPC BEFORE the mutation.
Migration 024 RLS policy on server_admins rewritten to delegate the
admin branch to is_server_admin() (SECURITY DEFINER, bypasses RLS) —
the original inline EXISTS hit infinite recursion (caught empirically
with `SET ROLE authenticated`).
22 new pgTAP assertions cover schema shape, RPC signatures, the
SECURITY DEFINER posture of every admin function, the new deleted_at
column, and the server-layer precedence semantics for section_enabled.
10 new Vitest integration tests (SA-01..SA-10) cover:
- admin_list_collectives gated for non-admins (P0001 'forbidden').
- soft / restore / hard delete audit + state transitions.
- hard-delete recency guard (not_soft_deleted, too_recent, force).
- hard-delete cascade is non-destructive to public.users.
- remove_member writes role_was + reason to audit payload.
- set_default_section rejects unknown sections; patches JSONB
without clobbering siblings.
- grant/revoke with last-admin guard (revoking the sole admin
raises P0003 'last_admin'; failed call does NOT write audit).
- section_enabled precedence walked layer by layer in one client.
- RLS: non-admin sees zero rows in admin_actions + server_admins.
packages/types/src/database.ts hand-extended with the new tables and
RPC signatures; collectives Row/Insert/Update get deleted_at.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 024 introduces the orthogonal global role and the append-only
audit log. server_admins is a separate table (not a column on users) so
that promote/revoke don't require a JWT re-issue; is_server_admin() is a
STABLE SECURITY DEFINER helper matching the shape of is_admin/is_member.
admin_actions has only a SELECT policy (admin-only) — no INSERT/UPDATE/
DELETE policies, so only the SECURITY DEFINER RPCs in migration 025 can
write to it. actor_id FK uses RESTRICT so the audit trail outlives
admins.
Bootstrap via infra/db-init/10-server-admin-seed.sh on first volume
init (reads SERVER_ADMIN_EMAIL); supabase/seed.sql also pre-seeds Ana
for dev/test because docker-entrypoint-initdb.d does not re-fire on
long-lived dev volumes. Documented in .env.erosi.example.
20 new pgTAP assertions cover schema shape, RLS posture, FK behaviour,
and the SECURITY DEFINER + STABLE attributes on is_server_admin().
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds docs/history/fase-12-section-visibility.md describing the
COALESCE-precedence design that keeps Fase 13's server-settings layer
to a one-line diff, the realtime publication addition that SV-02
forced, and the hermetic test-reset deviation from the plan.
Updates CLAUDE.md project status (2/6 → 4/6 since Fase 11 and 12 both
shipped on the mvp2 branch) and adds the "new top-level section requires"
rule under Domain Model.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The full `just test-all` first run surfaced state leakage from prior
suites (tasks tests) into SV-02/SV-03: another spec had left the
collective row's feature_flags populated, so by the time the SV suite
ran the realtime test was hitting a noisy baseline.
Add a symmetric beforeEach + afterEach that resets BOTH user.feature_flags
(Ana + Borja, each via their own context to satisfy RLS) and
collectives.feature_flags (seed collective, via Ana). Cost: ~6s per
spec in setup overhead; benefit: 5 sequential `just test-all` runs
green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new sections, both hooked into the precedence-aware features store:
* /settings — "Visible sections" block with one toggle per SectionKey
bound to setUserFeature(). When a collective-level override is
present the toggle is disabled and a one-line "Hidden by the
collective" hint replaces the visible state — making it obvious
why the per-user toggle is moot.
* /collective/manage — "Collective sections" block (admin-only,
inside the existing isAdmin guard) with one toggle per SectionKey
bound to setCollectiveFeature(). Flipping any of them propagates
to every member in realtime via the publication added in the
previous commit.
Both helpers fall back to the default-ON semantics of section_enabled
when the JSONB key is missing, so untouched rows render exactly as
before fase 12.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
UI side:
* DesktopSidebar / BottomTabBar filter their entries by $enabledSections,
tag each entry with data-section, and BottomTabBar exposes a
data-section-count attribute so the grid is observable from tests.
* "lists" is force-shown unconditionally — the §12.3.4 always-one-
landing rule covers the edge case where every layer says OFF.
* (app)/+layout.svelte adds a $effect that, when the URL matches a
disabled section (/tasks, /notes, /search), goto's /lists and shows
a transient `section_disabled_for_collective` toast.
* Root +layout.svelte exposes the supabase singleton on window.__sb
in dev so the new Playwright spec can patch rows without a parallel
client; dead-code-eliminated in production builds.
DB side:
* Migration 023 grows by ALTER PUBLICATION supabase_realtime ADD TABLE
public.users + public.collectives + REPLICA IDENTITY FULL on both.
Without this membership the features.ts subscriptions get zero
events and SV-02 (collective toggle → realtime → member's nav
updates) silently fails. Caught while running the spec.
* pgTAP 015 grows from 16 to 20 assertions to cover publication
membership + REPLICA IDENTITY for both new realtime tables.
Paraglide messages: section_disabled_for_collective, the visibility
section titles + blurbs, section_label_* per SectionKey. Both en + es.
Playwright tests/e2e/section-visibility.test.ts (SV-01..SV-03):
SV-01 user toggle → nav reflects → /tasks redirects to /lists
SV-02 admin collective toggle → member sees it disappear in realtime
SV-03 collective ON beats user OFF — per-collective resolution
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the client mirror of section_enabled():
* SectionKey / FeatureFlags types + SECTION_KEYS constant in
@colectivo/types so the SQL and TS sides share the same vocabulary.
* apps/web/src/lib/stores/features.ts:
- currentUserFeatures writable, loaded by root layout
- enabledSections derived map { section → boolean }
- enabledSectionList ordered SectionKey[] visible to UI;
forces 'lists' if every layer says OFF
- setUserFeature per-user toggle (optimistic + rollback)
- setCollectiveFeature admin toggle (rolls back on RLS deny)
- realtime subscriptions one channel for the user's row, one
for the active collective; rebuilt
whenever those targets change
Wired into the root layout: loadCurrentUserFeatures runs from inside
the existing setTimeout-deferred onAuthStateChange callback (the
documented gotcha), and currentCollective.subscribe drives
subscribeCollectiveFeatures so collective switches re-target the
realtime filter without an extra auth event.
loadUserCollectives + every other place that fabricates a Collective
object now carries feature_flags (defaulting to {} for freshly created
collectives) so the Collective interface stays sound.
Integration spec (6 tests, SV-01..SV-06) covers default-ON, user-only,
collective-beats-user, RLS writes (user_update_own + collectives_update),
and per-collective isolation for a user in two collectives.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds JSONB `feature_flags` columns on public.users and public.collectives
(default '{}') plus two SQL helpers:
* public.known_sections() — IMMUTABLE, returns the canonical 4 sections
(lists, tasks, notes, search). Adding a section is a fresh migration.
* public.section_enabled(section, user, collective) — STABLE, returns
boolean. Precedence is COLLECTIVE > USER > default TRUE, evaluated as
a single COALESCE so Fase 13 can prepend a server layer as a one-line
diff. Unknown section keys default TRUE (forward-compat).
No new RLS policy: writes to feature_flags piggyback on users_update_own
and collectives_update (migration 003). 16 new pgTAP assertions verify
column shape, function signatures, volatility, and precedence semantics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fase 11 closed. 385 tests green (+26 vs Fase 10): pgTAP 96→104, Vitest
integration 151→157, Vitest unit 38→45, Playwright 74→79. 3 skipped
unchanged (2 upstream Realtime presence, 1 gated rate-limit).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a "Tags" section to /settings between Collectives and Account. Lists
every tag in the active collective with:
* inline rename — tap the chip, edit, Enter / blur to save
* recolor swatch row (8 preset dots, current colour outlined)
* delete button per tag (cascades item attachments via FK)
Uses the tagsStore which is already subscribed to item_tags realtime, so
edits from another tab / device update this view live without a reload.
No new screens; everything is inline in the existing settings layout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The drag handle + dndzone + reorderItems batch UPDATE were already wired
on the list detail view (Fase 5.10). One observable contract was missing:
when another client reorders an item, the local realtime UPDATE feed
shallow-merges the new payload onto the existing row but does NOT
re-position the row in the array — so the new order is invisible until
a reload. Fix: sort the `items` array by sort_order after every realtime
UPDATE / INSERT merge. Cheap (n is small per list) and matches the cold-
load order.
E2E (reorder-items.test.ts):
* RO-01: create 3 items, write a new sort_order via the same PATCH the
in-page reorderItems() helper does, reload, assert the reorder
persisted.
* RO-02: dual-context, Ana reorders, Borja sees the new order via
realtime within the suite's 10s window — without this commit's
sort fix the test failed because Borja's DOM order stayed at the
old order.
Drag emulation in headless Chromium is unreliable for svelte-dnd-action
(native HTML5 DnD). Both tests drive the same write path the page uses
(reorderItems = batch PATCH on sort_order) via fetch from inside the
browser context, using the session token the SDK has already stored.
The realtime + reload paths are unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the tag model into the list detail view end-to-end.
`/lists/[id]/+page.svelte`:
* switches the cold-load to loadListItems() so every row carries its
tag array via the PostgREST embedding (one query, not N+1)
* items state typed as ItemWithTags; the existing realtime shopping_items
feed shallow-merges the row payload back onto the cached tag array,
so attaches survive an UPDATE echo for the same row
* new tagLinkChannel subscribes to shopping_item_tags `*` events and
refreshes just the affected item's tags from the server — the delta
payload doesn't carry the joined tag rows, refetching is simplest
* item-row chips render below the name on a separate wrap-friendly
line; each chip is its own filter button (TagChip onSelect)
* filter bar between the title and the list shows the collective's
tags (active ones first); selecting toggles a tag; multi-selection
is intersection; the selection persists in `?tags=foo,bar` via
history.replaceState so the URL is deep-linkable
* edit overlay gains a "Tags" section powered by TagPicker — attach +
detach are optimistic and write through to shopping_item_tags; the
"Create" affordance creates the tag then attaches it in one click
Drag-reorder handlers were updated to merge `e.detail.items` (the dndzone
subset, which is the post-filter unchecked rows) with EVERY off-zone row,
not just the checked half. Without this, dragging while a tag filter is
active would drop hidden rows from `items`.
E2E (tags.test.ts): TG-01 verifies the full create → attach → filter
loop (3 items shown after activating the filter, seed items hidden); TG-02
deep-links via `?tags=a,b` and asserts intersection; TG-03 sanity-checks
the picker for a second user in the same collective (cross-collective
denial is fully covered by the Vitest IT-03 integration assertion).
All 3 e2e tests pass against the dev stack.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
TagChip — small pill rendering a tag's name with the colour from the 8-preset
palette. Three usage shapes (mutually exclusive):
* display-only (default)
* with onRemove → renders an inline X (used by picker-selected chips)
* with onSelect → renders the whole chip as a button (used by the lateral
filter bar and by item-row chips that filter the list on click)
Nested <button> was the SSR warning from the first cut — fixed by making
onSelect / onRemove mutually exclusive.
TagPicker — combobox-style picker driven by filterTagOptions (pure helper
in tag-picker-filter.ts so it can be unit-tested without rendering). Lists
matches by substring, surfaces a "Create `{query}`" affordance only when no
existing tag matches the trimmed query exactly. Enter key prefers toggling
the first match, falls back to creating. Re-usable from the item modal,
the list-filter bar, and the settings tag-management section.
tag-picker-filter.test.ts — 7 unit tests (TP-01..07) covering case-
insensitive substring match, empty query → full list, create affordance
gating on exact-match absence, exclusion of already-selected ids, and
createName preserving verbatim user input (no lowercasing).
tailwind.config.ts safelists the runtime-built bg-/text-/ring-/dot
combinations for all 8 preset colours so JIT doesn't strip them.
Messages: 9 new keys in en + es (tag picker, filter bar, settings section).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
apps/web/src/lib/stores/tags.ts — collective-scoped store for item_tags
with realtime subscription per active collective (resub on switch), plus
mutation helpers: createTag, renameTag, recolorTag, deleteTag, and the
many-to-many writers attachTag / detachTag for shopping_item_tags.
apps/web/src/lib/stores/lists.ts — new loadListItems(listId) issues a
single PostgREST embedding (`*, shopping_item_tags(item_tags(*))`) and
flattens the rows to ItemWithTags. Cast goes through `unknown` because
the curated database.ts doesn't declare the shopping_items ↔
shopping_item_tags relationship — runtime resolves it from the FK.
Tests: packages/test-utils/tests/rls-item-tags.test.ts adds 6 integration
assertions covering the policies an authenticated role sees: member
create (IT-01), guest reject (IT-02), non-member empty read (IT-03),
cross-collective attach reject (IT-04), unique violation (IT-05), and
cascade on item delete (IT-06).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 022 adds two new tables for collective-scoped item tags plus the
many-to-many bridge to shopping_items. Tags carry a colour from an 8-preset
palette enforced by CHECK. RLS uses is_member for SELECT (guests can see)
and is_active_member for writes (guests are read-only). A new STABLE helper
item_collective_id(uuid) resolves the parent collective in a single function
call so the shopping_item_tags policies do not chain two STABLE lookups.
Both tables join the supabase_realtime publication with REPLICA IDENTITY
FULL, matching the shape of 007 for shopping_items / shopping_lists.
pgTAP 014 covers schema invariants (publication, replica identity, unique
+ CHECK constraints, cascade behaviour for both tag delete and collective
delete). Per-role RLS semantics will be covered by the Vitest integration
suite in the next commit — pgTAP runs as postgres which bypasses RLS.
Types: ItemTag, ItemWithTags + ItemTagColor in domain.ts; item_tags,
shopping_item_tags, item_collective_id added by hand to the curated
database.ts (the supabase CLI is not installed locally; just db-types is
gated and skips with a notice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Records the closing state of Fase 10: spec coverage now 100% on the
H-series CUs, full FK on-delete audit table, 5 architectural decisions
(Keycloak not cleaned up on account delete, sole-member must dissolve,
type-the-name dissolve confirmation, client-side language detection,
trash via RPC). Bumps the MVP2 progress to 2/6 and the test totals
to 360 active + 3 skipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New $lib/utils/accept-language.ts parses Accept-Language strings
(server-style "es;q=0.9,en;q=0.8") and navigator.languages arrays,
returns 'en' or 'es'. Rule (plan §10.8.1): the highest-q-scored 'es'
entry wins if q >= 0.5; otherwise fall back to 'en'. Unknown
languages (e.g. fr) → 'en'. Pure function, 12 unit tests covering
bare tags, regional variants, q-score ordering, malformed input,
navigator.languages arrays, and the borderline q=0.5 case.
The root +layout.svelte calls maybeBootstrapLanguage() after each
SIGNED_IN: it only fires for genuinely new users (public.users row
created within the last 60s) whose language is still the default
('en'); it reads navigator.languages, runs the parser, and
UPDATEs public.users.language if the result is 'es'. setLanguageTag()
flips the live Paraglide runtime so the user lands on the next
page already in Spanish — no reload needed.
Established users keep their explicit choice (plan §10.8.5 / Riesgo
5). Server-side Accept-Language sniffing was rejected: the OIDC
dance lives outside SvelteKit (Keycloak ↔ GoTrue), so a hooks.server.ts
would never see those requests. navigator.languages is the
client-side equivalent and survives Paraglide's compile-time
tree-shaking.
LANG-01/02 (vitest integration): UPDATE public.users RLS contract
the bootstrap relies on — own row OK, other user's row rejected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A list shown at status=completed used to have its only Reset action
buried in the 3-dot menu, forcing the user back to /lists to start
over. Now the title row of /lists/[id] renders a prominent
"Reset list" pill button when status === 'completed' — reusing the
existing handleReset() handler, no new logic. The 3-dot menu entry
stays for the active and archived views.
RST-01 seeds a completed list with a checked item, clicks the new
button, verifies status flips back to active + the item gets
unchecked.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a new "+ New collective" entry at the bottom of the sidebar
collective-switcher dropdown (and the mobile drawer for parity).
Clicking it opens a CreateCollectiveModal that reuses the same
onboarding form — name + emoji grid — and calls the existing
create_collective() RPC (migration 012). On success, the new
collective is appended to $userCollectives, set active in
$currentCollective, persisted to localStorage, and the user is sent
to /lists.
Side effects:
- Switcher now opens even with a single collective (previously gated
on >1) so the entry is always reachable; the chevron is always
shown.
- DesktopSidebar gets a data-testid on the switcher button so the
Playwright suite can target it without text matching.
O-04 (rescued from Fase 7): Eva creates her first collective via
onboarding, then opens the switcher and creates a second one without
leaving /lists; both collectives end up in the switcher and the DB.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 019 normalizes every \`created_by\` / \`updated_by\` FK from
\`NOT NULL REFERENCES users(id) ON DELETE RESTRICT\` to
\`NULL REFERENCES users(id) ON DELETE SET NULL\`. Affected tables:
collectives, collective_invitations, shopping_lists, shopping_items,
task_lists, tasks, notes (created_by + updated_by). Without this,
delete_account() would be blocked the first time the user creates any
content. Spec §6.3: content survives the user; the row is orphaned with
the FK set to NULL (UI shows "Deleted user" — to be polished later).
Migration 021 adds delete_account():
- two-step delete: public.users (fires the existing promote-oldest-
admin trigger from migration 002, then CASCADEs collective_members
and SET-NULLs content FKs), then auth.users (CASCADEs sync_conflicts
+ drops the GoTrue identity row + token rows)
- guard: errcode P0003 when the caller is the sole admin of a
collective where every other member is a guest (nobody promotable);
the user must promote someone manually first
- Keycloak is NOT touched (documented limitation in the UI body); the
user can re-register with the same email and will receive a fresh
UUID-distinct profile
Settings UI (already shipped with 10.1) wires the modal: explicit body
listing what is and isn't deleted; confirm button only enables when the
user types DELETE exactly. Post-success calls logout() so the Keycloak
SSO cookie is ended too.
pgTAP 013 (8 assertions): RPC exists, regular user delete + cascade +
content-orphan, sole-admin-with-promotable promotes, sole-admin-with-
only-guest blocked with P0003. Playwright DEL-01 covers the
type-the-word UI guard; full "delete + re-login fails" is left to
pgTAP because seeding ephemeral Keycloak accounts in E2E would
contaminate every downstream suite.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 020 (019 reserved per plan) introduces two SECURITY DEFINER
RPCs that back the trash drawer's per-row actions:
- restore_list(uuid): clears deleted_at; requires caller to be admin
or member of the owning collective; rejects if the list is not
currently in trash (defensive)
- hard_delete_list(uuid): permanently deletes a soft-deleted list;
same role guard; rejects if the list is still active
Both close the existing UI gap where guest could in principle issue
the equivalent UPDATE (the FROM-UPDATE policy is is_active_member,
which excludes guest, but the surface area is broader than these two
intents — the RPCs make the contract explicit).
The lists drawer already exposed the Restore + Delete-for-ever
buttons; only the store calls swap from chained PostgREST writes to
.rpc() — the UI itself is unchanged.
pgTAP 012 (8 assertions) covers existence, restore happy path,
non-member rejection, hard-delete happy path, active-list guard.
Integration T-10..T-14 (5 vitest) verifies the wire format the store
relies on (rpc errors, row state, active-listing visibility).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 018 adds public.dissolve_collective(uuid):
- admin-only; non-admin (member/guest/non-member) → errcode P0002
- single DELETE from public.collectives; all child tables cascade
(collective_members, collective_invitations, shopping_lists →
shopping_items, task_lists → tasks, notes, item_frequency,
sync_conflicts — verified by audit, no schema change needed)
/collective/manage gains a "Danger zone" card (admin-only) with a
Dissolve button. The confirmation modal loads live counts of lists,
tasks and notes, displays them in the warning, and requires the user
to type the collective name exactly before the confirm button is
enabled. On success, locals stores drop the collective and the user
is sent to /lists (next collective) or /onboarding (none left).
pgTAP 011 (8 assertions): RPC exists, member/guest/non-member all
rejected with P0002, admin succeeds, collective row gone, child
list cascade-deleted, members cascade-deleted. Playwright D-01..D-03
cover the happy path, the type-the-name guard, and member visibility.
Auxiliary collective fixture (NOT the seed) per test — the seed
collective must survive every run for downstream suites.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Admin gets an editable name input + emoji picker at the top of
/collective/manage. Enter / blur / Save button all flush the PostgREST
UPDATE; Esc reverts to the persisted value. The response payload feeds
both $currentCollective and $userCollectives so the sidebar updates
without reload. Members and guests see no editing affordances at all
(the section is admin-gated; UPDATE policies were already in place from
Fase 2a so no migration is needed).
MC-01a: Ana renames + picks a new emoji, reload preserves both, DB
reflects the change. MC-01b: Borja never sees the editable fields.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 017 adds public.leave_collective(uuid) with three branches:
- normal member: remove membership row
- sole admin with other members: promote oldest-joined remaining member
to admin (inline; the existing user-delete trigger does not cover the
membership-delete path), then remove the row
- sole member: reject with errcode P0001 so the UI can direct the user
to the dissolve flow (CU-H08)
Settings page gains a "Your collectives" section listing the user's
memberships with a per-row Leave button; the confirmation modal calls
the RPC, drops the collective from local stores, and either switches
to the next collective or sends the user to /onboarding when none
remain. Also seeds the Danger zone scaffolding for Fase 10.5 and adds
all message keys consumed by 10.1, 10.3, 10.5 and 10.6.
pgTAP 010 (7 assertions): member-leave, sole-admin-leave + auto-promote,
sole-member rejected with P0001. Playwright L-01 walks Borja through
the UI flow + checks the seed collective survives (content stays).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- docs/history/fase-9-polish-dark-mode.md captures the final palette
(hex + contrast ratios), the four sub-fases (dark mode / reading
width / sync_conflicts / WebKit specs), the test-count delta
(259 → 300 + 3 skipped, +41 specs), the upstream-bug audit for
Realtime presence (#1617 still open as of 2026-05-18, latest stable
v2.86.3), and the prod-migration note (existing sessions do not
need a re-login because users.theme is queried, not in the JWT).
- CLAUDE.md project-status line flips Fase 9 to complete, bumps the
test totals to 300 green / 3 skipped, and adds a new "Testing"
gotcha for the WebKit project gate (RUN_WEBKIT=1 + libicu74 deps).
- plan/fase-9 marks the phase ✅ with a one-liner about new test counts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 9.4 (swipe-delete E2E in WebKit — repurposed to the current
swipe-to-check / swipe-to-uncheck gesture, since Fase 5.10 replaced
swipe-to-delete with a double-tap delete flow).
- playwright.config.ts gains a second project named "webkit"
(devices['iPhone 13']) gated behind RUN_WEBKIT=1 so the suite stays
green on hosts that do not have the GTK/WebKit system libs
(libicu74, libxml2, libmanette-0.2-0, libwoff1) installed — those
need `sudo pnpm exec playwright install-deps`. The chromium project
ignores `*.webkit.test.ts` so the same file never runs in both.
- Justfile adds `just playwright-install` (chromium + webkit) and
`just test-webkit` for the on-demand run.
- New tests/e2e/swipe-toggle.webkit.test.ts contains SW-01 + SW-02:
swipe right on an unchecked row marks it checked; swipe left on a
checked row marks it unchecked. The handlers in lists/[id]/+page.svelte
use unified pointer events, so the helper dispatches
PointerEvent('pointer{down,move,up}') with pointerType:'touch'
directly — works regardless of the project's hasTouch setting.
- lists/[id]/+page.svelte exposes data-checked and data-name on each
item-row to give the specs a robust locator without depending on
text/heading hierarchy.
Note: cannot validate the WebKit runs end-to-end in this sandbox
(missing libicu74/libwoff1 require sudo apt install). The spec is
ready to run under any CI image that has Playwright's standard
linux deps installed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 9.3 in full: migration 016 + pgTAP test 010 + queue.ts
conflict-detection path + /settings debug panel + integration test.
- Migration 016 adds public.sync_conflicts (append-only audit log of
last-write-wins conflicts surfaced by the offline mutation queue).
RLS allows owners to SELECT + INSERT their own rows only; UPDATE
and DELETE have no policy and are silently denied. collective_id
is intentionally nullable so user-level conflicts (theme, language
in future) also fit. Indexed on (user_id, created_at desc) for the
"show me my last 20 conflicts" query.
- queue.ts gains a `QueueContext` (currentUserId + collectiveId) and
an optional `preImage` field on UpdateOp. Before sending an UPDATE
the queue fetches the remote row (limited to the patched fields),
proceeds with the UPDATE (last-write-wins), then fires a best-effort
INSERT into sync_conflicts when the remote diverged. Failure to log
never blocks the UPDATE or pollutes the pending_ops queue. Three
unit specs (SC-01..SC-03) lock the contract in.
- sync/index.ts exposes setSyncContext(), called from (app)/+layout's
$effect whenever currentUser / currentCollective change.
- New SyncConflictsPanel.svelte renders the last 20 conflict rows
for the current user with an "Export JSON" button. Gated on
import.meta.env.DEV (or a forceShow prop for a future secret
unlock) — never ships in the production bundle. Slotted into
/settings just above the Account section.
- Integration tests (packages/test-utils/tests/sync-conflicts.test.ts,
4 SC-INT specs) exercise the real PostgREST + RLS contract end-to-end
with the existing seed users (Ana, Borja). Requires fake-indexeddb in
the test-utils dev deps (queue's IDB shim).
- pgTAP test 010 covers structure, RLS reads/writes, ownership rejection,
and the append-only invariant (UPDATE/DELETE return 0 rows).
- packages/types/src/database.ts adds the sync_conflicts table type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 9.2 (desktop reading width).
- (app)/+layout.svelte wraps detail routes (/lists/[id], /tasks/[id],
/notes/[id]) in a max-w-2xl mx-auto container at the md breakpoint
and up. Mobile (<md) keeps the existing full-width layout — the
wrapper only kicks in via md:max-w-2xl. The shopping session
(/lists/[id]/session) is explicitly excluded so the immersive mode
still occupies the whole viewport.
- A new readingRoutePattern lives alongside the existing
detailRoutePattern so the two concerns stay independently testable
(detail = hide global top bar; reading = cap width).
- RW-01..RW-03 in tests/e2e/reading-width.test.ts measure the
bounding box of [data-testid="reading-column"] under 1280px and
390px viewports, and confirm the wrapper is absent from /session.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 9.1.6 (settings UI), 9.1.7 (reactivity), 9.1.8 (Playwright E2E).
- /settings now has an "Appearance" section housing ThemeToggle. The
onChange callback fires an UPDATE on public.users.theme so the choice
follows the user across devices. The DB write is best-effort: a failed
UPDATE does not roll back the immediate localStorage + <html data-theme>
flip the toggle already performed.
- Root +layout.svelte calls initTheme() once on mount so the JS-side
stores hydrate from localStorage (the inline anti-FOUC script in
app.html has already painted the correct theme by this point — this
just rebuilds the JS state on top so reactive consumers see it).
- (app)/+layout.svelte gains a one-shot effect that reads
public.users.theme on first hydration and adopts the remote value when
it differs from localStorage. Defers the PostgREST query through
setTimeout(..., 0) per the Supabase auth-lock gotcha.
- tests/e2e/theme.test.ts adds T-01 (toggle persists across reload),
T-02 (system mode follows emulated prefers-color-scheme), and T-03
(refresh in dark mode never paints a light first frame, including on
/logged-out which lives outside the (app) group and relies entirely
on the inline app.html script).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the dark-mode plumbing:
- New $lib/theme module: themePreference + resolvedTheme stores plus
initTheme() / setThemePreference(). Owns localStorage persistence,
<html data-theme=...> mirroring, and the matchMedia listener that
re-resolves "system" when the OS preference flips. Storage-agnostic
(no Supabase coupling) so the caller persists to public.users.theme
separately.
- Inline anti-FOUC script in app.html reads localStorage + matchMedia
synchronously and writes data-theme before the first paint — refresh
in dark mode no longer flashes white.
- Tailwind switched to darkMode: ['selector', '[data-theme="dark"]']
so all existing `dark:` utilities resolve from the same attribute.
- :root and :root[data-theme=light] share the light token set; dark
tokens move into :root[data-theme=dark]. Tokens stay as RGB triplets
(rgb(var(--token) / <alpha>)) per the repo gotcha — no hsl().
- ThemeToggle.svelte: three-way radiogroup (Light / Dark / System) with
paraglide messages in EN + ES.
Tests (TDD): 8 new unit specs in src/lib/theme.test.ts cover defaults,
localStorage round-trip, data-theme reflection, system resolution
against matchMedia, invalid-value fallback, and OS preference change
propagation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds `theme text not null default 'system' check (theme in
('light','dark','system'))` to public.users. New users get 'system' so
the UI follows the OS preference until they opt in; existing rows
backfill via the default.
Includes pgTAP test 009 (8 assertions covering column existence/type,
default value, valid transitions, and the check constraint rejecting
unknown values). Wires the theme field into the hand-curated
database.ts type so PostgREST queries stay typed.
Also hardens `just db-types` to bail out (instead of truncating
database.ts) when the supabase CLI is missing — the recipe was silently
wiping the file on this dev machine.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stamps the six planned post-MVP fases as the MVP2 cycle (matching the
`mvp2` branch name). Each plan file gets an "MVP2 · N/6" header. README
status + index tables group them under an MVP2 bracket. CLAUDE.md project
status reframes the planned line as MVP2 and clarifies that Fase 7+8
shipped as MVP cleanup, not MVP2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds four planned-phase docs covering the next post-MVP roadmap:
- Fase 11 — item tags (collective-scoped) + drag-reorder UI for the
existing `shopping_items.sort_order` column. Migration 021.
- Fase 12 — section visibility via JSONB `feature_flags` on `users` and
`collectives`, with a `section_enabled()` SQL helper that resolves
precedence (collective > user > default ON). Migration 022.
- Fase 13 — `/admin` area with a global `server_admins` role, append-only
`admin_actions` audit log, RPCs for collective CRUD + member removal,
and server-level section-visibility defaults that sit above Fase 12.
Migrations 023+024. Depends on Fase 12.
- Fase 14 — PWA hardening: `onNeedRefresh` update toast, explicit offline
indicator + sync queue badge + conflict surface, `__APP_VERSION__` and
`__APP_COMMIT__` baked at build with a chip in `/settings`. No migration.
Depends on Fase 9.3 (`sync_conflicts` wiring).
Also bumps CLAUDE.md project status with the planned-fases pointer, and
brings README.md status + index tables in line with Fase 7, 8, 9, 10
(previously missing) plus the four new ones.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The stack no longer talks to any specific external proxy. Removed traefik.*
labels and the shared `traefik` external network from kong + app, and dropped
TRAEFIK_NETWORK / TRAEFIK_ENTRYPOINT / TRAEFIK_CERTRESOLVER from the .env
template and the deploy-script bootstrap.
Internal edge is a `caddy:2-alpine` container on the `colectivo` network,
publishing host port 3000. `infra/Caddyfile.erosi` does the path split:
/rest|/auth|/realtime|/storage|/graphql|/pg -> kong:8000, everything else
-> app:3000. Global block sets `auto_https off` + `admin off` so :3000 is
the only listener.
Whatever proxy fronts the host (NetBird's Traefik, nginx, anything) just
terminates TLS for erosi.limonia.net and forwards plain HTTP to
ambrosio:3000. Off-stack and out of this repo.
Docs (CLAUDE.md + docs/deployment.md) updated to describe the two-layer
edge. Includes the gotcha that bind-mounted file edits (Caddyfile) need
`docker compose restart caddy` since `up -d` won't recreate the container
on file-only changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Document what we learned doing the real NetBird+Traefik migration:
- Keycloak realm/client names are operator-chosen; the realm-export.json's
literal "colectivo-web" / "colectivo" values are illustrative, not required.
- Legacy Keycloak /auth/ base path: PUBLIC_KEYCLOAK_URL must include the
suffix when the deployment serves realms under /auth/realms/... (hit this
on auth.fosil.eu). Verify with the discovery URL returning 200.
- NetBird's installer deploys Traefik with idleTimeout=0 (unlimited) by
default — verify instead of prescribing 3600s.
- Runbook: --no-cache fixes the intermittent vite SSR "transforming..."
hang that surfaces as a PostHog shutdown timeout.
- Runbook: any PUBLIC_* change needs an app rebuild (build args); secret
changes only need `docker compose restart auth`.
- Runbook: TRUNCATE recipe for wiping all app + auth data while keeping
schema + migration tracking intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ambrosio's TLS is now handled by the Traefik that NetBird's self-hosted
installer deploys. kong + app attach to that Traefik network and expose
themselves via Docker-provider labels (erosi-kong priority 100 for
Supabase API paths, erosi-app priority 1 for everything else on
erosi.limonia.net). No container publishes host ports; host Caddy is
out of the deploy path permanently.
Keycloak is no longer bundled — PUBLIC_KEYCLOAK_URL points at an
external IdP. The deploy script writes .env with FILL_IN_* placeholders
for PUBLIC_KEYCLOAK_URL, KEYCLOAK_CLIENT_SECRET, and TRAEFIK_NETWORK,
and fails fast until they are filled.
New domain: erosi.limonia.net. realm-export.erosi.json is retained
as reference for the external Keycloak operator; it is no longer
imported by this stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fase 9 bundles dark mode as the flagship feature plus the four polish items
parked from Fase 5/6 (sync_conflicts wiring, presence avatars still blocked
on supabase/realtime #1617). Fase 10 closes the completion debt found in the
2026-04-22 audit: CU-H06/H07/H08 (leave/dissolve/transfer admin), trash
restore, reset-from-detail, sidebar create-collective, account deletion, and
automatic language detection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated via /graphify over the full tree (177 files, ~320k words).
Produces graph.html, graph.json, GRAPH_REPORT.md plus per-file
extraction cache so incremental --update runs reuse prior results.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLAUDE.md status line + logout paragraph + migration 013 gotcha updated
to cover the INSERT+UPDATE two-step GoTrue behaviour and the RP-initiated
logout flow; new SvelteKit gotcha block spelling out the signOut→$effect
race that produced "PKCE code verifier not found in storage" and how the
isLoggingOut flag + /logged-out public route close it.
- docs/history/fase-8-auth-logout.md captures both prod bugs with symptom,
root cause, fix, the Keycloak id_token_hint tradeoff (why the one-click
confirmation page is accepted UX), and the test deltas.
- docs/development.md auth.ts one-liner updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking logout previously only called supabase.auth.signOut(): Keycloak's
SSO cookie was untouched, so the (app) layout's $effect immediately
re-fired login() and Keycloak silently re-authenticated — logout looked
broken. That same chain also surfaced "PKCE code verifier not found in
storage" on prod, since signOut()'s async storage clear could race
signInWithOAuth()'s verifier write, or the $effect could double-fire and
overwrite verifiers mid-flight.
- logout(): signOut() then redirect to Keycloak's end_session endpoint
(client_id + post_logout_redirect_uri=/logged-out). Keycloak shows a
one-click "Do you want to log out?" confirmation because GoTrue's proxy
flow does not expose the id_token, so we can't pass id_token_hint.
- isLoggingOut flag + guard in (app) layout $effect: prevents auto-relogin
during the logout navigation.
- New public /logged-out route, outside the (app) group.
- A-05 rewritten, new A-07 (fresh login must prompt for credentials after
RP-initiated logout).
- pgTAP 008 extended with 4 assertions for migration 014's UPDATE trigger.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GoTrue v2.158.1 emits an UPDATE right after inserting an OIDC-provisioned
user (pop ORM resends the full struct incl. the zero-value role=''),
which bypassed migration 013's BEFORE INSERT trigger and left prod users
with role='' — breaking every PostgREST call with `role "" does not exist`.
Add a matching BEFORE UPDATE trigger reusing the same guard function, and
re-backfill the rows already clobbered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>