Deleting a board has a thirty-day undo; home and settings polish #215

Merged
Grandiras merged 1 commit from issue-196-soft-delete into main 2026-09-22 20:53:23 +00:00
Owner

Part of #196: the soft delete from the design posted there. The super-admin half (moderation delete, bans, the board record page) follows in its own PR, stacked on this one. This PR also carries the QA polish on the home page and the board settings from the same pass.

Deleting a board is undoable for 30 days

  • A delete sets Board.DeletedAt, plus who deleted it and whether it was moderation, and changes nothing else. BoardAccess.VisibleTo gains a first clause, so a deleted board drops out of every read at once: home, switcher, both calendars and .ics feeds, search, the for-you strip, the move menu, link previews and the launch resume.
  • BoardNotEmptyException is gone: a board with cards can be deleted, because nothing is destroyed at the moment of pressing.
  • Restore: "Recently deleted" under the home tiles lists the boards this member ran (board admin) with the last day each can come back. Opening a deleted board's link shows DeletedBoardState ("Deleted on …", until when, Restore for its admins, a way home for everyone) instead of "not found". A board deleted as moderation isn't restorable by its own admins; that side is in the moderation PR.
  • BoardPurge, a daily hosted service, destroys boards deleted more than BoardService.GraceDays (30) ago. It's the one place that may destroy a card: the foreign keys stay Restrict, and PurgeExpiredAsync writes the delete order by hand in one transaction.
  • Board settings → General deletes in two steps (Delete → "Yes, delete").
  • Migration AddBoardSoftDelete: three nullable columns, nothing to backfill.

Home and settings polish

  • Home entrance (ponenteEntrance.playHome, gated on data-home-entered like the board and the calendar): headings lead their sections, for-you cards rise, tiles pop and their mini boards fill column by column, a pinned board's pin wiggles. The strip and the tiles play side by side, all within ~0.8 s, and nothing plays under reduced motion. The cards rise rather than slide sideways because a sideways transform inside a scroll container shifted the strip's scroll width mid-play.
  • For-you strip: room on every side for a hovered card's lift (it used to clip), and a thin scrollbar that only shows while the strip is hovered or focused.
  • Tiles: the settings control is a real gear, and a full mini column fits its six dashes without flex squeezing them to 1.67 px.
  • Board settings: one styled heading ("Board settings") over segmented tabs. The header lights Settings, not Board, on those pages.
  • Access page: each tick has its own label (a click on "Admin" used to untick the member), with the Admin tick as a chip at the row's end.
  • Tags page: the colour and the buttons take their own width, so there's no dead gap before Save/Delete.
  • Header: the board list rides across the prerender (PersistentComponentState), so the switcher is never empty while the circuit connects. That was the flaky access.spec switcher test. Disposal no longer calls JS during prerender, which removes 117 logged errors per e2e run.

Changelog

The two unreleased lines about board admins and board settings merge into one (the block was at its cap), plus one line for the undo.

Tests

  • BoardAccessTests: a deleted board is visible to nobody, and WasVisibleTo answers exactly what VisibleTo did before the delete.
  • e2e: delete → gone everywhere → "Recently deleted" → restore; the deleted-board page; two-step delete in the helpers; the home entrance plays exactly once (circuit on time and 700 ms late), counting headings too.
  • Full e2e suite with the moderation branch on top: 164/164. Unit tests: 558/558.

🤖 Generated with Claude Code

