Commit Graph

132 Commits

Author SHA1 Message Date
d1f6d3a5d6 release(v1.0.0): cut v1.0.0 release from Fase 17–20 cycle
- Bump package.json from 1.0.0-rc.0 to 1.0.0.
- Move all [Unreleased] entries (Fases 18/19/20) into a new
  [v1.0.0] — 2026-05-19 section. Add a corresponding Fase 17 entry
  (versioning + CHANGELOG tooling itself) that wasn't in [Unreleased]
  because the linter wasn't wired during 17's own work.
- Tag v1.0.0 (local only; not pushed) immediately after this commit.

Closes the v1.0 cycle:
- Fase 17 — versioning + CHANGELOG + about modal + deploy tag check.
- Fase 18 — required list title + #N auto-numbering + default + catalog.
- Fase 19 — emoji picker with food + animals categories.
- Fase 20 — Euskera locale (machine-translated seed pending review).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 04:22:19 +02:00
d3b4187119 docs(fase-20): history doc + CHANGELOG [Unreleased] entry
History doc covers: ENUM-vs-CHECK rationale (plan was written against
the wrong primitive — actual gate is the language_code ENUM from
migration 001), machine-translation seed + [needs review] marker,
ICU-placeholder parity verification, locale-keys diff script
contract, and the ParaglideJS {#key lang}-on-navigation cross-cutting
gap that constrained the shape of EU-01.

Test deltas: pgTAP +5, integration +2, unit +4, e2e +2 — 581 + 3
skipped (was 568 + 3). Documents the pre-existing 010_sync_conflicts
flake (offline.test.ts e2e contamination) as out of scope for Fase 20.

CHANGELOG gains the [Unreleased] entry. Moves into [v1.0.0] in the
release commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 04:16:19 +02:00
878402e579 feat(fase-20): /settings language selector gains 'Euskera' + EU-01/02 e2e
Adds the third button to the language tray in /settings; the local state
union widens to 'en' | 'es' | 'eu' (matches the new LanguageCode). Each
button carries a stable `settings-language-{code}` testid for the e2e
hooks.

Playwright `tests/e2e/euskera.test.ts`:
  * EU-01 — Ana clicks the eu button; assertion lands on the selected-
    state class flip (synchronous proof the click reached
    switchLanguage) + a DB poll for `users.language='eu'`. We
    deliberately don't assert on the sidebar nav label 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 — a known cross-cutting gap outside Fase 20's
    scope.
  * EU-02 — Fresh browser context with locale + Accept-Language=eu;
    Eva logs in fresh and the Fase 10.8 bootstrap path UPDATEs
    users.language to 'eu'. /onboarding renders 'Ongi etorri…' — the
    Basque heading the seed produces. Cleanup restores Ana to seed 'es'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 04:00:35 +02:00
e9c29dcf38 feat(fase-20): accept-language + bootstrap recognise 'eu'
Extends the Fase 10.8 Accept-Language parser to surface `'eu'` as a third
known primary subtag, gated on the same q ≥ 0.5 threshold the existing
`'es'` path uses. `'en'` continues to short-circuit because it's the
source language. `AppLanguage` widens to `'en' | 'es' | 'eu'`.

Tests:
  * Unit (apps/web): LANG-U-EU-01 bare 'eu', LANG-U-EU-02 'eu-ES',
    LANG-U-EU-03 'eu-FR;q=0.5,en;q=0.4' (borderline q wins), LANG-U-EU-04
    navigator.languages array — 4 new specs, all green.
  * Integration (packages/test-utils): LANG-INT-EU-01 user updates own
    language=eu, LANG-INT-EU-02 user attempts language='fr' and gets the
    Postgres 22P02 from the ENUM (cast through `as unknown as` so the
    narrow TS type doesn't block the test).

The integration negative case lands on the language_code ENUM gate from
migration 028, not RLS — it would still fail even from a service-role
client.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:54:31 +02:00
39ec7996c5 feat(fase-20): eu.json seed + Paraglide config + locale parity check
Seeds apps/web/messages/eu.json with hand-translated Basque values for
every key in en.json/es.json (362 keys, $schema excluded). Each value
carries the ` [needs review]` suffix marker per plan §20.2.1 so a
native speaker can sweep through and remove markers in a post-release
pass. ICU placeholders ({name}, {count}, {n}, {version}, …) preserved
verbatim — none move position relative to the source value.

Adds `eu` to project.inlang/settings.json so the Paraglide vite plugin
emits `src/lib/paraglide/messages/eu.js` on next build, and the runtime
`availableLanguageTags` widens to ["en","es","eu"]. The compile output
is gitignored as before; verified by re-running the compile CLI locally.

New `scripts/check-locales.mjs` (dependency-free Node script) compares
every non-source bundle's key set against en.json and fails on any
missing/extra keys. Currently: eu = 362 keys, es = 362 keys, both match.

Note on translation provenance: machine/LLM seed at mid-2026 quality.
The plan calls out euskera SOV ordering risks around placeholders —
spot-checked the 12 keys with placeholders ({name}, {count}, {n},
{version}, {date}, {commit}, {word}, {title}, {days}, {lists}, {tasks},
{notes}, {section}); placeholders stayed in source-comparable positions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:52:36 +02:00
ee13e7bc9d feat(fase-20): migration 028 — language_code ENUM gains 'eu'
Extends public.language_code with the Euskera (Basque, ISO 639-1) label.
Plan §20.1 was written against a CHECK-constraint mental model; the
actual gate is the ENUM from migration 001, so the right primitive is
ALTER TYPE … ADD VALUE. End-user contract is identical — 'eu' accepted,
'fr' rejected — and rejection surfaces as the canonical 22P02
invalid_text_representation error.

pgTAP `supabase/tests/020_users_language_eu.sql` (5 assertions, LANG-EU-
T01..T05) pins both ends: ENUM membership and UPDATE round-trip on Ana.

Hand-curated database.ts widened to `'en' | 'es' | 'eu'`. The supabase
CLI isn't on this host, so db-types regen is the manual edit per the
recipe's fallback path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:47:41 +02:00
9068ac4586 docs(fase-19): history doc + CHANGELOG [Unreleased] entry
Records: shipped 2026-05-19, 359 codepoints across 4 categories,
Unicode 14 baseline (iOS < 17 tofu protection), lazy-render by tab,
bundle delta +5,133 bytes uncompressed JS (well under +12 KB target).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:40:59 +02:00
2f01eb9cb0 feat(fase-19): wire EmojiPicker into 4 integration sites + EP-E specs
Replaces the four legacy hardcoded emoji arrays (settings avatar,
onboarding-create, CreateCollectiveModal, manage-collective rename) with
`<EmojiPicker selected={…} onSelect={…} />`. The shared component
delivers ~280 codepoints across 4 tabs instead of the previous 8–24
per surface, with consistent layout and behaviour.

Backwards-incompatible testid change on the manage page: the old
`collective-emoji-option-{e}` per-cell testid is gone — every cell now
lives under the picker's `emoji-picker-cell-{e}` namespace. Two existing
specs (onboarding O-02 + rename MC-01a) are updated to drive the new
tab → cell flow.

New e2e file `tests/e2e/emoji-picker.test.ts` covers EP-E-01..02:
- EP-E-01 — Ana on /settings picks 🍎 from "Comida"; DB row updates.
- EP-E-02 — Eva on /onboarding creates a collective with 🐱 from
  "Animales"; sidebar reflects the choice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:19:56 +02:00
a075a792d6 feat(fase-19): EmojiPicker component with tabbed lazy-rendered grids
4-tab tablist + WAI-ARIA grid for the active category only. Switching
tabs unmounts the previous category's cells so the DOM never holds more
than ~140 buttons at once (animals is the worst case). Cells use roving
tabindex (first cell tabindex=0, rest -1) per the grid pattern; arrow
keys / Home / End walk focus cell-by-cell, the tablist receives a single
Tab stop.

Covers EP-U-01..05 (tab count + role, default selected, click→onSelect,
tab switch re-renders grid, ArrowRight roving focus).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:13:24 +02:00
ae6ceef34f feat(fase-19): emoji catalog data module + EC unit tests
Hardcoded `EMOJI_CATALOG: Record<EmojiCategory, readonly string[]>` with
four categories (faces 64 / food 119 / animals 140 / objects 36 = 359
codepoints). Unicode 14 baseline, no ZWJ sequences, no skin-tone
variants so every glyph renders consistently on iOS ≥ 16 + Android.

Covers EC-U-01..04 from the plan: size lower bounds on food + animals,
no intra-category duplicates, no cross-category overlap (the picker
draws one tab at a time so a duplicate would render only in the first
tab silently and confuse the "selected" state on tab switch).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:11:44 +02:00
f7abba4888 docs(fase-18): history doc + CHANGELOG [Unreleased] entry
Records the four asks shipped in Fase 18 — required list title, auto-
numbered prefix from a catalog match, admin-curated catalog + per-
collective default — alongside the migration / pgTAP / store / UI /
e2e changes that landed across the cycle. Notes the four design
decisions worth remembering (DB stays #N-agnostic, no unique
constraint, auto-suffix only on catalog hits, bare-hash refuses to
autonumber) plus the explicit out-of-scope items.

CHANGELOG.md gains a single `[new]` line under `[Unreleased]` per the
orchestrator's spec.

Final test counts after Fase 18:
  pgTAP        191  (was 178; +13)
  Integration  185  (was 179; +6)   +3 skipped
  Unit          75  (was 56;  +19)
  Playwright   104  (was 100; +4)
  Gated          1  (unchanged)
  Total        555  +3 skipped       (was 514 +3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:46:18 +02:00
c0db382ae2 feat(fase-18): /collective/manage — suggested titles subsection (default + catalog)
Adds the admin-side surface for the Fase 18 list-title flow:

  • `default_list_title` input — single line, save-on-blur (matches the
    collective-name input shape from Fase 10.2). Optimistically patches
    `currentCollective` so the next /lists modal prefill sees the value
    without waiting for the realtime UPDATE to round-trip.
  • Curated catalog list — one row per entry with an inline "Remove"
    icon button. An "Add title" button opens a small modal (name input
    + Save) that calls `add_list_title` via the listTitles store.

Members see the whole section read-only — the input is `disabled`, the
"Add title" button is hidden, and a banner explains "Only admins can
curate these titles." Guests don't see the section at all (mirrors the
`canSeeCommonItems` gate from Fase 15).

The default-title `$effect` that mirrors `$currentCollective.default_list_title`
into the input draft is guarded against re-firing while the admin is
mid-edit — only sync when the input isn't focused — so realtime UPDATEs
from another tab don't clobber the unsaved edit.

E2E `tests/e2e/list-title-flow.test.ts` gains:
  • LTF-02 — Ana adds the catalog prefix via the manage UI, sets the
    default via the same UI, then drives the create-modal three times
    and asserts the third list lands as `${prefix} #3`. Exercises the
    full path: manage RPC → realtime/store update → modal auto-suffix
    on submit → numbered list created.
  • LTF-04 — Borja (member) opens manage, sees the section, no add
    button, read-only banner visible, default-title input disabled.

Tests: 4 LTF e2e + 4 existing manage-collective e2e all green (8/8).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:33:36 +02:00
4ce24a4145 feat(fase-18): CreateListModal replaces empty-create-then-rename flow
`CreateListModal.svelte` is the new entry point for "New list" — opens
on the masthead button click, prefills the input with
`currentCollective.default_list_title` (Fase 18.3.1), shows an inline
autocomplete dropdown via `fetchTitleSuggestions`, and renders a
"Sugerencia: Compra #6" chip when (a) the typed value exactly matches a
catalog-curated prefix case-insensitively and (b) `computeNextNumber`
resolves a value (Fase 18.3.2). Submit is disabled while the trimmed
value is empty or creation is in flight (Fase 18.3.3). On submit, if the
catalog match still holds, the modal auto-suffixes "#N" before
inserting and the lists page shows a transient toast confirming the
final name (Fase 18.3.4).

The prefill effect is gated by a `wasOpen` edge-tracker — a naive
`$effect(() => { if (open) value = default })` re-runs whenever
`$currentCollective` updates (e.g. a realtime UPDATE landing after the
user typed something), which clobbered the typed value and froze the
submit button as disabled. The edge guard limits the prefill to the
false→true transition.

`/lists/+page.svelte`: the masthead "New list" button now opens the
modal instead of calling `createList(collective, '', user)` and goto-ing
to the detail page for inline rename. The legacy flow was nice for
quick creates but couldn't enforce a required title or surface the
catalog. Existing per-list actions are unchanged.

15 new i18n strings (en + es) for the modal, the suggestion chip, the
auto-numbered toast, and the manage-page subsection that ships in the
next commit.

Updated existing e2e specs: `lists.test.ts`'s `createList()` helper +
the C-13 guest path now drive the modal instead of the legacy
placeholder input; `session.test.ts` S-03 uses the modal for the
fresh-list setup. New `tests/e2e/list-title-flow.test.ts` adds LTF-01
(default prefill end-to-end with a Supabase-driven setDefaultListTitle
since the manage UI lands in the next commit) and LTF-03 (empty input
keeps submit disabled, even with whitespace).

Tests: 4 lists + 3 session + 2 new LTF specs all green (9/9).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:29:17 +02:00
3c4551acd9 feat(fase-18): listTitles store + numbering utils + unit + integration
`$lib/utils/list-title.ts` is the pure module — `parseTitle` extracts
{prefix, number} from a typed value, `nextNumberFromNumbers` is the
trivial `max + 1`, and `computeNextNumberFromNames` walks a row list,
counts a bare-prefix match as N=0, and falls back to 1 when nothing
matches. The regex requires whitespace between prefix and `#` so
"Compra#5" stays literal (the chip only fires for catalog-curated
prefixes). 19 unit tests cover LT-U-01..05 plus seven boundary cases
(multi-word prefixes, case-insensitive matching, substring near-miss,
trimming, empty input, bare-hash rejection).

`$lib/stores/listTitles.ts` wraps the two SECURITY DEFINER RPCs
(add/remove) and exposes `loadTitleCatalog`, `setDefaultListTitle`
(plain UPDATE on `collectives` — RLS already gates UPDATE to admins),
`fetchTitleSuggestions` (catalog ∪ last 10 distinct shopping_lists.name,
dedup case-insensitive, cap 15) and `computeNextNumber` (driver for the
SQL `name.ilike.<prefix>,name.ilike.<prefix> #%` filter that feeds the
pure helper). Race-condition note in the docstring points back to the
migration header.

Integration test `list-title-flow.test.ts` (6 cases): admin add round-
trips into the catalog (LT-INT-01), Borja's member-attempt yields P0002
(LT-INT-02), the computeNextNumber math is end-to-end exercised against
the dev DB with 3 seeded "Compra #1/#2/#5" rows → 6 (LT-INT-03), the
no-matches path returns 1 (LT-INT-04), admin remove deletes the row
(LT-INT-05), and an empty-title insert is rejected with P0001 (LT-INT-06).
The pgTAP suite can't drive these because postgres bypasses
auth.uid — same caveat documented in Fase 15's common-items.test.ts.

Tests: 75 unit (was 56; +19), 185 integration (was 179; +6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:11:34 +02:00
bd9a0a6d3f feat(fase-18): migration 027 — list title catalog + per-collective default
Adds `collectives.default_list_title` (nullable text) plus a new
`collective_list_titles (collective_id, title)` table — admins curate via
two SECURITY DEFINER RPCs (`add_list_title` / `remove_list_title`, P0002
on non-admin, idempotent on conflict). Members read; direct writes are
all-deny RLS, matching the item_frequency pattern. Table is added to the
`supabase_realtime` publication with REPLICA IDENTITY FULL so the manage
UI sees admin edits live across browsers.

Numbering rules (#N suffix) stay client-side intentionally — the DB is
agnostic about the format so future "YYYY-MM" or other prefixes don't
require a schema migration. The two-clients-race-on-#6 case is accepted
(documented in migration header); a unique constraint would block
deliberate duplicates.

Wires `default_list_title` through every Collective construction site:
loadUserCollectives in root layout, onboarding, CreateCollectiveModal,
features.ts realtime subscriber, and the manage-collective patcher. The
hand-curated `database.ts` gets the new column, table, and two RPC
signatures.

pgTAP `019_list_title_catalog.sql` (13 assertions): schema invariants on
the column + table, PK shape, RLS posture, two-policies count, RPC
existence + SECURITY DEFINER flag, realtime publication membership, and
the default-NULL behaviour. Role-gate denial is covered by the upcoming
list-title-flow integration suite (postgres bypasses auth.uid inside
pgTAP, same caveat as Fase 15's 018_item_frequency_weight.sql).

Tests: 191 pgTAP (was 178; +13).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:03:41 +02:00
4832981e25 docs(fase-17): history doc + bump version to 1.0.0-rc.0
Closes Fase 17 (v1.0 · 1/4). Bumps package.json `version` to
`1.0.0-rc.0` — picked up by apps/web/vite.config.ts's `__APP_VERSION__`
define, so the next built bundle reads it. The plain `1.0.0` tag is
reserved for the close of Fase 20 (Euskera locale) per the v1.0 plan.

Updates CLAUDE.md to mark the v1.0 cycle as in-progress (1/4 ) and
adds the per-fase history docs (Fase 13–17) to the documentation map.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:48:09 +02:00
489fc3ec87 feat(fase-17): deploy script HEAD-tag check + tag column in deploy log
deploy-erosi.sh now runs `git describe --exact-match --tags HEAD` up
front. When HEAD is not at a tag, prints a yellow warning and sleeps
5s so the operator can Ctrl-C and tag before the rsync starts (no
abort — keeps untagged hotfix deploys possible). When a tag is
present, surfaces it in the banner next to the SHA.

`.deploys.log` gains a 4th tab-separated column: the tag (empty when
absent). The 3-column compat for pre-Fase-17 rows is preserved — the
rollback script's existing `cut -f1/-f2/-f3` keeps working, and `cut
-f4` returns the empty string for the older rows.

rollback-erosi.sh:
- `--list` reformats each row through awk so the tag is visible (with
  "(no tag)" placeholder for older rows), falling back to the raw log
  when awk isn't on PATH.
- The target banner shows `git tag: <tag>` when present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:21:23 +02:00
f549dc53a2 feat(fase-17): /settings About section opens ChangelogModal
Adds a discreet "Ver historial" text-button under the existing version
chip in `/settings` › About. The button toggles a local `showChangelog`
$state that drives the ChangelogModal mounted at the bottom of the
template, after every other modal so it stacks correctly.

Playwright CL-01 verifies the trigger → modal → CHANGELOG header path,
CL-02 verifies Escape closes the modal. Both pass against the bundled
?raw import (no fetch, exercises the production load path).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:20:00 +02:00
d11cf6ccef feat(fase-17): ChangelogModal component + unit tests
ChangelogModal renders the repo-root CHANGELOG.md (bundled at build
time via Vite's ?raw query, 5 levels up from the component file) in
an overlay+panel modal. Markdown is parsed inline (`#`/`##`/`###`
headers, `-`/`*` lists, `**bold**`) with HTML-escape on every input
line — input is static repo content so the surface is closed.

Esc and overlay-click both call `onClose`. The keydown listener is
attached only while `open` is true via a $effect.

Three new Paraglide messages (en/es): `settings_about_view_changelog`,
`changelog_modal_title`, `changelog_modal_close`.

Unit tests (CHM-U-01..04) follow the Spinner.test.ts pattern (Svelte 5
mount/unmount + flushSync for the Escape spec).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:17:08 +02:00
cb223a0c29 feat(fase-17): CHANGELOG.md backfill + Keep-a-Changelog linter
Adds repo-root CHANGELOG.md in Keep-a-Changelog format with a single
[v0.0.0-beta] section that catalogues every shipped feature across
Fases 0–16 (58 bullets, [new]/[fix]/[tweak] prefixes, grouped by area).
The companion linter at scripts/check-changelog.mjs enforces (a) the
Keep-a-Changelog header, (b) the `## [vX.Y.Z] — YYYY-MM-DD` heading
shape on every release, and (c) the three-prefix rule on every bullet.
Wired into `just check` so a malformed CHANGELOG fails the check gate
without blocking commit hooks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:11:57 +02:00
427fe33c9c docs(plan): v1.0 cycle — fases 17–20 (versioning + list flow + emojis + euskera)
Four planned-fase docs for the first tagged release.

- Fase 17 — semver git tags + CHANGELOG.md (Keep-a-Changelog, [new]/
  [fix]/[tweak] prefixes), one big v0.0.0-beta backfill block covering
  Fases 0–16, About-modal in /settings rendering bundled CHANGELOG via
  ?raw import, deploy-script tag check + tag column in .deploys.log.
- Fase 18 — shopping list flow: required title (client + DB tighten),
  per-collective per-prefix #N auto-numbering parsed from typed title,
  collectives.default_list_title, collective_list_titles catalog + admin
  RPCs, autocomplete union (catalog + last-10). Migration 027.
- Fase 19 — EMOJI_CATALOG with faces/food/animals/objects categories
  (~280 codepoints, Unicode 14 baseline for iOS<17 compat), new
  EmojiPicker.svelte replacing the 8-emoji hardcoded selector at 4
  sites. No migration.
- Fase 20 — Euskera locale: messages/eu.json via machine-translate
  seed flagged with [needs review], users.language check constraint
  extended to ('en','es','eu'), accept-language parser + selector in
  /settings. Migration 028. v1.0.0 tag lands at the end of this fase.

README + CLAUDE.md updated with the v1.0 cycle pointers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:01:34 +02:00
1638d2f6e8 deploy(prod): NOTIFY pgrst reload schema after migrations + after rollback
Post-deploy, PostgREST keeps its pre-migration pg_proc snapshot and
404s any RPC added in the just-applied migrations until the rest
container restarts. Confirmed on 2026-05-18 after migration 026
(set_item_frequency_weight + purge_item_frequency) — the "Purgar"
button in /collective/manage returned "Could not find the function
public.purge_item_frequency in the schema cache".

`NOTIFY pgrst, 'reload schema'` triggers the same reload path the
container would do on restart, but without a 2s blip in flight requests.
Added at the end of the migration loop in deploy-erosi.sh, and at the
end of the rebuild step in rollback-erosi.sh (rollback can change RPC
shapes too).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 00:15:06 +02:00
abfb64b177 fix(pwa): add manifest link in app.html so Android Chrome shows install prompt
The plugin generated /manifest.webmanifest and the SW precached it, but
nothing in app.html linked to it. Chrome's install criteria require a
discoverable `<link rel="manifest">` in the document head — without it,
the address-bar install affordance never appears and the
beforeinstallprompt event never fires.

iOS Safari's "Add to Home Screen" already works via apple-touch-icon
alone, but with the manifest linked it now uses the manifest's name +
short_name + theme color as well.

All other install criteria were already in place: HTTPS, service worker
with skipWaiting+clientsClaim, 192/512 + maskable icons, iOS meta tags.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:22:30 +02:00
ae4fd45b99 deploy(prod): pre-deploy DB backup + paired code/DB rollback skill
Every `just deploy` now takes a pg_dumpall to
/opt/colectivo/backups/predeploy-<ts>-<sha>.sql.gz BEFORE rebuilding the
app or applying migrations, aborts the deploy if the dump is < 1 KiB,
and (on success) appends `<iso-ts> \t <sha> \t <backup-file>` to
/opt/colectivo/.deploys.log on ambrosio. Keeps the newest 10 backups
(prunes older predeploy-* files).

New `infra/scripts/rollback-erosi.sh` reads .deploys.log and pairs a
code rollback with a DB restore atomically:

  just rollback-list          # show recent deploys
  just rollback               # roll back to N-1
  just rollback-to <sha>      # roll back to a specific deploy
  just rollback-code          # roll back code only, keep current DB

Rollback safety:
- 5-second abort window.
- Verifies the target SHA exists locally + the backup is still on prod
  (warns if it's been pruned past the 10-deploy window).
- Uses a temporary git worktree so the user's working tree isn't
  disturbed.
- Stops app/auth/rest/realtime/storage before the gunzip|psql restore.
- Rebuilds the app image at the rolled-back SHA with the same GIT_SHA
  build-arg path the deploy uses (so __APP_COMMIT__ in the bundle
  matches the running code).
- Does NOT write a new .deploys.log entry — rollback is intentionally
  not a deploy event; the next `just deploy` is from current HEAD.
- Storage volume (/var/lib/storage user uploads) is NOT rolled back.

Justfile `deploy` recipe repointed from the stale `deploy.sh` (which
referenced GHCR pull) to `deploy-erosi.sh` (the active prod path).

New project-scoped skill: `.claude/skills/deploy/SKILL.md` documents the
flow + safety boundaries for Claude-assisted invocations. `.gitignore`
keeps `.claude/settings.local.json` ignored but tracks `.claude/skills/`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:57:25 +02:00
c0e5b5ed7f feat(sidebar): manage-collective link as last nav entry with spacer
The DesktopSidebar previously had no entry point for /collective/manage,
making member-management and the new common-items / tags subsections
unreachable from the chrome on desktop. The MobileDrawer already linked
it from inside the collective switcher block; desktop didn't.

- Adds a `<div aria-hidden>` divider with `my-2 border-t` to separate
  the section nav (lists / tasks / notes / search) from the manage
  entry.
- Adds a `Users`-iconed link to `/collective/manage` as the last nav
  item, with the same active-state styling as the other entries.
- Not gated by `$enabledSections` (membership management is orthogonal
  to feature toggles — same call as the Admin entry in the footer).
- Reuses the existing `m.manage_title()` Paraglide string; no new
  strings.

Tests: tests/e2e/sidebar-manage-link.test.ts adds SBM-01 (link visible
and navigates) + SBM-02 (divider present in <nav>).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:47:03 +02:00
40a59ef581 docs(mvp2): close out — 8/8 fases shipped, 505 tests green
Fase 16 (loading spinner) lands; MVP2 cycle complete.

Final test totals after MVP2 (Fase 9 → 16):
- pgTAP:               177 (was 45 → +132)
- Vitest integration:  179 (was 140 → +39)
- Vitest unit:          52 (was 15  → +37)
- Playwright e2e:       96 (was 59  → +37)
- Rate-limit gated:      1
- Total active:        505 (was 259 → +246)
- Skipped:               3 (unchanged)

12 new migrations (015 → 026). 10 new pgTAP files (009 → 018).
8 history docs under docs/history/fase-{9,10,11,12,13,14,15,16}-*.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:45:16 +02:00
666b4802a9 docs(fase-16): history record — loading spinner
Fase 16.Z. Documents the component decisions, the five integration
sites, the unit + e2e test surface (5 + 3 specs), the vitest browser-
condition tweak, the Supabase storage-key derivation gotcha used by
SP-E-03, and the cumulative MVP2 test count (497 → 505).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:06:36 +02:00
fef186c1cb feat(fase-16): integrate Spinner at the 5 loading sites + E2E coverage
Fase 16.2 + 16.3 — replace the bare `m.loading()` text at every
pre-existing async-loading site with `<Spinner size=...> + <span>`.
Text is kept (visual fallback + tooltip / SR redundancy). No new
loading semantics — only existing flags get a visual indicator.

Sites:
- (app)/+layout.svelte: auth splash gets `size="lg"` centred under the
  loading label.
- (app)/search/+page.svelte: inline `size="sm"` next to the search-in-flight
  copy.
- (app)/notes/archive/+page.svelte: inline `size="sm"` next to the
  archive-load copy.
- lib/components/CreateCollectiveModal.svelte: inline `size="sm"` inside
  the disabled Save button while the create RPC is in flight.
- (app)/collective/manage/+page.svelte: three sites — members-list load,
  invitation-generate button, and add-common-item Save button.

E2E (tests/e2e/spinner.test.ts):
- SP-E-01 throttles search_in_collective RPC by 500ms and asserts the
  spinner is visible in the loading paragraph.
- SP-E-02 throttles set_item_frequency_weight RPC and asserts the spinner
  is scoped inside the Save button.
- SP-E-03 seeds an expired `sb-192-auth-token` so GoTrue is forced to
  call /auth/v1/token, then hangs that endpoint indefinitely and
  asserts the splash spinner is visible. Storage key derivation matches
  Supabase's `sb-${hostname.split('.')[0]}-auth-token` default; we
  populate both the LAN-IP variant and a loopback fallback for
  resilience.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:54:26 +02:00
f01dc21e74 feat(fase-16): add Spinner component + unit tests
Fase 16.1 — reusable inline loading indicator. SVG circle with
stroke-dasharray + Tailwind animate-spin (respects motion-reduce).
Sizes: sm=16, md=24, lg=40. Inherits currentColor so it plays nicely
with the Fase 9 theme tokens. Wrapper has role=status + aria-live=polite
and an sr-only m.loading() label (no new Paraglide strings).

vitest.config: resolve.conditions=['browser'] + inline svelte so the
Svelte 5 mount() API resolves to index-client.js instead of the server
entry. Without it the new Spinner.test throws lifecycle_function_unavailable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:39:40 +02:00
51e62ecba3 docs(plan): add Fase 16 — loading spinner (MVP2 reopened to 8 fases)
Adds a tiny planned-phase doc for visual feedback during async operations.

- Single SVG-circle Spinner.svelte component, three sizes (sm/md/lg = 16
  / 24 / 40 px), color via currentColor (theme-aware), motion-reduce-aware,
  role="status" + sr-only m.loading() for a11y.
- Integration at 5 existing sites that already render m.loading() text or
  own a `loading` flag: auth splash, /search, /notes/archive,
  CreateCollectiveModal "creating" state, /collective/manage sub-sections.
- 5 unit + 3 e2e tests planned. No DB migration. No new Paraglide strings.

Rebrand: MVP2 reopens from 7/7  to in-progress 7/8 . Headers in fases
9–15 bump "·N/7" + "fases 9 → 15" to "·N/8" + "fases 9 → 16". README +
CLAUDE.md updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:34:58 +02:00
3c60e5a026 docs(mvp2): close out — 7/7 fases shipped, 497 tests green
Fase 15 (common items management) lands; MVP2 cycle complete.

Final test totals after MVP2 (Fase 9 → 15):
- pgTAP:               177 (was 45 → +132)
- Vitest integration:  179 (was 140 → +39)
- Vitest unit:          47 (was 15  → +32)
- Playwright e2e:       93 (was 59  → +34)
- Rate-limit gated:      1
- Total active:        497 (was 259 → +237)
- Skipped:               3 (unchanged)

Migrations 015 → 026 (12 new). pgTAP 009 → 018 (10 new files).
Per-fase history docs under docs/history/fase-{9,10,11,12,13,14,15}-*.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:40:34 +02:00
e814f3b5b3 docs(fase-15): history record — common items management + exclude-on-list
Closes the Fase-15 cycle. Final counts: 177 pgTAP + 179 Vitest
integration + 47 Vitest unit + 93 Playwright + 1 gated = 497 + 3
skipped. The plan estimated +14; actual is +21 (the extra came from the
schema-invariant pgTAP assertions on the new index + RPC posture).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:38:52 +02:00
6a02d6d5ab fix(fase-15): cap excludeNames at 200 + update D-06 for excluded suggestions
Two regressions surfaced when running the full e2e suite against the
shared dev DB (which accumulates hundreds of items in the seed list
over the lifetime of the test stack):

  - fetchSuggestions URL ballooned past Kong's ~16 KiB request URI cap
    when excludeNames carried 300+ names, freezing the query and
    breaking the realtime + reorder-items + section-visibility tests
    (which co-locate two browser contexts hitting the same list). Cap
    at 200 names; past that the filter degrades to a no-op (the
    dropdown may surface a few names already on the list, which is
    fine UX for a long-running shared list).
  - items.test.ts D-06 (frequency suggestions visible) was asserting
    on "milk" which is in the seed list — Fase 15 correctly suppresses
    it now. Route the test through a freshly-created empty list so
    the strip is deterministic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:16:15 +02:00
a3fb423596 feat(fase-15): exclude-on-list wiring + CI-02 e2e (15.4)
/lists/[id]/+page.svelte now passes the current item names through to
fetchSuggestions as excludeNames, both on initial cold-load and on every
debounced keystroke. The exclude list is a `$derived` of `items` so it
recomputes the moment an optimistic insert lands — the next keystroke
no longer surfaces the freshly-added item.

ItemSuggestions.svelte gets a stable data-testid handle on the strip
plus per-chip testids so the suite can assert presence/absence without
fragile text matches. The component still hides itself entirely when
the post-filter suggestion list is empty (the dropdown was already
gated on `suggestions.length > 0`).

Playwright CI-02 lives on a freshly-created list (the seed list is
shared with the entire suite and tends to accumulate hundreds of test
items, which both buries the assertion target and inflates the
excludeNames URL). Boosts two synthetic names heavily so they lead
the dropdown regardless of pollution in item_frequency, verifies
"added" → "next fetch excludes it", and tears its own rows down via
the admin purge RPC (direct table deletes are blocked by the deny-all
RLS from migration 006).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:40:22 +02:00
834034bada feat(fase-15): /collective/manage common items UI + e2e (15.3)
New subsection on /collective/manage:
  - Table with rows of (name, uses, last used) + 3-way Hide/Normal/Boost
    segmented control writing weight=-50/0/50, + Remove action that opens
    a confirmation modal explaining "items already in lists are not
    touched".
  - Add common-item modal (admin only) — input + same 3-way control,
    defaulting to Boost; useful for seeding the catalogue before lists
    actually mention an item.
  - Empty / filtered-empty states + client-side substring search.
  - Members see the entire table with all buttons disabled and a tooltip
    pointing to "only admins can manage common items". Guests don't see
    the section at all.

Implementation notes:
  - The data-weight-state attribute on each row gives the test suite a
    stable handle for the current visual state (boost / normal / hide).
  - The segmented control is a plain `inline-flex` of three buttons —
    different visual language to the existing section-visibility toggles
    (those are checkboxes), so there's no ambiguity at a glance.
  - HIDE_WEIGHT / BOOST_WEIGHT constants make the magic numbers explicit;
    the column itself has no check constraint so future tiers can land
    without a schema change.

Playwright covers CI-01 (Ana boosts yogurt → it leads the dropdown ahead
of higher-use-count milk), CI-03 (Borja sees the table, all actions
disabled, "Add common item" hidden), CI-04 (Ana purges apples → row gone
from the table). CI-02 (exclude-on-list) lives in the same file but
exercises the /lists/[id] page and ships in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:27:03 +02:00
3f96e6cdb3 feat(fase-15): fetchSuggestions weight ordering + commonItems store (15.2)
fetchSuggestions now:
  - Orders by `weight DESC, use_count DESC, last_used_at DESC` so admin-
    promoted items lead the dropdown regardless of historical use_count.
  - Accepts `{ excludeNames?: string[]; limit?: number }`. The exclude
    filter is applied as `.not('name', 'in', '(...)')` only when the
    sanitised list is non-empty (PostgREST renders an empty list as a
    SQL error). Each name is lowercased + trimmed to match the
    normalised form `item_frequency.name` is stored in.

commonItems.ts is the unbounded counterpart used by /collective/manage:
loadCommonItems (full catalogue, same sort), setWeight (RPC + optimistic
upsert into the store), purge (RPC + optimistic remove).

Integration test common-items.test.ts (6 specs) covers the role gate
(admin OK, member/guest get P0002), the boost-promotes-low-use-count
ordering, the excludeNames filter, the RPC purge, and the
seed-via-RPC-with-use_count=0 flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:18:37 +02:00
5563afd04c feat(fase-15): item_frequency.weight + admin RPCs (15.1)
Adds a per-collective curation knob on top of the existing use_count
ordering. The UI exposes a 3-way Hide/Normal/Boost control that writes
weight=-50/0/50 via the new SECURITY DEFINER RPC; the column itself is
unconstrained so a future granularity tier doesn't need a schema diff.

Both new RPCs (set_item_frequency_weight, purge_item_frequency) gate on
collective_members.role='admin'. The all-deny RLS from migration 006 is
preserved — the RPCs are the only write path. The trigger that fires on
shopping_items INSERT keeps backfilling weight=0 via the column default.

Re-create the suggestion index to lead with weight DESC so the dropdown
query can scan in order without a sort step.

pgTAP test 018 covers the column/index/RPC schema invariants + the
default-from-trigger behaviour (11 assertions). Role-gate denial-for-non-
admin is covered by the Vitest integration suite (next commit) — pgTAP
runs as postgres so the auth.uid() guard can't be exercised here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:15:20 +02:00
b994fc7c95 docs(plan): add Fase 15 — common items management (MVP2 reopened to 7 fases)
Adds a planned-phase doc for the next ask on top of the closed MVP2 cycle:
weighted suggestion ordering + exclude items already on the active list.

- Extends item_frequency (migration 026) with `weight integer` written via
  two admin-only SECURITY DEFINER RPCs (set_item_frequency_weight +
  purge_item_frequency). The client never writes weight directly — the
  existing all-deny policies on item_frequency stay in place.
- fetchSuggestions reorders by `weight desc, use_count desc, last_used_at
  desc` and accepts an excludeNames arg to drop already-added items from
  the dropdown.
- UI lives in /collective/manage as a 3-way segmented control per row
  (Hide / Normal / Boost mapping to -50 / 0 / 50). Admin writes; member
  read-only; guest hidden.

Rebrand: MVP2 reopens from 6/6  to in-progress 6/7 , with Fase 15
pending. Headers in fases 9–14 bump from "·N/6" + "fases 9 → 14" to
"·N/7" + "fases 9 → 15". README status table + index updated, CLAUDE.md
project status appends the Fase 15 pending paragraph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:08:00 +02:00
d9d5f780c1 docs(mvp2): close out — 6/6 fases shipped, 476 tests green
Final test totals after MVP2:
- pgTAP:               166 (was 45 → +121)
- Vitest integration:  173 (was 140 → +33)
- Vitest unit:          47 (was 15  → +32)
- Playwright e2e:       89 (was 59  → +30)
- Rate-limit gated:      1
- Total active:        476 (was 259 → +217)
- Skipped:               3 (unchanged: 2 Realtime presence + 1 gated)

Cycle summary:
- Fase 9  — dark mode + max-w-2xl + sync_conflicts + WebKit project
- Fase 10 — H06/H07/H08 + trash restore + account delete + sidebar create
            + reset-from-detail + auto language detection
- Fase 11 — item tags + drag-reorder
- Fase 12 — section visibility (feature flags, JSONB + section_enabled())
- Fase 13 — server admin (/admin, server_admins, audit log, server-layer
            section_enabled extension)
- Fase 14 — PWA auto-update toast + offline polish + version chip

Migrations 015 → 025 (11 new). pgTAP 009 → 017 (9 new files).
Per-fase history docs under docs/history/fase-{9,10,11,12,13,14}-*.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 07:39:54 +02:00
7bf9135f33 docs(fase-14): history record — PWA auto-update + offline polish + version chip
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>
2026-05-18 07:26:46 +02:00
38b2be61ef feat(fase-14): version chip in /settings + GIT_SHA bake-through (14.3)
`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>
2026-05-18 07:09:15 +02:00
45cca5072b feat(fase-14): sync_conflicts review route + chrome banner (14.2.3)
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>
2026-05-18 07:06:24 +02:00
c8c7f9f8c9 feat(fase-14): offline chip + queued-op badge in global chrome (14.2.1-2)
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>
2026-05-18 07:02:00 +02:00
a6e6915b0b feat(fase-14): PWA auto-update toast (14.1)
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>
2026-05-18 06:59:29 +02:00
d34c578ad8 test(fase-13): bump SA-01 + SA-04 timeout to 60s
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>
2026-05-18 06:31:35 +02:00
0485ccc75e docs(fase-13): history doc + deployment server admin runbook + CLAUDE.md status
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>
2026-05-18 06:26:51 +02:00
27afda74f1 feat(fase-13): admin UI + sidebar entry + isServerAdmin store + e2e SA-01..04
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>
2026-05-18 05:30:56 +02:00
7556d77bf8 feat(fase-13): admin RPCs + server_settings + section_enabled server layer
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>
2026-05-18 05:01:19 +02:00
b1858542d0 feat(fase-13): server_admins + admin_actions audit log model
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>
2026-05-18 04:52:18 +02:00
99096f9f4a docs(fase-12): history doc + CLAUDE.md status + new-section convention
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>
2026-05-18 04:45:36 +02:00