mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
77dcd07ecda3c72ee62af2eaf0d8d5a38374759f
395 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
77dcd07ecd |
feat(web): graph search fly-to + collection/status/role filters (TASK-1735) (#703)
* feat(web): graph search fly-to + collection/status/role filters (TASK-1735) Toolbar grows a type-ahead search (ref/title over the post-filter node list; ArrowUp/Down + Enter picks, Escape closes without stealing the page's deselect) that routes through the existing selectNode() — same camera fly-to, highlight, and detail card as a click. Client-side filters subset the rendered graph: collection chips with palette dots, status chips, and a role select (hidden when no node carries a role; the graph endpoint now emits the assigned agent-role slug per node). Edges survive only when both endpoints do; counts read "X of Y" while filtered. Workspace switch resets filters; show-completed doesn't. Filter changes deselect so a vanished node can't strand focus mode. New GraphToolbar.svelte owns the presentational toolbar; the page owns authoritative filter state (CONVE-1688 discipline unchanged). Parent: PLAN-1730. * fix(web): close graph search dropdown on blur per Codex review (round 1) The dropdown opened on focus/input but only closed on pick or Escape, leaving stale results floating over the canvas after clicking away. The result buttons already pick on mousedown+preventDefault, so the input never blurs mid-pick — a plain onblur close is safe. * fix(web): gate search Escape on dropdown visibility per Codex review (round 2) Escape in a focused-but-empty search now falls through to the page-level deselect instead of being swallowed by the searchOpen flag. |
||
|
|
1c3db435a3 |
feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) (#702)
* feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) Clicking a node now enters focus mode instead of navigating away: the camera flies to the node (800ms, standard distance-ratio pattern with at-origin guard), its neighborhood (any shared edge type) keeps full color while everything else dims to low-alpha, adjacent links brighten and the rest fade hard. A DetailCard slides in from the right: collection dot + ref + title, status pill (terminal styling), child count, relative updated-at, plus priority/assignee fetched lazily via the items API (stale-select token), and the "Open item" button carrying the old click-through navigation. Deselect via background click, Escape (only when a selection is active), or automatically when the workspace/show-completed payload changes. Selection sets stay plain non-reactive lets per CONVE-1688; accessor re-evaluation is explicit via graph.refresh(). Parent: PLAN-1730. * fix(web): focus-mode link adjacency vs mutated endpoints per Codex review (round 1) The force layout mutates link source/target from ref strings into node objects after ingest, so linkColor's adjacency check against selectedRef silently failed once the simulation ran. Preserve the raw refs as sourceRef/targetRef at mapping time and compare those. |
||
|
|
db3917f6d2 |
feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733) (#701)
* feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733)
New full-viewport graph page at /{username}/{workspace}/graph rendering
the TASK-1731 endpoint via 3d-force-graph. Three.js loads only through
a dynamic import inside onMount, landing in its own ~1.3MB chunk
referenced solely by the graph route node — entry bundle unchanged.
Node color = collection (local hex palette; the chart PALETTE's CSS
vars can't reach WebGL), node size = 1 + 2×child_count, blocks edges
red with directional arrows, structural links brighter than soft ones.
Click navigates to the item page (ResolveItem accepts refs in the slug
param). Active items by default with a "Show completed" toggle that
refetches in place; workspace switches refetch with a stale-response
guard. Teardown via _destructor + ResizeObserver disconnect.
Nav: 'graph' added to destinations.ts (NavKey, RESERVED_SLUGS, primary
destinations, getActiveKey) and a Sidebar entry after Insights — the
mobile More sheet picks it up from the shared source automatically.
Parent: PLAN-1730.
* fix: clear stale canvas on workspace switch + reserve 'graph' collection slug per Codex review (round 1)
1. The renderer-sync effect now pushes empty graphData when the
reactive payload is null (workspace switch in flight / load error)
instead of early-returning — the previous workspace's nodes no
longer linger behind the loading overlay.
2. 'graph' added to reservedCollectionSlugs so a collection can't
shadow the /{username}/{workspace}/graph route, matching the
frontend's RESERVED_SLUGS.
|
||
|
|
a20095e870 |
feat(web): graph TS types + API client method (TASK-1732) (#700)
GraphNode / GraphEdge / GraphResponse types mirroring the
GET /workspaces/{ws}/graph payload (TASK-1731), and
apiClient.graph.get(ws, includeTerminal) to fetch it.
Deliberately web-only: no CLI command, no MCP action — the graph
payload is a rendering data feed for the upcoming /graph route, not
an agent surface.
Parent: PLAN-1730.
|
||
|
|
5c491b78b5 |
feat(web): mobile bottom navigation (PLAN-1694) (#692)
* feat(web): mobile bottom navigation + quick-capture (PLAN-1694)
Add a mobile-only persistent bottom navigation bar that coexists with the
existing TopBar. Five slots: Dashboard, Search, Quick-capture (center +),
Activity, More. The "More" slot opens a BottomSheet with the sidebar's
overflow destinations + collections; the center action opens a quick-capture
sheet that creates an item via the existing API.
- New shared nav source (lib/nav/destinations.ts) consumed by both the
desktop Sidebar and the new BottomNav/More sheet so the two can't drift;
Sidebar refactored to derive active-state from it (lossless).
- BottomNav mounts in the workspace layout (workspace-only), shows only on
mobile (uiStore.isMobile, ≤768px), and toggles body.has-bottom-nav so
app.css reflows .main-content above the fixed bar.
- iOS: app.html viewport-fit=cover + env(safe-area-inset-bottom) padding.
- z-index 40 (above TopBar/sidebar, below sheets).
Header retirement + full "You" (workspace/account) consolidation are
deliberately deferred to a human design checkpoint (DR-3).
Verify: web npm run check (0 errors) + npm run build (✓). Codex review loop
clean (1 P2 fixed: quick-capture re-defaults a stale collection slug).
Refs PLAN-1694 (TASK-1695, TASK-1696, TASK-1697, TASK-1698); parent ROADM-28.
* feat(web): retire mobile header in-workspace, add You sheet + contextual bar (PLAN-1694)
Complete the mobile nav vision on top of the bottom bar: inside a workspace
the global mobile header is gone, replaced by the BottomNav + a "You" sheet +
a contextual back/title bar on detail screens.
- BottomNav 5th slot More → You (👤). New YouSheet composes the workspace
switcher, the nav overflow, and the full account menu carried over from the
retired TopBar (Workspaces/Settings/Billing/Admin/theme/Resources/Connect/
Sign out) so nothing is lost.
- Root layout gates <TopBar mobile /> on !inWorkspace (page.params.workspace),
keeping it for the non-workspace picker/home which has no BottomNav; the
.app-layout top offset now applies only via body.has-mobile-topbar.
- New MobileContextBar: fixed back+title bar shown only on detail screens
(path depth ≥2); body.has-context-bar reflows .main-content top. Root/tab
screens render full-height (reclaimed space).
Codex consensus + review clean (no P1). P2s fixed: safe in-app-history check
via afterNavigate (deep-link fallback to parent URL), humanized title fallback
for pages that don't wire titleStore, and the You sheet closes on navigation
so a workspace switch dismisses it.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1699, TASK-1700); parent ROADM-28.
* feat(web): redesign mobile nav into Workspace + You docked sheets (PLAN-1694)
Address design feedback: the previous "You" sheet reused common/BottomSheet
(covered the nav bar) and embedded the old WorkspaceSwitcher (plain, unadapted).
Replace it with two purpose-built surfaces that dock ABOVE the nav.
- DockedSheet (new): bottom sheet anchored above the bottom nav — backdrop
stops at the nav's top edge so the bar stays visible + tappable and the
active slot stays lit. ~2/3 height, grab handle, slide-up, swipe-down /
tap-out / Escape to dismiss. No more full-screen overlay covering the nav.
- Bottom-nav slot 1 Dashboard → Workspace (icon = current workspace avatar),
opening WorkspaceSheet: a designed switcher card (inline-expand list) +
Navigate tile grid + Collections.
- "You" slot → YouSheet, now account-only: profile header, theme toggle,
account Settings / Workspaces / Billing / Admin / Connect / Resources /
Sign out. Search / + / Activity unchanged.
- New avatar util (avatarColor/avatarInitial) for workspace + user avatars.
Splits the overstuffed single sheet into two focused surfaces (where-am-I vs
me) and removes the reused WorkspaceSwitcher/BottomSheet. Codex review CLEAN.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
* feat(web): toggle Workspace/You sheets on repeat nav tap (PLAN-1694)
Tapping the Workspace or You nav slot a second time now closes its open
sheet (tap to open, tap again to close). Opening one still closes the other.
The docked backdrop stops above the nav, so the slot button stays tappable
while its sheet is open.
Refs PLAN-1694 (TASK-1701).
* feat(web): dock mobile search to match the nav sheets (PLAN-1694)
The CommandPalette was the last mobile surface that didn't match — a
full-screen takeover (square, no handle, hid the nav). Give its mobile
presentation the same docked treatment as the Workspace/You sheets;
desktop ⌘K is untouched.
- Mobile: dock the palette above the bottom nav (backdrop stops at the nav's
top edge so the bar stays visible), rounded top, grab handle with
swipe-down-to-dismiss, tap-outside to close, no X. Lift above the nav only
when it's present (:global(body.has-bottom-nav)); flush to bottom on the
non-workspace picker. Keyboard handling preserved: input stays at the sheet
top, .results is the sole scroll area, min-height keeps the input clear of
the keyboard when empty.
- BottomNav: Search slot toggles (tap to open, tap again to close) and lights
up while open; the three surfaces (Workspace / You / Search) are now
mutually exclusive.
Codex review CLEAN. Verify: web npm run check (0 errors) + build (✓).
Note: on-screen-keyboard feel should get an on-device smoke test.
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
|
||
|
|
72d8963c4c |
fix(timeline): resolve collab-snapshot diffs + collapse autosave bursts (BUG-1612) (#691)
Item timelines showed two collab-snapshot problems:
1. Artifacts: the timeline endpoint (ListItemVersionsBeforeTime) served
diff versions unresolved, so TimelineVersionCard fed raw diff-match-patch
patch text into DiffView. Add GET /items/{slug}/versions/{versionID}
(handleGetItemVersion -> Store.GetItemVersionResolved) and have the card
lazily fetch resolved content the first time a diff version is expanded.
2. Clutter: every ~5s web-editor autosave flushes a collab-snapshot version.
buildTimeline now collapses uninterrupted collab-snapshot bursts (within
10 min, no intervening event) to their newest entry, and the source badge
renders as "Autosave" instead of the raw slug.
Adds TestCollapseAutosaveBursts. Known limitation (accepted): collapse is
page-local, so a 150+ cross-actor autosave chain can leak one row per
"Load more" page — gated by the 1h version throttle, degrades gracefully.
|
||
|
|
d2e80efefc |
fix(web): reflow page content when sidebar collapses (BUG-1651) (#689)
Desktop .sidebar.collapsed only applied translateX(-100%), which slides the sidebar off-screen but keeps its column reserved in the .app-shell flex row — .main-content (flex: 1) had nothing to expand into, leaving a blank gap. Release width/min-width on desktop-collapsed so the flex row reflows; scoped :not(.mobile) since the mobile sidebar is position: fixed and uses its width to drive the slide-in drawer. Width animates alongside the existing transform transition for a smooth close. |
||
|
|
a5c7fc986e |
fix(attachments): grant-aware upload auth so share-link editors can attach (BUG-1661) (#688)
handleUploadAttachment gated on requireMinRole("editor") — a workspace-level
check — but the editor and comment composer offer the paste/drop upload
affordance based on grant-aware edit permission. A grant-based editor (guest
with an item/collection edit grant via a share link, no workspace editor role)
could type/post but hit 403 on upload.
Server: read ?item_id early (before spooling the body); when present and
resolvable, authorize via requireEditPermission against the item's grant chain,
else fall back to requireMinRole("editor") for free-floating uploads (new-item
creation, storage settings). Reordered the nil/getWorkspaceID checks above auth.
Client: upload() now also sends item_id as a query param so the server can
authorize before spooling. Threaded the item UUID through Editor.svelte (both
mount sites) and CommentEditor.svelte (ItemTimeline composer + the 3
TimelineCommentCard composers via comment.item_id).
Test: TestUpload_GrantBasedEditorCanAttach — guest with an item edit grant gets
201 with ?item_id and 403 without it (confirms the editor-role fallback didn't
widen access).
|
||
|
|
034b540a77 |
fix(web): de-reactify open-transition trackers in 3 more modals/banners (#687)
Sweep follow-up to BUG-1687. ConnectBanner, CreateCollectionModal, and ConnectWorkspaceModal each tracked their open→close transition with a `$state` variable (`prevOpen`/`lastOpen`) that their `$effect`/`$effect.pre` both read and wrote — the same self-invalidating pattern that silently wedges Svelte's effect scheduler in production builds on close. Make each tracker a plain `let` (they're only used for edge-detection inside the effect), so the effects depend solely on `open`/`connectOpen` and never re-trigger themselves. Found via a codebase-wide audit; these were the only other genuine matches. |
||
|
|
a4e0eb13d8 |
fix(share): stop ShareDialog effect from wedging the scheduler in prod (#686)
ShareDialog tracked the open transition with `let prevOpen = $state(false)` and an `$effect.pre` that both read and wrote `prevOpen` — a self-invalidating effect. In dev this trips `effect_update_depth_exceeded`; in a production build the guard differs and it silently wedges Svelte's global effect scheduler the moment `open` flips on close. Result: after opening+closing the share dialog, effects stop flushing app-wide — clicks change the URL but the view never re-renders (no error, no CPU spin). Make `prevOpen` a plain variable (it's only used for edge-detection inside the effect), so the effect depends solely on `open` and never re-triggers itself. |
||
|
|
d29392443a |
feat(share): inline read-only row expand in public shared view (TASK-1684) (#685)
* feat(share): inline read-only row expand in public shared view (TASK-1684)
Clicking an item row/card in the public `/s/[token]` collection view now
expands the item's fields + sanitized markdown content INLINE on the same
page — no navigation. Items aren't individually shared, so a link would 404
or bypass item-level share ACLs; the expansion is gated by the same share
link and is strictly read-only.
- New PublicItemExpansion panel renders fields + content; it does NOT
sanitize — it receives pre-sanitized HTML from the route's single
marked()+DOMPurify pipeline (the same one the single-item share view uses),
so there's no new {@html} XSS surface.
- Wired `expandable`/`onactivate`/`expandedKey`/`renderContent` through
PublicCollectionView → board/list/table renderers. Toggle on re-click.
- Keyboard accessible: Enter/Space toggles, aria-expanded + aria-controls on
each interactive row/card.
- Works across all three views: board card, list row, and table row (the
table panel spans the full grid width as a presentation row).
- Per-item sanitized HTML is memoized; expansion collapses on view switch.
Parent: PLAN-1677.
* fix(share): render literal field values verbatim, label-format only categorical fields per Codex review (round 1)
formatLabel() was title-casing/de-hyphenating every expanded field value,
corrupting literals like dates (2026-05-31), slugs, IDs, and URLs. Now only
status/priority/select values get label-formatting + color; all other fields
render verbatim, matching the single-item share view.
|
||
|
|
b422988601 |
feat(share): schema-driven colors + empty/large-collection states (TASK-1683) (#684)
Phase 3 polish for the public shared collection view: - Schema-driven status/select colors via fieldValueColor(): canonical open/in_progress/done/blocked palette matches the owner exactly, and a custom terminal option (schema terminal_options, e.g. shipped/closed) now reads as "done" green in cards, list rows, table cells, and board column accents — the bare literal switch missed these. Table colors any select-type column, not just status/priority. - Polished empty state (icon + title + hint, dashed card) shared across all three view types. - Large-collection cap: PUBLIC_ITEM_CAP=200 with a visible "showing N of M" banner (no silent truncation). Applied after saved-view filtering so the count reflects the visible set. - Documented the saved-view sort deferral: payload carries no sort_order/timestamps, so list_sort_by stays a no-op (owner's sort_order order is already honored); a faithful field sort is a backend/payload decision. Parent: PLAN-1677. |
||
|
|
d4ff473715 |
feat(share): read-only view switcher on shared collection route (TASK-1682) (#683)
* feat(share): read-only view switcher on shared collection route (TASK-1682) Add a read-only Board/List/Table view switcher to /s/[token]. Anonymous viewers can toggle the presentation of a shared collection; the choice drives PublicCollectionView's `view` prop, overriding the owner's settings.default_view (which is the initial selection, falling back to 'list'). Saved views from the share payload (collection.views) surface as selectable chips alongside the three base types; selecting one resolves through its view_type (kanban -> board) to a base renderer. Purely presentational — no mutation affordances. Selection persists via the ?view= URL param + localStorage keyed by share token, mirroring the logged-in collection page's saveViewMode/loadSavedViewMode pattern (read-only context, so no server save). URL precedence: ?view= > localStorage > owner default. Parent: PLAN-1677. * fix(share): apply saved-view config (grouping + filters) per Codex review (round 1) Saved-view chips previously only pinned the base view_type, ignoring the view's config — so a public saved view could show the whole collection in the wrong grouping. Now overlay the saved view's config onto the rendered output (read-only): config.group_by maps to board_group_by/list_group_by per active base type, and config.filters (eq/in) narrow the item set. Render path feeds derived parsedCollection/parsedItems to PublicCollectionView. Parent: PLAN-1677. * fix(share): graceful filter skip + correct URL persistence per Codex review (round 2) - Saved-view filters now skip fields absent from the public payload (share items carry only `fields`, not top-level tags/parent/phase), so a saved view filtering on tags no longer hides the entire collection; it degrades to showing the superset. - syncUrl drops ?view= only when the base selection equals the owner default (not hard-coded 'list'), so picking List on a board-default share produces a link that reproduces the choice. Parent: PLAN-1677. * fix(share): schema-based filter evaluability + URL normalization per Codex review (round 3) - Decide saved-view filter applicability from the public collection schema, not item values: a filter on a schema field stays applied even when no shared item sets it (returns none, matching the owner). Only fields genuinely absent from the public schema (tags/parent/phase) are skipped. - initSelection normalizes the URL after resolving, so a stale/invalid ?view= (e.g. deleted saved view) no longer leaves the address bar describing a view the page isn't rendering. Parent: PLAN-1677. |
||
|
|
be53856223 |
feat(share): include saved views in collection share payload (TASK-1681) (#682)
* feat(share): include saved views in collection share payload (TASK-1681)
Expose the collection's saved views on the public /s/{token} payload so the
read-only view switcher (TASK-1682) can render and toggle them. Fetched via
Store.ListViews (ordered by sort_order) and projected to a public shape
under collection.views — name, slug, view_type, config (parsed object),
is_default, sort_order — with internal UUIDs and timestamps stripped.
Always emits an array (never null); empty when the collection has no saved
views, so the switcher falls back to settings.default_view.
Extends the SharePayload TS type with PublicShareView + an optional
collection.views array (additive) for TASK-1682 to consume.
Parent: PLAN-1677.
* fix(share): pin distinct view sort_order in test per Codex review (round 1)
CreateView inserts sort_order=0 and now() is second-granularity, so the two
test views could tie on (sort_order, created_at) and SQL could return either
order, flaking the position-based assertion. Set explicit sort_order 0/1 and
assert on it.
Parent: PLAN-1677.
|
||
|
|
ae055ab412 |
feat(share): render matching view on /s/[token] (TASK-1680) (#681)
Wire PublicCollectionView into the public share page so a shared
collection renders the owner's chosen view type (board/list/table,
falling back to list) instead of a hardcoded flat list. The raw
`collection` + `items` branches of the share payload are passed
straight through — the component parses settings/schema/fields
defensively. expandable={false} for now (inline expand is TASK-1684).
Add a typed SharePayload (+ PublicShareCollection/Item/Settings) to
$lib/types and annotate api.share.get's return type to reflect the
enriched payload (collection.settings, collection.schema,
items[].content).
Existing share-page chrome (header/footer/password/auth states) is
untouched; only the collection BODY rendering swaps. Dead collection
row/header CSS removed.
Parent: PLAN-1677.
|
||
|
|
a58c64fb07 |
feat(web): public read-only collection view renderers (TASK-1679) (#679)
* feat(web): public read-only collection view renderers (TASK-1679)
Adds purpose-built read-only renderers for the public share page that
match the owner's view TYPE (kanban/list/table) and grouping, styled for
an anonymous external audience — no app chrome, no edit/drag/create
affordances, no internal app links.
New components under web/src/lib/components/share/:
- shareView.ts — payload types + DEFENSIVE parsers (settings/schema/
fields tolerated as JSON string OR object, since TASK-1678 ships in
parallel) + label/status/priority color + grouping helpers lifted
from the in-app vocabularies (ItemCard / BoardView / ListView).
- PublicItemCard.svelte — inert read-only card (board).
- PublicBoardView / PublicListView / PublicTableView — the three view
renderers; board groups by board_group_by|status, list optionally
groups by list_group_by, table mirrors TableView's CSS-grid layout.
- PublicCollectionView.svelte — entry point that parses the raw payload
and switches on settings.default_view, delegating to a leaf renderer.
Why: the share page currently renders a hardcoded flat list, ignoring
the owner's chosen view. These components are the render layer; wiring
into /s/[token] against the merged payload is TASK-1680. Row/card
components carry an optional expandable + onactivate contract so the
inline read-only expand (TASK-1684) can be added without a rewrite.
Not wired into any route yet; npm run build passes.
Parent: PLAN-1677.
* fix(web): stable unique each-keys for public renderers per Codex review (round 1)
PublicItem now carries a `key` assigned at parse time from the item's
payload position (ref-prefixed when present). The board/list/table
renderers key their {#each} blocks on it instead of `ref || title` —
refs may be empty and titles aren't unique, so the old key could collide
for two same-titled unprefixed items and break rendering/state.
Parent: PLAN-1677.
|
||
|
|
634e834f22 |
feat(board): Trello-style inline draft card creation (TASK-1676) (#678)
* feat(board): Trello-style inline draft card creation (TASK-1676) Replace the lane `+` direct-navigate (TASK-1671) with an inline draft card. Clicking `+` (or "Add item here") opens an editable card in the lane — no item exists yet. Enter creates it (group value pre-filled) and opens it; blur keeps the card without saving; Escape hides it but retains the text so reopening restores it (until reload). Drafts are per-lane and in-memory. A beforeNavigate guard intercepts in-app navigation while any lane has unsaved draft text and shows a Save / Discard / Stay dialog: Save creates all pending drafts (in place, no navigate) then proceeds, Discard drops them, Stay cancels. Reload/tab-close aren't guarded (nav.to is null) — drafts intentionally live only until reload. quickCreateInColumn now takes (groupValue, title, navigate) and returns the created item / throws so the draft can be restored on failure. The draft card renders above the dndzone so it isn't draggable. * fix(board): lift draft state to page + clear-each-on-save per Codex review (round 1) (1) BoardView unmounts on a board↔list view switch, which destroyed unsaved inline drafts and bypassed the leave guard. Move the draft state (draftText/draftOpen) to the page and bind it into BoardView, and move the beforeNavigate guard + Save/Discard/Stay dialog to the page (always mounted). A draft now survives view switches (state persists; the card just isn't rendered off-board) and the guard fires regardless of view. (2) leaveSaveAll cleared all drafts only after the whole loop, so a retry after a partial failure re-created already-saved drafts. Clear each draft as its create succeeds. * fix(board): only guard true leaves, not same-page URL syncs per Codex review (round 2) beforeNavigate fired on every goto while a draft existed — including the replaceState query/view-state syncs from updateUrlFilters (view toggle, filters, search). Switching Board→List or tweaking a filter wrongly opened the Save/Discard dialog. Skip navigations whose destination pathname equals the current one (internal same-page sync); the draft survives those as page state, so only true page leaves are guarded. * fix(board): don't guard willUnload navigations per Codex review (round 3) For native/external navigations (willUnload), goto can't replay the leave after cancel, so Save/Discard could strand the user. Skip guarding willUnload (and full-unload) navigations — treated like reload, drafts intentionally lost — so the replay path is always a client-side goto. * fix(board): replay Back/Forward with history.go to preserve history per Codex review (round 4) Cancelling a popstate (Back/Forward) navigation then replaying via goto(url) pushed the target as a new history entry, corrupting Back. Capture nav.type/nav.delta and replay popstate with history.go(delta); non-popstate leaves still replay with goto. |
||
|
|
710c76fb66 |
feat(board): per-lane sort override in the kebab menu (TASK-1673) (#677)
Add a "Sort lane by" drill-down to LaneActionsMenu — an ephemeral per-lane override on top of the page-wide sort. BoardView holds the override map (Record<lane, SortMode>, not persisted), applies the effective mode (override ?? page default) per lane in propColumnData, and disables drag per-lane under a non-manual effective sort. The submenu lists the same options as the toolbar (Priority hidden when no priority field) plus a "Page default" entry to clear the override, with a check on the active one. Sort is a view preference, available to everyone — so the kebab now shows for any non-empty lane (not just editors), and the menu's separators are section-gated so nothing dangles when a viewer sees only Sort. |
||
|
|
2cd8932148 |
feat(board): confirm + undo toast for destructive bulk actions (TASK-1674) (#676)
* feat(board): confirm + undo toast for destructive bulk actions (TASK-1674 frontend) Extend the toast store with an optional inline action button (label + onAction) and render it in ToastContainer. Wire Undo onto the two destructive bulk lane actions: - Archive all → "Archived N" with Undo → bulk `restore` of the affected ids (the backend op from the companion PR). - Move all to → "Moved N" with Undo → bulk move back to the source lane (all lane items shared its status). runBulk gains an optional undo callback (invoked with the affected ids) and gives undo-bearing toasts a longer 7s dwell. Archive keeps its in-menu count confirm (TASK-1672); the undo toast is the new safety affordance replacing the old single-item inline confirm. * fix(board): undo targets the original workspace per Codex review (round 1) The 7s undo toast is global and runBulk read the live wsSlug, so navigating to another workspace before clicking Undo would target the wrong one. Thread an explicit captured `ws` through runBulkOn; the archive/move handlers capture wsSlug at action time and the undo closure reuses it, so a deferred undo always hits the workspace the action ran on. Immediate (non-undo) actions keep a thin runBulk bound to live ws. |
||
|
|
1d9a611508 |
feat(api): bulk restore op for undo (TASK-1674 backend) (#675)
Add a 'restore' verb to the bulk endpoint so an undo of a bulk archive is one call. The loop resolves include-deleted for restore (archived rows are hidden from ResolveItem); applyBulkOp calls store.RestoreItem, mapping UNIQUE-constraint races to a conflict and sql.ErrNoRows to not-found, and logging action="restored". Also make ResolveItemIncludeDeleted UUID-aware (mirrors ResolveItem) so restore resolves by the ids the bulk response returns. Adds 'restore' to the TS BulkItemOp / BulkItemsRequest union and a Go test (archive → restore round-trip by id). |
||
|
|
99e8802095 |
feat(board): wire bulk lane actions via the bulk endpoint (TASK-1672) (#674)
* feat(board): wire bulk lane actions via the bulk endpoint (TASK-1672)
Extract LaneActionsMenu.svelte — a drill-down kebab menu — and populate
it with the full mutation set, each operating on the lane's CURRENTLY-
FILTERED items via api.items.bulk:
- Archive all (count + "(filtered)" in the confirm)
- Move all to ▸ (other status lanes; status-grouped boards only)
- Tag all ▸ (free input + workspace tag suggestions)
- Untag all ▸ (tags present on the lane's items)
- Set priority ▸ (the priority field's options)
- Assign all ▸ (workspace members by name)
Page handlers funnel through a shared runBulk() that calls the bulk
endpoint once, deltaSyncs, and toasts the updated/failed counts; it
returns the affected ids for TASK-1674's undo. BoardView threads the
lane's items + member/tag data + callbacks into the menu; the old
inline menu markup/styles (TASK-1671) move into the component. All
actions canEdit-gated. Drill-down clicks stopPropagation (the Svelte 5
same-click detach fix). Move scope = status, Assign = members only.
* fix(board): chunk bulk lane actions at the 1000-id server cap per Codex review (round 1)
The bulk endpoint rejects >1000 ids; the old per-item archive loop had
no ceiling, so a >1000-item filtered lane would fail every action with
"too many items". runBulk now chunks ids at 1000 (one bulk call per
chunk — a few SSE events instead of thousands) and aggregates the
updated/failed counts across chunks.
* fix(board): finalize partial bulk results when a chunk throws per Codex review (round 2)
A thrown chunk returned early, skipping deltaSync and the count toast —
leaving the UI stale after earlier chunks had already mutated items
server-side, behind a generic error. Move the try/catch inside the loop
(break on throw), then always deltaSync (when anything succeeded) and
toast the partial result: 'Verb N, M failed' on partial success, the
server error message only when nothing succeeded.
* fix(board): gate bulk lane actions on owner/editor role per Codex review (round 3)
canEditThisCollection is grant-aware (true for collection edit grants),
but the bulk endpoint requires workspace owner/editor (TASK-1668), so a
grant-only editor saw the lane bulk actions and got a 403 per click.
Gate the bulk callbacks (archive-all/move/tag/untag/set-priority/assign,
in both Board and List) on a new canBulkEdit = owner|editor role. The
single `+` create stays grant-aware (canEditThisCollection). LaneActions-
Menu's action callbacks are now optional and each entry hides when its
callback is absent, so a grant-editor sees only "Add item here".
* fix(board): drive lane-action visibility off callbacks, not canEdit per Codex review (round 4)
The kebab wrapper was still inside {#if canEdit} (= canEditThisCollection),
so an owner/editor without a collection edit grant (canBulkEdit true,
canEdit false) couldn't open the menu at all. Show the `+` when
onCreateInColumn is wired and the kebab when any menu action is wired
(hasMenuActions) — each callback already encodes its own permission.
Same fix for ListView's archive-group button (gate on onArchiveGroup
alone, not canEdit && onArchiveGroup).
* fix(board): lane-aware kebab visibility per Codex review (round 5)
The kebab showed whenever any bulk callback existed, but the bulk menu
entries only render for a non-empty lane — so a role-only bulk editor
(no onCreateInColumn) saw a ⋯ that opened an empty panel on empty
schema lanes. Show the kebab only when onCreateInColumn is wired OR the
lane has items AND a bulk action is wired.
|
||
|
|
520380946e |
feat(collections): page-wide sort control + priority-weight helper (TASK-1670, closes IDEA-1648) (#673)
Add a Sort dropdown to the collection toolbar (Manual, Priority, Recently updated, Created, A→Z) applied within each lane/group in both BoardView and ListView. A shared helper (lib/collections/itemSort.ts) builds the comparator; the priority weight reads the `priority` select field's own options order, so high/medium/low and must/should/nice both rank naturally (top option = highest) with no hardcoded map. - 'manual' (default) resolves to the stored sort_order — prior behavior. - Non-manual sorts disable item drag (like the preserveOrder seam): a comparator-ordered lane can't accept a drag, so DnD is suppressed. - 'Priority' is hidden when the collection has no priority field; a stale persisted 'priority' falls back to 'manual'. - Sort persists per collection in localStorage (mirrors view mode). - Table view keeps its own column sorting; the control is board/list only. |
||
|
|
9dd4fa653e |
feat(board): lane-header + add button + kebab menu shell (TASK-1671) (#672)
* feat(board): lane-header + add button + kebab menu shell (TASK-1671, folds IDEA-1159) Replace the hover-only .archive-col-btn in BoardView lane headers with two always-visible, touch-sized affordances: - `+` add-into-this-lane button → onCreateInColumn(colValue), which quick-creates an item with the lane's group field pre-filled (status or a custom board_group_by) and opens it. - `⋯` kebab → a lane menu (the new home for column actions). Shell carries "Add item here" + "Archive all (N)" with an in-menu confirm; move/tag/priority/assign land in TASK-1672. One menu open at a time, dismissed on outside click (QuickActionsMenu pattern). Both affordances are canEdit-gated. Touch targets bump to 32px ≤768px. * fix(board): stopPropagation on lane-menu clicks per Codex review (round 1) Clicking "Archive all" set confirmArchiveColumn, which re-rendered and detached the clicked button before the event bubbled to the window outside-click handler — closest() on the orphaned node returned null and slammed the menu shut, so the confirm never showed. Stop propagation on the kebab toggle and every in-menu click (the documented Svelte 5 same-click bubbling fix from console/+layout.svelte). |
||
|
|
2721d890ec |
feat(web): bulk-mutation API client method + types (TASK-1669) (#671)
* feat(web): bulk-mutation API client method + types (TASK-1669)
Add api.items.bulk(ws, data) hitting POST /workspaces/{ws}/items/bulk
(TASK-1668), plus types in lib/types: BulkItemOp, a discriminated-union
BulkItemsRequest (each verb requires only its own params), and
BulkItemOutcome / BulkItemFailure / BulkItemsResponse for the per-row
outcome envelope. Wire contract matches the backend struct field-for-
field; `details` is unknown (server json.RawMessage).
Unblocks the lane-header bulk-action UI (TASK-1672).
* fix(web): assign bulk op ids are string-only, not nullable per Codex review (round 1)
The server treats JSON null as absent (mirrors ItemUpdate) and rejects an
assign with no real id and no clear flag, so a nullable type would let a
caller type-check `assigned_user_id: null` into a 400. Type these string-
only and document that clearing goes through the clear flags.
|
||
|
|
57995c5898 |
fix(sync): moved-out tombstones for cross-visibility collection moves (BUG-1675) (#670)
* fix(sync): emit moved-out tombstones for cross-visibility collection moves (BUG-1675) /items-changes filtered deltas by an item's CURRENT collection, so an item moving from a collection a restricted member can see into one they can't vanished with no eviction signal — the stale, now-unauthorized row lingered in their local cache until a full rebootstrap. Server: - store.ListMovedOutSince: finds items that changed since the cursor, are now outside the caller's visible scope, and have a 'moved' activity FROM a collection the caller CAN see. Returns id+seq only — no destination data leaks (the caller has read access to the source). - handleListItemsChanges merges these in as moved_out tombstones, then seq-sorts + caps the combined stream so pagination stays gap-free. - Bulk collection moves now log a proper 'moved' activity with from/to collection slugs (mirroring handleMoveItem) — the signal the tombstone query reads. Previously they logged generic 'updated'. Client: - ItemChangeRow gains moved_out; applyDelta hard-evicts those ids from RAM + search and queues the IDB delete into the SAME atomic cursor-advance tx (persistDelta gains removeIds) so it can't resurrect on warm boot. Full members (nil visibility) skip the extra query entirely — the path only runs for restricted members/guests. Tests: store-level matrix (ListMovedOutSince), end-to-end restricted member /items-changes tombstone, bulk-move 'moved' activity logging. * fix(sync): tie moved-out tombstone to the move event's seq per Codex review (round 1) Keying the tombstone on the item's CURRENT seq meant any later change while it sat in a hidden collection re-emitted a moved_out row — leaking that an invisible item keeps mutating, and never settling. Stamp the post-move seq into the 'moved' activity metadata (both single + bulk move paths) and key the tombstone on THAT seq: it fires once, for the move that crossed the visibility boundary, and the cursor settles past it. Moves logged before the seq stamp are skipped (evict on rebootstrap) rather than risk the re-fire. Test: re-fire regression (a post-move hidden-collection update must not re-emit the tombstone). * fix(sync): page moved-out tombstones by move seq, not current seq per Codex review (round 2) Ordering/capping candidates by the item's current seq could strand an item that moved out early (low move seq) but later churned in the hidden collection (high current seq): it fell past the limit while the cursor advanced beyond its move seq, never to be emitted again. Collect all eligible rows, keep the earliest qualifying move per item, sort by move seq, then apply the limit at a move-seq boundary so dropped rows re-fetch cleanly on the next poll. Test: 3 items move out ascending; the earliest churns to a high current seq; limit=2 must still return the two smallest move seqs, then the third on the next page with no gap. * fix(sync): durable item_collection_moves table for moved-out detection per Codex review (round 3) Moved-out detection read the 'moved' activity row, which is written after the move commits and best-effort (errors discarded) — so a delta poll racing the audit write, or a failed write, could advance the cursor past the move seq and strand the unauthorized item forever. Record every cross-collection move in a new item_collection_moves table inside the SAME transaction as the move (MoveItemWithPreCheck), carrying the workspace seq the move assigned. ListMovedOutSince now reads that table — fully SQL/indexed (from_collection_id IN visible, MIN(seq) for multi-hop, current-collection NOT IN visible), no JSON parsing, no best-effort dependency. The 'moved' activity stays for audit only. Migration 066 adds the table + indexes. Tests updated to rely on the durable record (MoveItem writes it) rather than hand-logged activity. * fix(sync): add Postgres migration for item_collection_moves per Codex review (round 4) Postgres reads the separate pgmigrations/ tree, so the SQLite-only migration 066 left item_collection_moves absent on PG deploys — every cross-collection move would fail at the in-tx insert and moved-out queries would error. Add pgmigrations/045 with the equivalent table + indexes. |
||
|
|
dfd3811eee |
feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668) (#669)
* feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668)
Add POST /workspaces/{ws}/items/bulk accepting item IDs + a verb
(archive, move, tag, untag, set-priority, assign). The lane-header
bulk actions operate on a whole filtered lane, so the endpoint emits
ONE items_bulk_updated SSE event and ONE item.bulk_updated webhook for
the batch instead of per-item fan-out.
Reuses the existing store paths (UpdateItemWithPreCheck / MoveItem /
DeleteItem) rather than re-implementing writes; the open-children
guard runs per status-bearing move exactly as the single PATCH path
does (force-overridable). Per-row failures are collected into the
response envelope (updated/failed/total) rather than aborting the
batch. Editor/owner gated.
Frontend client + TS types follow in TASK-1669; UI wiring in TASK-1672.
Parent: PLAN-1667.
* fix(api): per-item visibility + collection-move guard on bulk endpoint per Codex review (round 1)
- Enforce per-item collection visibility (checkItemVisible) in the bulk
loop so a member with collection_access="specific" can't bulk-mutate
items in hidden collections by guessing refs; report invisible rows as
not-found. Also gate the move target collection on visibility.
- Route bulk collection moves through MoveItemWithPreCheck with the
open-children guard (destination schema), closing the bypass where a
collection move + terminal status could mark a parent terminal with
open children. Status-only moves already ran the guard.
- Tests: status-move + collection-move guard coverage (reject + force
override + mutation-safety).
* fix(web): consume items_bulk_updated SSE event per Codex review (round 2)
The bulk endpoint emits one items_bulk_updated event, but the SSE
service only listened for the fixed ITEM_EVENTS list — so a bulk
mutation left other tabs/sessions stale until an unrelated sync fired.
Route the batch event through the existing sync_required path: it
carries item_ids + a max seq but no per-item field payload, so an
incremental /items-changes delta reconciles every affected row by seq.
Broadcast so peer tabs reconcile too.
* fix(api): scope bulk SSE event per-collection, drop item_ids per Codex review (round 3)
The batch event published with an empty Collection, which the SSE
filter treats as workspace-level: restricted members received bulk
events for hidden collections (leaking item_ids/op/count) while guests
with grants were dropped entirely and stayed stale.
Emit one items_bulk_updated event per affected collection with
Collection set, so the existing visibility filter routes it like any
collection-scoped event. Drop per-item IDs from the SSE payload — a
batch can't be item-grant-filtered for guests on a broadcast bus, so
IDs would leak; recipients reconcile via the /items-changes delta,
which is visibility-filtered server-side (Seq carries the cursor). The
webhook (a trusted workspace integration) keeps the full id list.
Test asserts the event is collection-scoped and carries no item_ids.
* fix(api): bulk collection move notifies both source and target scopes per Codex review (round 4)
A cross-collection move only emitted a batch event for the target
collection, so a restricted member watching the source lane wouldn't
reconcile the item leaving it. Notify both the source and target
collection scopes for moves (still no per-item IDs). Test asserts both
events fire.
* fix(api): suppress itemless batch SSE events for item-grant-only subscribers per Codex review (round 5)
A guest/restricted member with only item-level grants in a collection
could still receive the collection-scoped items_bulk_updated event
(itemless), learning op/count/timing for items they can't see. Extract
the SSE visibility filter into sseEventVisibleFor and add a rule:
itemless collection-scoped events go only to subscribers with FULL
collection access; item-grant-only subscribers reconcile their granted
items via the next resume/reconnect /items-changes sync instead.
Adds a unit test covering the visibility matrix.
* fix(api): validate status override against target schema on bulk collection move per Codex review (round 6)
A status override on a collection move was applied after MigrateFields
but never validated against the target schema, so an out-of-options
value (e.g. status=bogus) could be written. Run ValidateFields on the
final field map before the move. Test asserts the invalid value is
rejected per-row and the item stays put.
|
||
|
|
1be42e5406 |
feat(collections): multi-select tag filter + per-collection tag counts (TASK-1666) (#668)
Add a TagFilter chip row to the collection page FilterBar — one toggle chip per tag present in the collection, each showing its item count, with OR selection semantics. Tag counts are derived client-side from the local index (no network round-trip) and honor the showArchived toggle. Selection round-trips through the `tags` URL param and the saved-view ViewConfig (rides along as a single `op: 'in'` filter). Implements TASK-1666. |
||
|
|
0d5f96657b |
feat(comments): inline comment/reply editing via CommentEditor (TASK-1665) (#667)
* feat(comments): inline comment/reply editing via CommentEditor (TASK-1665) Completes PLAN-1662. Adds inline edit mode for comments and replies, built on the backend (TASK-1663) + CommentEditor (TASK-1664). - Edit affordance (hover pencil) next to Delete on each comment and reply, shown only when editable: comment.user_id === currentUserId || isAdmin — distinct from canEdit (item perm, which gates delete/reply/react). Mirrors the server's canEditComment; null user_id → admin-only. - Clicking Edit swaps the rendered body for a CommentEditor seeded with the current body; Save calls api.comments.update via an onEdit callback (throws on failure so the editor keeps the draft); Cancel restores. One reply edits at a time (editingReplyId). - Image removal falls out: removing an image is just deleting its node in the editor and saving — no separate endpoint/button. - "edited" marker (· edited, title=updated_at) when updated_at is meaningfully after created_at. Reactions live in separate tables and don't bump updated_at, so it's edit-specific. - handleReply now re-throws so the reply editor also preserves its draft on failure; comment_updated SSE refresh was wired in TASK-1663. Parent: PLAN-1662. * fix(comments): flag edited on any positive updated_at delta per Codex review (round 1) created_at and updated_at are set identically on create, so a strict inequality (not a >1000ms threshold) is the correct 'edited' signal at RFC3339 second precision — the old threshold missed edits exactly 1s later. Same-second edits remain undetectable without an explicit edited_at column; acceptable for v1. |
||
|
|
6ed16ef930 |
feat(comments): lean Tiptap CommentEditor — inline image thumbnails (TASK-1664) (#666)
Replaces the plain-textarea comment composer and reply box with a small WYSIWYG editor so pasted/dropped images render as inline thumbnails instead of `` markdown text. - web/src/lib/components/CommentEditor.svelte: a purpose-built Tiptap instance (NOT the heavy Editor.svelte) — StarterKit basics + Link + Placeholder + tiptap-markdown + the shared attachment pipeline (AttachmentUpload plugin + AttachmentImage/AttachmentChip nodes). No tables/slash/collab/URL-modal. Emits markdown via the markdown storage (round-trips through the nodes' addStorage serializers), so comment.body stays markdown — display, lightbox, search, and orphan-GC are untouched. Wraps the upload fn to track in-flight uploads and gate submit (the plugin doesn't expose its placeholder count). Ctrl/Cmd+Enter submits, Esc cancels (reply mode). - ItemTimeline composer + TimelineCommentCard reply box now render CommentEditor; submitComment/submitReply take the markdown string and throw on failure so the editor preserves the draft. Removed the textarea + commentAttachments paste/drop wiring and now-dead CSS. Inline thumbnails in the editor are capped to match the rendered-comment display. Parent: PLAN-1662. Unblocks TASK-1665 (edit mode reuses this). |
||
|
|
076fb9b2e7 |
feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663) (#665)
* feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663)
Foundation for comment editing (PLAN-1662). No migration — comments.user_id
already exists (012_users.sql) but was never written or exposed.
- Populate user_id on create/reply: CreateComment takes an explicit userID
param (passed from currentUserID by the handlers, not via the request body
so it can't be spoofed). Expose user_id on models.Comment + all comment
SELECTs/scans. The workspace export path is left as-is — imported comments
keep NULL user_id (admin-only edit), matching the pre-identity fallback.
- Store.UpdateComment(id, body): replaces body + bumps updated_at; the
comments_fts_update trigger re-indexes.
- PATCH /workspaces/{ws}/comments/{commentID}: author-or-admin only
(canEditComment), rejects empty body. Editing is an authorship op, distinct
from delete (item editors). NULL user_id → admin-only.
- comment_updated SSE event: broadcast from the handler; added to the web
sse allowlist + ItemTimeline refresh set.
- web: api.comments.update(), Comment.user_id type.
Tests: author edits own (200), non-author non-admin (403), admin edits
anyone (200), empty body (400), NULL-user_id comment is admin-only.
Parent: PLAN-1662.
* fix(account): detach authored comments on account deletion per Codex review (round 1)
Now that TASK-1663 populates comments.user_id (FK to users.id),
DeleteAccountAtomic would fail on the FK for any user who authored a
comment. Null comments.user_id for the user before deleting the row —
comments live on in soft-deleted/other workspaces; the display-name
author is preserved and the comment just becomes admin-only to edit.
Regression test added.
|
||
|
|
4c2c4d1e1d |
feat(comments): thumbnail comment images + click-to-expand lightbox (IDEA-1660) (#664)
* feat(comments): thumbnail comment images + click-to-expand lightbox (IDEA-1660)
Tier 1 + Tier 2 of the IDEA-1650 follow-up.
Tier 1 — thumbnails:
- Thread an image `variant` through the markdown render pipeline
(renderMarkdown → AttachmentRenderContext → resolveAttachmentImage →
renderAttachmentImage), defaulting to thumb-md. Comment + reply bodies
pass thumb-sm (256px) so pasted screenshots fetch the small variant.
- CSS caps inline attachment images to a 280×180 box with cursor:zoom-in.
Tier 2 — lightbox:
- New reusable Lightbox.svelte (common/) — full-resolution overlay,
Esc/backdrop close, ←/→ paging, image counter. Loads the original
(un-variant) blob for full detail.
- ItemTimeline attaches a delegated click handler (via a use: action, so
no a11y lint on the static container) that opens the lightbox on any
img[data-attachment-id], collecting sibling images in the same
comment/reply body for paging.
No backend changes. Tier 3 (dims-via-headers, multi-image grid,
loading=lazy) intentionally deferred.
* fix(comments): keyboard-activatable comment image thumbnails per Codex review (round 1)
Thumbnails come from sanitized {@html}, so they can't be wrapped in a
<button> at render time. Instead make each img[data-attachment-id] a
focusable role=button with an aria-label imperatively (re-applied on
entries change, covering SSE-added comments), and add a delegated
keydown (Enter/Space) alongside the existing click so keyboard users
can open the lightbox.
* fix(comments): re-run thumbnail focusability pass when attachment metadata resolves per Codex review (round 2)
An attachment renders as <img> only after its HEAD-probe metadata
resolves (before that it's a 'missing' placeholder span). The
focusability pass depended only on entries, so it missed images that
appeared on metadata resolution. Add attMeta as a dependency.
|
||
|
|
e179c595e4 |
feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650) (#663)
* feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650) Comment composers were plain textareas with no upload path, and comment bodies rendered markdown without an attachment resolver — so a `pad-attachment:UUID` reference would never display. This wires both halves end to end: - Compose: paste or drop files into the comment composer (ItemTimeline) and the reply box (TimelineCommentCard). A shared helper (commentAttachments.ts) splices an "Uploading…" placeholder at the caret, uploads concurrently via the existing attachment API, and swaps each placeholder for its `pad-attachment:UUID` markdown ref (image syntax for image MIMEs, link/chip syntax otherwise — mirrors the editor's split). Submit is gated while uploads are in flight. - Display: ItemTimeline lazily HEAD-probes each referenced UUID (reusing the editor's fetchAttachmentMetadata cache), builds a reactive resolver, and threads it into renderMarkdown for comments and replies so refs render as inline images / file chips. - Orphan GC: comment uploads leave attachments.item_id NULL (like the editor), but the GC reference scan only checked items.content/fields. Renamed AttachmentReferencedInItems -> AttachmentReferenced and extended it to scan comments.body, so a screenshot referenced only from a comment isn't reclaimed after the grace period. Added TestOrphanGC_KeepsAttachmentReferencedFromComment. Follow-up refinement (thumbnails + click-to-expand lightbox) captured as IDEA-1660. * fix(comments): escape markdown-significant chars in attachment filenames per Codex review (round 1) Filenames containing [ ] or backslash could break the generated  markdown. P2 (grant-aware upload auth) is a pre-existing endpoint-wide gap shared with the rich editor — tracked as BUG-1661, not fixed here to keep the PR focused. * fix(comments): preventDefault on dragover for file drops per Codex review (round 2) Browsers only deliver a file drop to a custom target if its dragover cancels the default; without it the page navigates to the file. Gated on isFileDrag so in-textarea text drag-drop is unaffected. |
||
|
|
5c0d367c12 |
feat(tags): list/board view toggle on the tag page (TASK-1656) (#661)
* feat(tags): list/board view toggle on the tag page (TASK-1656) Adds a board (kanban) view to the per-tag page: one lane per collection, reusing ItemCard (compact). Read-only — no drag/status changes, since moving a card between collection lanes would mean reclassifying the item. Implemented as a focused layout on the tag page rather than overloading the shared DnD/status-grouping BoardView (582 lines), whose semantics don't fit a read-only cross-collection view. Reuses the existing collection-grouped `groupedItems` derived; view mode persists per workspace via localStorage. Parent: PLAN-1652. * fix(tags): guard localStorage access on the tag view toggle per Codex review (round 1) localStorage get/set can throw when storage is disabled or blocked; wrap both so the page loads and the toggle works (in-session) regardless. |
||
|
|
1e3b3254d7 |
feat(tags): render tag chips on cards, list rows, and the item header (TASK-1655) (#660)
* feat(tags): render tag chips on cards, list rows, and the item header (TASK-1655) - lib/types: shared parseTags() — defensive JSON-array parse + case-insensitive dedupe; reused by ItemCard and the detail page (which drops its inline dupe). - ItemCard (shared by BoardView + ListView): a clickable tag-chip row. The card is an <a>, so chips are buttons that goto the tag page with stopPropagation — same pattern as the existing star/PR/status controls — avoiding nested anchors. - Item detail header: tag chips as real <a> links to the tag page. Chips link to /[ws]/tags/[tag]; those pages arrive in TASK-1657. Parent: PLAN-1652. * feat(tags): dedicated tag pages (index + per-tag aggregated view) + nav (TASK-1657) Folded into the chip-display PR because the chip links require the routes to exist — without them /[ws]/tags/[tag] is swallowed by the generic [collection]/[slug] route and hits the item error path (Codex PR #660 round 1). - /[ws]/tags — index: tag cloud with per-tag item counts (GET /tags), each linking to its tag page. - /[ws]/tags/[tag] — aggregated view: cross-collection items for the tag, grouped by collection (the shared axis across heterogeneous status enums), modeled on the starred page. A static `tags` segment takes routing precedence over [collection], so the collision is resolved. - Sidebar: a Tags nav entry; `tags` added to the reserved-slug guard so the route isn't mistaken for a collection. Parent: PLAN-1652. * fix(tags): reserve 'tags' collection slug + tighten Tags nav active-match per Codex review (round 2) - backend: add 'tags' to reservedCollectionSlugs so a collection can't be created with a slug that the /tags routes would shadow. - Sidebar: isTagsPage now matches exactly /tags or the /tags/ boundary, not any /tags* prefix (e.g. a 'tags-collection' path no longer marks it active). * fix(tags): don't drop item-granted rows whose collection isn't listable per Codex review (round 3) A restricted member can see an item via item-level grant without being able to list its collection, so getCollection() missed and the row was dropped while still counted (count != rendered). Synthesize a minimal collection from the item's embedded collection_* metadata so those rows render (empty schema = ItemCard omits status/priority). |
||
|
|
feb068a91f |
feat(tags): tag chip editor on the item detail page (TASK-1654) (#659)
* feat(tags): tag chip editor on the item detail page (TASK-1654) Tags live on item.tags (a JSON-array string), not the collection schema, so this adds a TagInput sibling to FieldEditor rather than a field type. - TagInput.svelte: chip editor — Enter/comma to add, Backspace/× to remove, case-insensitive dedupe (stored as typed), autocomplete dropdown sourced from the workspace tag set; readonly mode renders plain chips. - Item detail page: derive `tags` from item.tags (defensive parse), load `tagSuggestions` via a workspace-keyed $effect kept separate from the item-load path (Svelte 5 effect-splitting convention), and updateTags() mirrors updateField() — optimistic with revert-on-failure, PATCHing `tags`. The Tags row renders between the schema fields and the Assignment section. api.items.update already accepted `tags` via ItemUpdate, so no client change was needed there. Parent: PLAN-1652. * fix(tags): guard overlapping tag saves with a sequence counter per Codex review (round 1) Rapid chip edits can issue overlapping PATCHes; a late-resolving older request could clobber the newer tag set with stale data or an errant revert. Only the latest save (by monotonic seq) applies its result or reverts. * fix(tags): drop stale tag-suggestion results across workspace navigation per Codex review (round 2) loadTagSuggestions now only assigns when the in-flight workspace still matches the current one, so a slower old-workspace /tags response can't overwrite the new workspace's autocomplete. * fix(tags): dedupe tags + key chips by index per Codex review (round 3) An item can carry duplicate tags (e.g. ["ux","ux"]) since the write path doesn't enforce per-item uniqueness, which would collide value-based Svelte keys. Key chips by index, and dedupe case-insensitively at the source so the cleaned set persists on the next save. * fix(tags): gate tag-save completion UI on item freshness per Codex review (round 4) If the user navigates to another item while a tag save is in flight (no further edit, so the seq guard doesn't trip), skip showSaved()/toast/refresh so completion UI can't fire on an unrelated page. * fix(tags): serialize+coalesce tag saves, revert to last confirmed per Codex review (round 5) Replace the concurrent-PATCH-with-seq-guard approach with a single in-flight, coalescing saver scoped per item. Eliminates the overlap class structurally: no stale completion clobbers a newer set, and `confirmed` tracks the last server-acknowledged tags so a failed save reverts to server truth rather than an optimistic unconfirmed value. Subsumes the round-1 race guard and round-4 navigation gate. * fix(tags): key tag savers by item id to prevent cross-navigation concurrency per Codex review (round 6) A single saver slot let navigating away from an item mid-save and back spawn a second concurrent saver for it. Hold savers in a Map keyed by item id so edits coalesce into the existing in-flight saver; evict on drain. * fix(tags): reapply in-flight desired tags after item reload per Codex review (round 7) Navigating away and back mid-save reloaded stale server tags; a follow-up edit computed from that stale set could drop the in-flight edit. The saver now tracks the latest desired set and loadData reapplies it when a save is still in flight for the reloaded item. * fix(tags): keep save indicator active across reload so refresh guards hold per Codex review (round 8) loadData reset saveStatus to idle while a tag PATCH was still in flight, letting SSE/sync snapshot adoption bypass the saveStatus==='saving' guard and land stale tags. Restore 'saving' when reapplying an in-flight saver so the existing refresh guards keep skipping until the save drains. * fix(tags): overlay in-flight tags at every server-snapshot assignment per Codex review (round 9) The saveStatus guard is racy (checked before the refresh handlers' own await, not after), so a concurrent snapshot could still drop optimistic tags. Extract withInflightTags() and route ALL item = <server snapshot> sites through it (realtime SSE/sync, initial load, content-save echoes, title/field/assignment/ role update echoes, post-action refresh, version restore). Overlaying the saver's desired set at assignment time is race-free regardless of the guard. * fix(tags): overlay tags on field-save + forced-retry echoes per Codex review (round 10) updateField's success echo (item = fresh) and the forced open-children retry (item = forced) were the last two un-overlaid server-snapshot assignments; route both through withInflightTags so a concurrent tag save isn't clobbered. * fix(tags): preserve unsaved content when reconciling tag-save echo per Codex review (round 11) flushTagSaver adopted the full tag PATCH response (item = fresh), which carries server content and could clobber unsaved editor edits. Route it through adoptServerItem so local content is preserved (non-collab) like the other snapshot adoption sites. |
||
|
|
1b1068537c |
feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653) (#658)
* feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653)
Foundation for the tags feature (PLAN-1652 / IDEA-1649). The write path and
per-collection ?tag= filter already existed; this adds tag enumeration and a
verified cross-collection read so a single tag can group items of any type.
- store: dialect.JSONArrayElements unnests a JSON text-array column
(json_each on SQLite, jsonb_array_elements_text on Postgres);
Store.ListWorkspaceTags returns distinct tags + item counts, ordered by
count desc then tag asc, with the same collection/item ACL filters as
ListItems so counts never leak hidden items.
- server: GET /workspaces/{ws}/tags (handleListTags), respecting collection
visibility + guest item grants.
- models: TagCount{tag,count}.
- cli: client.ListTags + `pad tag list`.
- web: api.tags.list + TagCount type (items.list already forwards `tag`).
- tests: store-level (cross-collection aggregation, collection scoping,
non-nil-empty = empty, archived excluded) and handler-level (a Task + an
Idea sharing one tag; GET /tags counts + ordering).
Parent: PLAN-1652.
* fix(tags): count distinct items per tag, not tag occurrences per Codex review (round 1)
COUNT(DISTINCT i.id) so an item with duplicate tags (e.g. ["ux","ux"]) is
counted once — the write path doesn't enforce per-item tag uniqueness.
Adds a regression test.
|
||
|
|
9870f364ac |
feat(insights): "What shipped" card with linked item IDs on the dashboard (#657)
Mirror the print report's completed-items list on the live Insights
dashboard, grouped by collection. Each item ID is a link to the item
(/{user}/{ws}/{collection}/{ref}) — the route resolves PREFIX-NUMBER refs
via ResolveItem, so no slug lookup is needed. Fetches the report with
include_items=true; the card is toggleable via Customize and persists like
the other cards (new "completed_items" id, defaults visible).
|
||
|
|
a6667152cc |
fix(insights): professional print formatting for the exec report (TASK-1647) (#656)
* fix(insights): professional print formatting for the exec report (TASK-1647) Polish the /insights/print @media print block (screen view unchanged): - Print typography: 10pt base so em sizes cascade down; capped headings (title 16pt, section 12pt, stat values 14pt, labels 8–9pt). - Charts: cap canvas height to 150px in print (overrides the inline 200–240px); smaller legend. - Page breaks: break-inside:avoid on status tables + rows + stat cards + chart canvases; section headings kept with their content (break-after:avoid). - Density: tighter report/section/stat/list gaps for a compact report. Parent: PLAN-1628 (IDEA-1627). * redesign(insights): charts-up-front dashboard layout + fix LayerCake print overlap (TASK-1647) - Move the three charts to a compact 2-up grid right after the headline stats (charts up front, half-width, not full-bleed). - Fix the chart-overlap bug: charts were sized via an inline screen height that LayerCake measured and cached, then an @media-print rule shrank the box afterwards, leaving the SVG drawn at the old range and spilling onto the next panel. Now pass small heights (130-150px) identical for screen + print so the measurement matches; add overflow:hidden as a clip-safety. - Cycle time becomes a full-width band: metrics rail + short chart. - What shipped flows into two columns (dense, fills page width) and is no longer kept whole — .shipped-group/.block/.status-table dropped from the break-inside:avoid set so a tall section no longer jumps to a fresh page and leaves the big blank gap on page 1. Only small atoms (tr, shipped line) stay unsplit. - Compact table cell padding + repeat thead across page breaks. * fix(insights): stop print charts clipping + shorten throughput X labels (TASK-1647) - Charts were measured at the 960px app width on screen (~450px per 2-col panel) then printed into ~360px columns, so LayerCake's cached width drew the SVG too wide and overflow:hidden clipped the right edge (only ~3 of 4 collection bars showed). Constrain the print page's on-screen report to a paper column (720px) so the measured width already fits the printed sheet — WYSIWYG, no clipping, all bars visible. - Throughput X-axis used full ISO dates (2026-05-23) that overlap once several buckets share a narrow chart; render compact M/D (or 'M/D Hh' for hourly) labels via fmtBucket. maxTicks=8 already thins longer windows. |
||
|
|
0d46842cf7 |
feat(insights): print-optimized exec report view (Save as PDF) (TASK-1642) (#652)
* feat(insights): print-optimized exec report view (Save as PDF) (TASK-1642) Add /[username]/[workspace]/insights/print — a clean, shareable report of "what's been done this period" for the active selection, exported via browser print (no server-side PDF dependency). A "Print report" link on the Insights page passes the current window/offset/collections as query params. The report renders: header (workspace, period label honoring offset, generated-on), headline stats (completed/created/net flow/median cycle-time), "what shipped" (completed_items grouped by collection, ref + title, +N more on overflow), all charts (throughput, completed-by-collection, cycle-time) as SVG, and a compact status table. A layout reset (+layout@) drops the workspace chrome; @media print + :global hides the root shell/sidebar + the Print button, with sensible page breaks. Loading/error/empty states handled. Parent: PLAN-1628. * fix(insights): hide mobile topbar in print report CSS per Codex review (round 1) The print @media rules only hid chrome inside .app-layout, but the mobile TopBar (<header class="topbar topbar-mobile">) renders as a sibling before .app-layout, so a Save-as-PDF from a mobile viewport included the app bar. Hide :global(.topbar-mobile) in print too. |
||
|
|
b68164a714 |
feat(report): opt-in 'what shipped' completed-items list (TASK-1641) (#651)
* feat(report): opt-in 'what shipped' completed-items list (TASK-1641)
Add ?include_items=true → completed_items[{ref,title,collection,completed_at}]
on the report: items that reached a positive terminal in the window, deduped
by item (newest completion first), capped at 500 with
completed_items_overflow_count. Same positive-terminal source as
totals.completed (joins live items, deleted_at IS NULL), so the list reconciles
with the count. Opt-in so the interactive dashboard stays count-only; the
print/export report (TASK-1642) requests it.
Web ReportData gains completed_items + the api.report.get includeItems flag.
Parent: PLAN-1628.
* fix(report): scope completed-items list to the item's current visible collection per Codex review (round 1)
The list scoped transitions by st.collection_id (visible at completion) but
returned the item's CURRENT title/ref/collection — so an item completed while
visible then moved to a hidden collection could leak its hidden collection
slug/prefix + current title to a restricted caller. Require i.collection_id to
be in the resolved (scoped) collection set on both the count and list queries.
Adds a move-to-hidden-collection visibility test.
|
||
|
|
4368c576c5 |
feat(insights): reconstruct historical WIP + status snapshot for past periods (TASK-1640) (#650)
* feat(insights): reconstruct historical WIP + status snapshot for past periods (TASK-1640)
For a past period (offset>0), reconstruct the status-distribution and WIP
snapshot as-of the window end from status_transitions, instead of hiding them.
An item's status as-of-T = the to_status of its latest transition on the
collection's done field with created_at <= T (NOT EXISTS "no later transition",
dual-dialect, no window functions); only items that existed at T (created_at
<= T) and weren't deleted by T are counted. WIP age is measured against T.
offset 0 keeps the live-from-items path (ground truth for now). Frontend drops
the interim hide so the cards show in the past, with a note that they're
reconstructed (older periods approximate — the backfill parsed the
debounce-coalesced activity log).
Parent: PLAN-1628.
* fix(insights): items-first historical snapshot + period-aware WIP subtitle per Codex review (round 1)
1. reportSnapshotAsOf started from status_transitions, so an item that existed
at T but had no done-field transition (e.g. created with fields={}) was
dropped — undercounting historical WIP vs the live path, which treats a
missing done value as open. Start from items and correlated-subquery the
latest transition <= T (NULL → empty/open). Adds a no-transition-open test.
2. WIP card subtitle "Open items right now" → "Open as of this period" when
viewing a past period (offset>0), matching the reconstructed snapshot.
* fix(store): record done-field CLEARS as transitions so historical snapshot is accurate per Codex review (round 2)
The capture hook only recorded a transition when newStatus != "", so clearing
a done field (e.g. done → unset) wasn't logged — and as-of-T reconstruction
would still show the old terminal value, undercounting historical open work.
input.Fields is the full merged blob (CLI/handler merge before write), so
newStatus == "" genuinely means cleared, not omitted. Drop the `!= ""` guard
in both the update and move hooks so X → "" is recorded. Adds a clear test.
* docs(store): document current-collection attribution as an accepted historical-snapshot limitation (Codex review round 3)
reportSnapshotAsOf attributes items to their CURRENT collection for past
periods (not the collection at t). Reconstructing historical collection
membership needs move-history replay (status-preserving moves record no
transition) — disproportionate, and consistent with the same current-collection
attribution the backfill + completed-by-collection already use. Documented as a
deliberate best-effort limitation rather than built; items rarely move.
* docs(store): document same-second transition-ordering limitation + file follow-up (Codex review round 4)
reportSnapshotAsOf's "latest transition <= t" is nondeterministic for 2+
same-second status hops on one item (second-precision created_at + random
UUID ids). Rare; documented as a known limitation and tracked as a follow-up
task (monotonic ordering column / sub-second timestamps).
|
||
|
|
0d0c660565 |
feat(insights): navigate to past periods (offset + prev/next) (TASK-1639) (#649)
Add an `offset` to the report (periods back; 0 = current, clamped >= 0): window becomes [now - (offset+1)*lookback, now - offset*lookback]. Throughput and cycle-time shift automatically; response echoes `offset` + shifted range. Backend: ReportOptions.Offset + ReportData.offset; handler parses ?offset=. Web: api.report.get passes offset; ReportData.offset typed. Insights page: ◀ Previous / Next ▶ controls (Next disabled at offset 0) + a period label; offset is session-only (not persisted to the layout); resets on window or workspace change. Interim: WIP + status-distribution are hidden when viewing a past period (they're as-of-now) with a note — TASK-1640 reconstructs them historically. Parent: PLAN-1628. |
||
|
|
cd8ac9b618 |
feat(charts): per-category hover tooltips + a11y titles (TASK-1638) (#648)
* feat(charts): per-category hover tooltips + a11y titles (TASK-1638) Hovering a bar chart shows exact numbers. Shared BarChart gains a per-category tooltip (hover a bucket → label + all series with color swatches), so it lands on throughput, aging, and completed-by-collection at once. - Bars layer: invisible full-height hit-rect per band reports the hovered index + band center; lifted to BarChart which renders an edge-clamped, absolutely-positioned HTML tooltip. Grouped inner-band layout unchanged. - A11y: native <title> per visible rect (and a band-summary title on the hit-rect); chart keeps role=img + aria-label. - Sparkline: concise native <title> (latest / min–max). Parent: PLAN-1628. * fix(charts): cap tooltip width to the chart so it can't overflow per Codex review (round 1) min-width:max-content prevented the tooltip from shrinking, so long (user-controlled) collection names overflowed the canvas/viewport on narrow screens — the center clamp only repositioned. Cap max-width to the measured canvas width (box-sizing:border-box), let the header wrap (overflow-wrap), ellipsis-truncate long series labels (min-width:0), and keep the value column unshrunk (flex-shrink:0). |
||
|
|
eeff78118b |
feat(insights): per-user layout customization + persistence (TASK-1634) (#645)
* feat(insights): per-user layout customization + persistence (TASK-1634)
Let users personalize the Insights surface, persisted per-user per-workspace:
toggle which metric cards show, and remember the window + collection filter.
Backend:
- migrations 064/043: user_report_layouts (user_id, workspace_id, config JSON,
PK(user_id,workspace_id), ON DELETE CASCADE) — dual-dialect.
- models.ReportLayout (hidden_cards/default_window/default_collections) +
ReportCardIDs/ValidReportWindow validation.
- store.GetReportLayout / SaveReportLayout (ON CONFLICT upsert, both dialects).
- GET/PUT /workspaces/{ws}/report/layout — per-user; PUT sanitizes window +
filters hidden_cards to the known card set. web client + TS type.
Frontend (Insights page):
- loads the saved layout, hydrates window/collections/hidden cards
- a "Customize" panel toggles each card (SvelteSet-backed); each section gated
on !hiddenCards.has(id); Totals always shown
- debounced auto-save, gated on a per-workspace `hydrated` flag so it never
saves during load or stomps another workspace's layout on switch
Single config per user (no named/multiple layouts — deliberate v1 scope).
Parent: PLAN-1628.
* fix(insights): save layout only on explicit user changes, not on load per Codex review (round 1)
The auto-save $effect ran once after hydration (loadLayout assigns reactive
state, then flips hydrated=true), firing a PUT /report/layout on mere page
view — which 401s on no-user/legacy-token sessions and bounces the user to
/login. Replace the effect with a scheduleSave() called only from explicit
handlers (toggleCard, selectWindow, toggleCollection, clearCollectionFilter);
hydration never saves. Also capture wsSlug at schedule time and drop the
pending save if the workspace changes mid-debounce, so A's edit can't land
on B.
|
||
|
|
1a728fe51a |
feat(web): add Insights sidebar nav link (TASK-1636) (#644)
Add an "Insights" nav item to the workspace sidebar directly under Dashboard, routing to /[username]/[workspace]/insights (the Reports surface, TASK-1633). Mirrors the existing nav-item pattern with an isInsightsPage active state. No dashboard widget/CTA (owner directive — keep the dashboard uncluttered). Parent: PLAN-1628. |
||
|
|
5afacaf478 |
feat(web): Insights analytics page (TASK-1633) (#643)
* feat(web): Insights analytics page (TASK-1633)
Add /[username]/[workspace]/insights — the Reports surface consuming
GET /workspaces/{ws}/report via api.report.get + the LayerCake chart library:
- window segmented control (day/week/2wk/month) + collection filter chips,
both driving refetch via a single $effect
- totals (created/completed/net flow), throughput BarChart (created vs
completed per bucket), cycle-time (median/p90 + per-collection), WIP
(open count, median age, aging-band BarChart, per-collection), completed-
by-collection BarChart, status-distribution bar rows
- loading/error/empty states; responsive grid; titleStore "Insights"
Route is /insights (owner directive); sidebar link is TASK-1636. Card
toggling + saved layouts are TASK-1634. Svelte 5 runes; MCP-validated;
npm run check 0 errors. Parent: PLAN-1628.
* fix(web): reset insights collection filter on workspace change per Codex review (round 1)
SvelteKit reuses the route component across workspace param changes, so a
collection filter selected in workspace A leaked into B — sending A's slugs to
B's /report, which the server scopes to an empty set (no match) → empty report
for a non-empty workspace. Track the previous wsSlug in a plain (non-reactive)
var and clear selectedCollections on an actual workspace change before
snapshotting, so the new workspace starts unfiltered. Loop-safe.
* fix(web): guard insights report fetch against stale/out-of-order responses per Codex review (round 2)
loadReport left the previous report visible during an in-flight fetch and wrote
responses unconditionally, so switching workspace A→B showed A's data under B's
URL, and a slow older request could overwrite a newer selection. Add a plain
request-sequence counter: capture seq at fetch start, commit report/error only
when seq is still the latest, and only the latest request clears `loading`.
Also clear report/collections on an actual workspace change so A's data doesn't
linger under B while B loads.
* fix: reserve 'insights' collection slug to avoid route shadowing per Codex review (round 3)
The static /[username]/[workspace]/insights route shadows the dynamic
/{collection} route, so an 'insights'-slugged collection would be unreachable.
Add 'insights' to reservedCollectionSlugs (server, blocks creation) and to the
Sidebar's reserved-slug filter, matching the existing activity/starred/library/
ref precedent (which likewise reserve UI routes without migrating pre-existing
data — an 'insights' collection on this new feature is not expected).
|
||
|
|
d9fab3ea02 |
feat(report): cycle-time + WIP/aging metrics (TASK-1631) (#642)
* feat(report): cycle-time + WIP/aging metrics (TASK-1631)
Extend GET /workspaces/{ws}/report with two metric blocks:
- cycle_time: created→positive-terminal duration for completions in the
window — overall median + p90 + per-collection medians.
- wip: point-in-time open items (done field NOT a terminal value), open count,
median age, fixed aging bands (<1d/1-7d/7-30d/>30d), per-collection median age.
Medians/percentiles computed in Go from raw durations (dual-dialect: neither
SQLite nor Postgres has a portable percentile). Completed/WIP queries join live
items (deleted_at IS NULL), consistent with the rest of the report.
Wires through web ReportData TS types + `pad project report` rendering.
Tests: cycle-time median (backdated 48h), WIP open-count + aging bands,
percentile helper. Parent: PLAN-1628.
* fix(report): well-formed cycle_time/wip arrays on empty-scope path per Codex review (round 1)
The no-visible-collections early return left cycle_time.by_collection,
wip.aging_buckets, and wip.by_collection as nil → marshaled null, violating
the TS array contract for guests/restricted callers. Initialize those nested
slices in the ReportData literal so every path (including the early return) is
well-formed. Adds a JSON-shape regression test for the empty-scope case.
|
||
|
|
0c8c066dd6 |
feat(web): LayerCake chart component library for Reports (TASK-1632) (#639)
Add a reusable charting library under web/src/lib/components/charts/ to power the upcoming Reports surface (TASK-1633): - BarChart / LineChart / Sparkline (public) + Bars/Lines/AxisX/AxisY layers - theme.ts: CSS-var palette with hex fallbacks + typed LayerCake context - Svelte 5 runes; role=img + aria-label; "No data" empty states Library only (not wired into a page yet). Deps: layercake@10.0.2 + d3-scale@4.0.2 (+@types/d3-scale); npm override lets layercake accept the repo's TypeScript 6. ~8-12KB gzipped added when the Reports bundle imports them; Sparkline is dependency-free SVG. Parent: PLAN-1628. |
||
|
|
a1d09c90df |
feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630) (#638)
* feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630)
GET /workspaces/{ws}/report?window=week&collections=tasks,bugs returns a
time-bucketed report: created-vs-completed throughput, net flow,
completed-by-collection, and a current status-distribution snapshot.
- Dialect.DateBucket(column, granularity) — day/hour bucketing via fixed-width
substring on the UTC RFC3339 TEXT (identical + exact on SQLite + Postgres;
avoids SQLite 'Z'-parsing fragility). Routes all report date math through it.
- store.GetReport: resolves per-collection done field + positive terminals
(terminal options minus rejected/cancelled/etc.), counts completions from
status_transitions and created from items.created_at, zero-fills buckets.
- HTTP handler + route; web ReportData type + api.report.get client.
- Tests: throughput/totals, negative-terminal exclusion, status distribution,
collection filter, non-status done-field, out-of-window exclusion, hourly
day-window, DateBucket per granularity. Dual-dialect via testStore.
Fixes the response contract that TASK-1632/1633/1635 consume (noted on them).
Parent: PLAN-1628.
* fix(report): scope report to caller's visible collections per Codex review (round 1)
The endpoint sits under RequireWorkspaceAccess (members, restricted members,
guests), but GetReport resolved ALL workspace collections — letting a caller
with access to one collection infer hidden collections' slugs, created/
completed counts, and status distribution. Mirror the dashboard: the handler
computes visibleCollectionIDs() and GetReport restricts to that set
(ScopeToVisible). Empty visible set → empty report. Aggregate reports are a
full-collection-visibility feature; item-level grants aren't surfaced in
workspace-wide counts.
* fix(report): correct visibility scoping for all-access + item-grant callers per Codex review (round 2)
Round 1's scoping had two bugs in how it read visibleCollectionIDs:
1. nil means "all-access" (admin / collection_access=all), but the handler
treated nil as an empty visible set → all-access users got an EMPTY report.
Now nil → ScopeToVisible stays false (full workspace report).
2. For guests, visibleCollectionIDs includes collections visible only via
item-level grants; passing those to the aggregate report leaked the whole
collection's counts. Now mirror the dashboard: when item-level grants are
present, scope to fullCollIDs (full-access collections only).
Adds report handler tests (owner full report + default window) alongside the
store-level scoping test.
* fix(report): bearer-aware admin visibility scoping per Codex review (round 3)
visibleCollectionIDs grants ANY platform admin an unrestricted (nil) view, but
RequireWorkspaceAccess suppresses the platform-admin bypass for bearer auth and
falls through to membership (BUG-1616/1617). So a bearer admin (PAT/CLI/OAuth)
who is only a restricted workspace member could read the full workspace report.
Extract reportVisibleCollections(): gate the admin bypass on cookie auth; for
everyone else resolve actual member/guest visibility, and when item-level
grants exist scope to the full-access collection set only. Adds a cookie-vs-
bearer scoping test (cookie admin unrestricted, bearer restricted-member scoped
to the granted collection, end-to-end through GetReport).
* fix(report): exclude soft-deleted items from completion counts per Codex review (round 4)
status_transitions rows survive a soft delete (only a HARD delete cascades
them), so a completed-then-soft-deleted item still counted toward completed /
completed_by_collection while created and status_distribution (which filter
deleted_at IS NULL) excluded it — inconsistent totals. Join live items in both
completed queries. Adds a regression test.
|
||
|
|
225fb4a53f |
Wire upgrade CTAs with Stripe-ready billing flow (TASK-800) (#629)
* feat(billing): add billing_available session flag gated on PAD_BILLING_AVAILABLE (TASK-800)
Add Server.billingAvailable field set by SetBillingAvailable(), called from
cmd/pad/main.go when PAD_BILLING_AVAILABLE=true|1. Expose the flag as
billing_available in both the setup-state and authenticated session payloads
(value: cloudMode && billingAvailable) so the web UI can gate Stripe CTAs
without a code change at deploy time. False by default.
* feat(billing): wire upgrade CTAs, checkout POST flow, plan section, clickable limit toasts (TASK-800)
Frontend prep work gated on authStore.billingAvailable (from billing_available
session field). When false, upgrade buttons remain hidden and the "coming soon"
note stays in place — flip PAD_BILLING_AVAILABLE=true at deploy time.
Changes:
- client.ts: add billing_available to AuthSession; add api.billing.createCheckoutSession()
(POST /billing/checkout → parse {url} → caller does window.location.href)
- auth.svelte.ts: billingAvailable getter
- console/billing: replace STRIPE_AVAILABLE=false with $derived(authStore.billingAvailable);
fix GET→POST on upgrade buttons; add ?checkout=cancelled banner; add cancelled style
- console/settings: new cloud-mode-gated "Plan" section with current plan + upgrade/manage link
- All 11 limit-hit sites: replace plain-text '/console/billing' appendage with
toastStore.show(msg, 'error', 6000, '/console/billing') so the toast is clickable
* docs(billing): document pad-cloud CSRF and error-envelope contract divergences in createCheckoutSession (TASK-800)
|