Part of #196: the soft delete from the design posted there. The super-admin half (moderation delete, bans, the board record page) follows in its own PR, stacked on this one. This PR also carries the QA polish on the home page and the board settings from the same pass. ## Deleting a board is undoable for 30 days - A delete sets `Board.DeletedAt`, plus who deleted it and whether it was moderation, and **changes nothing else**. `BoardAccess.VisibleTo` gains a first clause, so a deleted board drops out of every read at once: home, switcher, both calendars and `.ics` feeds, search, the for-you strip, the move menu, link previews and the launch resume. - `BoardNotEmptyException` is gone: a board with cards can be deleted, because nothing is destroyed at the moment of pressing. - **Restore**: "Recently deleted" under the home tiles lists the boards this member ran (board admin) with the last day each can come back. Opening a deleted board's link shows `DeletedBoardState` ("Deleted on …", until when, Restore for its admins, a way home for everyone) instead of "not found". A board deleted as moderation isn't restorable by its own admins; that side is in the moderation PR. - **`BoardPurge`**, a daily hosted service, destroys boards deleted more than `BoardService.GraceDays` (30) ago. It's the one place that may destroy a card: the foreign keys stay `Restrict`, and `PurgeExpiredAsync` writes the delete order by hand in one transaction. - Board settings → General deletes in two steps (Delete → "Yes, delete"). - Migration `AddBoardSoftDelete`: three nullable columns, nothing to backfill. ## Home and settings polish - **Home entrance** (`ponenteEntrance.playHome`, gated on `data-home-entered` like the board and the calendar): headings lead their sections, for-you cards rise, tiles pop and their mini boards fill column by column, a pinned board's pin wiggles. The strip and the tiles play side by side, all within ~0.8 s, and nothing plays under reduced motion. The cards rise rather than slide sideways because a sideways transform inside a scroll container shifted the strip's scroll width mid-play. - For-you strip: room on every side for a hovered card's lift (it used to clip), and a thin scrollbar that only shows while the strip is hovered or focused. - Tiles: the settings control is a real gear, and a full mini column fits its six dashes without flex squeezing them to 1.67 px. - Board settings: one styled heading ("Board settings") over segmented tabs. The header lights **Settings**, not Board, on those pages. - Access page: each tick has its own label (a click on "Admin" used to untick the member), with the Admin tick as a chip at the row's end. - Tags page: the colour and the buttons take their own width, so there's no dead gap before Save/Delete. - Header: the board list rides across the prerender (`PersistentComponentState`), so the switcher is never empty while the circuit connects. That was the flaky `access.spec` switcher test. Disposal no longer calls JS during prerender, which removes 117 logged errors per e2e run. ## Changelog The two unreleased lines about board admins and board settings merge into one (the block was at its cap), plus one line for the undo. ## Tests - `BoardAccessTests`: a deleted board is visible to nobody, and `WasVisibleTo` answers exactly what `VisibleTo` did before the delete. - e2e: delete → gone everywhere → "Recently deleted" → restore; the deleted-board page; two-step delete in the helpers; the home entrance plays exactly once (circuit on time and 700 ms late), counting headings too. - Full e2e suite with the moderation branch on top: 164/164. Unit tests: 558/558. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Deleting a board has a thirty-day undo; home and settings polish
All checks were successful
CI / build (pull_request) Successful in 43s
3ffca90536
A delete sets Board.DeletedAt (who, and whether it was moderation) and
changes nothing else. BoardAccess.VisibleTo drops a deleted board from every
read at once, "Recently deleted" on the home page and the deleted board's own
page bring it back, and BoardPurge destroys it after BoardService.GraceDays —
the one place that may destroy a card. BoardNotEmptyException is gone.

The same pass polishes what #194 and #195 built: a home entrance (headings
lead, the strip rises, tiles pop and fill in, side by side inside 0.8s), room
for a hovered strip card and a scrollbar only while wanted, a real gear on the
tiles, mini columns that fit their dashes, one styled heading with tabs over
the board settings, one label per tick on the access page, tight tag columns,
and a header that carries its boards across the prerender.

Part of #196.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

The soft delete is built the right way round: one clause in BoardAccess.VisibleTo takes a deleted board out of every read, the delete itself destroys nothing, and PurgeExpiredAsync is the single hand-written, transactional exception that may touch a card. Permission checks are in place on both delete and restore (RequireAdminister), the moderation flag is stored rather than inferred, and the deleted/denied/missing distinction in ResolveAsync only discloses a board's deletion to somebody who could see it before. The PersistentComponentState carry in NavHeader is scoped to the carried records, not the entities, and the prerender disposal guard is correct. Migration, snapshot and context configuration agree; the e2e helpers and the once-only entrance counter test are coherent.

Two non-blocking remarks:

  • RestoreAsync (and DeleteAsync) return silently when the board is already in the requested state, so a race between two admins restoring shows the "Board restored" toast for a board that was never gone. Harmless, but a DeletedAt is null check before the success toast would be tidier.
  • DeletedBoardState caches _canRestore per board id, so a grant changed while the page sits open (or a viewer whose session changes) is not picked up until the component is rebuilt. Fine in practice for an empty state.


⏱ 3m 29s · 9 steps · $0.0335 · tokens: 122.9k in / 8.8k out (+355.1k cache-read) · Go: 5h 37% · wk 21% · mo 43%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. The soft delete is built the right way round: one clause in `BoardAccess.VisibleTo` takes a deleted board out of every read, the delete itself destroys nothing, and `PurgeExpiredAsync` is the single hand-written, transactional exception that may touch a card. Permission checks are in place on both delete and restore (`RequireAdminister`), the moderation flag is stored rather than inferred, and the deleted/denied/missing distinction in `ResolveAsync` only discloses a board's deletion to somebody who could see it before. The `PersistentComponentState` carry in `NavHeader` is scoped to the carried records, not the entities, and the prerender disposal guard is correct. Migration, snapshot and context configuration agree; the e2e helpers and the once-only entrance counter test are coherent. Two non-blocking remarks: - `RestoreAsync` (and `DeleteAsync`) return silently when the board is already in the requested state, so a race between two admins restoring shows the "Board restored" toast for a board that was never gone. Harmless, but a `DeletedAt is null` check before the success toast would be tidier. - `DeletedBoardState` caches `_canRestore` per board id, so a grant changed while the page sits open (or a viewer whose session changes) is not picked up until the component is rebuilt. Fine in practice for an empty state. --- --- ⏱ 3m 29s · 9 steps · $0.0335 · tokens: 122.9k in / 8.8k out (+355.1k cache-read) · Go: 5h 37% · wk 21% · mo 43% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-215/run-2026-09-22T20-49-13-943Z.md) <!-- opencode-reviewed-head:3ffca90536ae55e2daab0d76f06cb8f791455cbc --> <!-- opencode-verdict:approve -->
Grandiras deleted branch issue-196-soft-delete 2026-09-22 20:53:23 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente!215
No description provided.