mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
feat(web): collection page reads from localIndex (TASK-1357) (#504)
* feat(web): collection page reads from localIndex (TASK-1357)
Wire the collection page (web/src/routes/[username]/[workspace]/[collection]/+page.svelte) to the local-first read model.
- `items` is now `$derived` from `localIndex.getByCollection(ws, coll, { includeArchived: showArchived })`. The collection page no longer fires `/items-index` on every nav — bootstrap is idempotent and runs once per workspace per session.
- New `bootstrap` `$effect` calls `localIndex.bootstrap(ws, { userId })` when the workspace or signed-in user changes, picking up the warm-IDB / cold-/items-index flow from PLAN-1343 Phase 2.
- Mutations (`handleStatusChange`, `handleReorder`, `handleRestore`, `quickCreate`) call `localIndex.upsert(ws, item)` with the canonical post-API row; the derived `items` re-renders automatically.
- SSE handler now triggers `/items-changes` → `applyDelta` via a new `deltaSync` helper instead of refetching the whole collection. SSE merely says "something changed"; the local cursor pulls only the delta. TASK-1358 will refine this to per-event seq-stamped apply.
- `syncService.onSync` routes both incremental and full-refresh signals through the same `deltaSync` path. The legacy /changes payload is no longer threaded into the local store — the seq-cursor /items-changes is canonical.
- The plans cross-collection lookup for task relation labels reads directly from `localIndex.getByCollection(ws, 'plans')` — no extra request.
Parent: PLAN-1343. Depends on TASK-1355 + TASK-1356.
* fix(web): collection page loading + reactive plan labels (Codex round 1)
- [P2] deltaSync() now returns a boolean and the syncService.onSync
handler only calls markSynced() on a clean catch-up. A transient
/items-changes failure leaves the legacy cursor untouched so a
later tab-resume retries instead of pinning at "fresh".
- [P2] `loading` is now derived from BOTH the metadata fetch
(metaLoading) AND the localIndex bootstrap state. Without this,
non-empty collections briefly rendered the empty-state CTA while
items were still hydrating, and scroll-restore could be consumed
against an empty filteredItems list.
- [P3] `relationLabels` (plan-id → plan-title for task cards) is
now `$derived` over `localIndex.getByCollection(ws, 'plans')`
instead of a one-shot fetch in loadCollection. Plans flow into
the local store as they hydrate, so the badge stays correct
without a navigation refresh.
Parent: PLAN-1343.
* fix(web): always deltaSync + gate archive toast on success (round 2)
- [P2] syncService.onSync now runs deltaSync for ALL result types,
including 'caught_up'. SSE only delivers events, not delta data;
a previous incremental deltaSync failure won't recover without
a fresh fetch attempt. The localIndex cursor is independent of
syncService.lastSyncTime, and per-row seq guards make repeated
calls idempotent.
- [P3] handleBulkArchive now waits for deltaSync to succeed before
showing a definitive success toast. The server-side deletes are
already persisted; if the cache fetch fails, surface a softer
"queued / updating…" toast so the user knows the local view will
catch up. The deletes themselves are still real.
Parent: PLAN-1343.
* fix(web): optimistic local sort_order on reorder (Codex round 3)
[P2] handleReorder now upserts the row into the local index with
the new sort_order BEFORE awaiting the API. Otherwise ListView,
which calls onReorder without awaiting, resyncs its displayed groups
from the unchanged `items` prop the moment dragging ends and the
rows snap back to the old order until the network PATCH returns.
Clearing `seq: undefined` on the optimistic copy bypasses the
per-row seq guard so the real API response (with a higher seq)
wins on arrival without the guard rejecting it.
Parent: PLAN-1343.
* fix(web): deltaSync 401/403 + error state on bootstrap failure (round 4)
- [P1] deltaSync now resets the local index on 401/403, mirroring
the auth-error handling in localIndex.bootstrap. If workspace
access is revoked after the page is mounted, the cached rows
drop instead of staying visible until reload. Other errors stay
transient.
- [P2] localIndex.bootstrapState === 'error' is no longer
conflated with 'ready'. A new `indexError` derived gates a
dedicated error-state branch in the template with a Retry CTA;
the misleading "No items yet" empty state no longer fires on a
transient /items-index failure for a non-empty collection.
Parent: PLAN-1343.
* fix(web): deltaSync on page entry + error surface after revoke (round 5)
- [P1] The bootstrap effect now ALWAYS runs a deltaSync after the
bootstrap promise settles. Once localIndex is 'ready', bootstrap
itself no-ops — but an item the user created/updated elsewhere
(item detail page, dashboard, another tab) while this collection
was unmounted is still catchable via /items-changes. Without
this, returning to the collection page after creating an item
elsewhere could miss the new row until the next SSE event.
- [P2] After a 401/403 from /items-changes, deltaSync now sets a
`deltaSyncFailed` flag in addition to calling localIndex.reset.
The reset rolls bootstrapState back to 'cold' but the bootstrap
effect can't re-fire on the same wsSlug/userId, so without the
flag the page would pin at "Loading…" forever. The error-state
banner now triggers on EITHER indexError OR deltaSyncFailed and
the Retry CTA clears the flag before re-bootstrapping.
Parent: PLAN-1343.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api/client';
|
||||
import { api, PadApiError } from '$lib/api/client';
|
||||
import type { Collection, Item, QuickAction, View, ViewConfig } from '$lib/types';
|
||||
import { parseSettings, parseFields, parseSchema, getStatusOptions, itemUrlId } from '$lib/types';
|
||||
import BoardView from '$lib/components/collections/BoardView.svelte';
|
||||
@@ -20,19 +20,40 @@
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
|
||||
type ViewMode = 'list' | 'board' | 'table';
|
||||
|
||||
let loading = $state(true);
|
||||
// `metaLoading` tracks the collection-metadata / saved-views /
|
||||
// members fetch. The overall `loading` indicator combines that
|
||||
// with the localIndex bootstrap state so the empty-state CTA
|
||||
// doesn't flash for non-empty collections while items are still
|
||||
// hydrating (Codex P2 round 1 of TASK-1357). Scroll restoration
|
||||
// also gates on `loading`, so this prevents the "attempt-and-fail"
|
||||
// case where filteredItems is briefly empty.
|
||||
let metaLoading = $state(true);
|
||||
let collection = $state<Collection | null>(null);
|
||||
let items = $state<Item[]>([]);
|
||||
let viewMode = $state<ViewMode>('list');
|
||||
let activeFilters = $state<Record<string, string>>({});
|
||||
let searchQuery = $state('');
|
||||
let showArchived = $state(false);
|
||||
let itemProgress = $state<Record<string, { total: number; done: number }>>({});
|
||||
let progressLabel = $state('tasks');
|
||||
let relationLabels = $state<Record<string, string>>({});
|
||||
// `relationLabels` maps plan id → plan title for the task-card
|
||||
// "relates to" badge. `$derived` so it picks up plans as they
|
||||
// hydrate through the local index — the previous one-shot fetch
|
||||
// in loadCollection raced the localIndex bootstrap on cold loads
|
||||
// and could leave the badge empty until a manual refresh (Codex
|
||||
// P3 round 1 of TASK-1357).
|
||||
let relationLabels = $derived.by(() => {
|
||||
if (!wsSlug || collSlug !== 'tasks') return {};
|
||||
const labels: Record<string, string> = {};
|
||||
for (const p of localIndex.getByCollection(wsSlug, 'plans')) {
|
||||
labels[p.id] = p.title;
|
||||
}
|
||||
return labels;
|
||||
});
|
||||
|
||||
// Saved views state
|
||||
let savedViews = $state<View[]>([]);
|
||||
@@ -53,6 +74,82 @@
|
||||
let wsSlug = $derived(page.params.workspace ?? '');
|
||||
let username = $derived(page.params.username ?? '');
|
||||
let collSlug = $derived(page.params.collection ?? '');
|
||||
|
||||
// `items` is now a $derived view over the local-first read model
|
||||
// (localIndex, PLAN-1343 / TASK-1355-1356). The collection page no
|
||||
// longer calls /items-index on every navigation; bootstrap is
|
||||
// idempotent and fires once per workspace per session. Filter by
|
||||
// the `showArchived` toggle at read time — the store holds both
|
||||
// live and archived rows. Widen to `Item` by setting content=''
|
||||
// to match the existing prop type contract for view components;
|
||||
// the detail page rehydrates content on open.
|
||||
let items = $derived<Item[]>(
|
||||
wsSlug && collSlug
|
||||
? localIndex
|
||||
.getByCollection(wsSlug, collSlug, { includeArchived: showArchived })
|
||||
.map((row) => ({ ...row, content: '' }) as Item)
|
||||
: [],
|
||||
);
|
||||
|
||||
// `loading` is true until BOTH the collection metadata fetch AND
|
||||
// the localIndex bootstrap have settled. Without this, the empty-
|
||||
// state CTA can flash for non-empty collections while items are
|
||||
// still hydrating, and scroll restore can be consumed against an
|
||||
// empty filteredItems list (Codex P2 round 1).
|
||||
let indexState = $derived(
|
||||
wsSlug ? localIndex.bootstrapStateFor(wsSlug) : 'ready',
|
||||
);
|
||||
let indexReady = $derived(indexState === 'ready');
|
||||
// `indexError` surfaces a cold-load failure (transient /items-index
|
||||
// failure with no cache to fall back on) AND the post-revoke
|
||||
// reset state (`deltaSync` resets on 401/403 — see Codex P2 round
|
||||
// 5 of TASK-1357 — and the bootstrap effect can't re-trigger on
|
||||
// the same wsSlug/userId pair, so we surface the error directly
|
||||
// when indexState rolls back to 'cold' after we'd been 'ready'
|
||||
// or 'loading'). The template renders an error banner with a
|
||||
// retry CTA instead of the misleading "No items yet" empty state
|
||||
// or a stuck-forever "Loading…" spinner.
|
||||
let indexError = $derived(indexState === 'error');
|
||||
// `deltaSyncFailed` lets the auth-error reset on /items-changes
|
||||
// surface as an error banner even when the local-index state
|
||||
// has rolled back to 'cold'. Cleared on every successful
|
||||
// deltaSync.
|
||||
let deltaSyncFailed = $state(false);
|
||||
let loading = $derived(
|
||||
metaLoading || (!indexReady && !indexError && !deltaSyncFailed),
|
||||
);
|
||||
|
||||
// Bootstrap the workspace on entry. Idempotent: if already 'ready'
|
||||
// (and no pendingResync), this is a no-op; if 'cold', it kicks off
|
||||
// the warm-IDB / cold-/items-index flow. Re-runs when the
|
||||
// workspace slug or the signed-in user changes so a user switch
|
||||
// in the same browser tab gets a fresh per-user cache.
|
||||
//
|
||||
// Then run a deltaSync regardless of bootstrap state. Once the
|
||||
// localIndex is `ready`, bootstrap() no-ops — but an item the
|
||||
// user created/updated elsewhere (item detail page, dashboard,
|
||||
// or another tab) while this collection was unmounted is still
|
||||
// catchable via /items-changes. Without this, returning to the
|
||||
// collection page after creating an item elsewhere could miss
|
||||
// the new row until the next SSE event arrives (Codex P1 round
|
||||
// 5 of TASK-1357).
|
||||
$effect(() => {
|
||||
if (!wsSlug) return;
|
||||
const uid = authStore.userId || null;
|
||||
(async () => {
|
||||
try {
|
||||
await localIndex.bootstrap(wsSlug, { userId: uid });
|
||||
} catch {
|
||||
// Bootstrap errors flip bootstrapState to 'error'; the
|
||||
// indexError-gated error banner surfaces them. 401/403
|
||||
// redirect/purge is handled by the API client +
|
||||
// TASK-1360.
|
||||
}
|
||||
// Catch up any deltas missed while the user was on a
|
||||
// different page within the same workspace.
|
||||
await deltaSync(wsSlug);
|
||||
})();
|
||||
});
|
||||
// isOwner now comes from workspaceStore (PLAN-1100 / TASK-1101) — populated
|
||||
// by workspaceStore.setCurrent via the /me endpoint. The workspaceMembers
|
||||
// array remains for the assignee dropdown / member rows.
|
||||
@@ -282,29 +379,15 @@
|
||||
const coll = collSlug;
|
||||
|
||||
unsubscribeSSE = sseService.onItemEvent(async (event) => {
|
||||
// Only react to events for this collection
|
||||
if (event.collection !== coll) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'item_created':
|
||||
case 'item_archived':
|
||||
case 'item_restored': {
|
||||
try {
|
||||
items = await fetchSkinnyItems(ws, coll, false);
|
||||
} catch {
|
||||
// Ignore fetch errors — will retry on next event
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'item_updated': {
|
||||
try {
|
||||
items = await fetchSkinnyItems(ws, coll, false);
|
||||
} catch {
|
||||
// Ignore fetch errors
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// React to any item lifecycle event by pulling deltas
|
||||
// through the local store. The `items` derived view picks
|
||||
// up changes automatically; no manual re-fetch needed.
|
||||
// We don't filter by collection here — an item moved into
|
||||
// or out of this collection still needs its delta applied
|
||||
// so the derived view reflects it. TASK-1358 will replace
|
||||
// this generic reconcile with seq-stamped per-event apply.
|
||||
void event;
|
||||
await deltaSync(ws);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,54 +398,19 @@
|
||||
unsubscribeSync = syncService.onSync(async (result) => {
|
||||
if (!wsSlug || !collSlug) return;
|
||||
|
||||
if (result.type === 'caught_up') return;
|
||||
|
||||
if (result.type === 'incremental') {
|
||||
// Apply incremental changes to this collection's item list
|
||||
const changes = result.changes;
|
||||
let changed = false;
|
||||
|
||||
for (const updated of changes.updated) {
|
||||
const existingIdx = items.findIndex(i => i.id === updated.id);
|
||||
|
||||
if (updated.collection_slug === collSlug) {
|
||||
// Item belongs to this collection — update or add
|
||||
changed = true;
|
||||
if (existingIdx >= 0) {
|
||||
items[existingIdx] = updated;
|
||||
} else {
|
||||
items = [...items, updated];
|
||||
}
|
||||
} else if (existingIdx >= 0) {
|
||||
// Item was moved OUT of this collection — remove it
|
||||
changed = true;
|
||||
items = items.filter(i => i.id !== updated.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const deletedId of changes.deleted) {
|
||||
const idx = items.findIndex(i => i.id === deletedId);
|
||||
if (idx >= 0) {
|
||||
changed = true;
|
||||
items = items.filter(i => i.id !== deletedId);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh progress data if anything changed
|
||||
if (changed) {
|
||||
await refreshProgress(wsSlug, collSlug, items);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Full refresh fallback
|
||||
try {
|
||||
const freshItems = await fetchSkinnyItems(wsSlug, collSlug, showArchived);
|
||||
items = freshItems;
|
||||
await refreshProgress(wsSlug, collSlug, freshItems);
|
||||
syncService.markSynced(); // Advance cursor now that reload succeeded
|
||||
} catch {
|
||||
// Ignore — will catch up on next SSE event
|
||||
// Always run deltaSync, even for `caught_up` — SSE
|
||||
// delivers events, not delta data, and a previous
|
||||
// deltaSync failure (Codex P2 round 2) won't recover
|
||||
// without a fresh fetch attempt. The localIndex cursor is
|
||||
// independent of syncService.lastSyncTime, and per-row
|
||||
// seq guards make repeated calls idempotent.
|
||||
const ok = await deltaSync(wsSlug);
|
||||
await refreshProgress(wsSlug, collSlug, items);
|
||||
if (ok && result.type === 'full_refresh') {
|
||||
// Only advance the legacy syncService cursor on a
|
||||
// clean catch-up. A failed reconcile leaves it where
|
||||
// it is so the next tab-resume retries.
|
||||
syncService.markSynced();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -382,24 +430,58 @@
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch a collection's items via the skinny /items-index endpoint
|
||||
* (TASK-1349 / PLAN-1343 Phase 1). The endpoint omits the rich-text
|
||||
* `content` body, which is the bulk of an item's wire size and is
|
||||
* only needed when the user opens the detail page.
|
||||
* Drive a /items-changes delta apply against the local store. Used
|
||||
* by the SSE event handler and the syncService incremental path —
|
||||
* both signal "something on the server changed; reconcile". The
|
||||
* store's per-row seq guards make the call idempotent even when
|
||||
* SSE + sync both fire for the same change.
|
||||
*
|
||||
* The result is widened to `Item[]` by setting `content: ''` on
|
||||
* every row. This satisfies the existing type contract — view
|
||||
* components and progress code already treat empty content as
|
||||
* "nothing to compute" — without leaking a custom skinny type
|
||||
* through the whole call graph. The detail-page fetch still
|
||||
* returns full items, so opening any item rehydrates `content`.
|
||||
* Loops until the cursor stops advancing (the server caps each
|
||||
* response; deeply-behind tabs need multiple pages). Hard cap at
|
||||
* 50 iterations to defend against pathological loops, matching
|
||||
* localIndex.bootstrap's reconcile loop.
|
||||
*
|
||||
* Returns true on a clean catch-up, false on any failure / cap
|
||||
* trip so the caller can avoid advancing its own cursor against a
|
||||
* stale local cache (Codex P2 round 1 of TASK-1357).
|
||||
*/
|
||||
async function fetchSkinnyItems(ws: string, coll: string, includeArchived: boolean): Promise<Item[]> {
|
||||
const resp = await api.items.listIndex(ws, {
|
||||
collection: coll,
|
||||
includeArchived,
|
||||
});
|
||||
return resp.items.map((row) => ({ ...row, content: '' }));
|
||||
async function deltaSync(ws: string): Promise<boolean> {
|
||||
try {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const since = localIndex.cursorFor(ws);
|
||||
const delta = await api.items.changes(ws, since);
|
||||
if (delta.changes.length === 0 || delta.cursor === since) {
|
||||
deltaSyncFailed = false;
|
||||
return true;
|
||||
}
|
||||
localIndex.applyDelta(ws, delta.changes, delta.cursor);
|
||||
if (delta.cursor === since) {
|
||||
deltaSyncFailed = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Cap hit — pretend success at the page level so we don't
|
||||
// thrash, but tell the caller it wasn't a clean catch-up.
|
||||
return false;
|
||||
} catch (err) {
|
||||
// 401 (session expired) / 403 (access revoked) mean the
|
||||
// cache is no longer ours to display. Drop it through the
|
||||
// same path bootstrap uses so the 403 handler (TASK-1360)
|
||||
// + 401 /login redirect (already in api/client.ts) can
|
||||
// react. Set `deltaSyncFailed` so the page surfaces an
|
||||
// error banner — `localIndex.reset` rolls state back to
|
||||
// 'cold' but the bootstrap effect can't re-trigger on the
|
||||
// same wsSlug/userId, so without this flag the page
|
||||
// pins at "Loading…" forever (Codex P2 round 5).
|
||||
if (
|
||||
err instanceof PadApiError &&
|
||||
(err.code === 'forbidden' || err.code === 'unauthorized')
|
||||
) {
|
||||
localIndex.reset(ws);
|
||||
deltaSyncFailed = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProgress(ws: string, coll: string, itemList: typeof items) {
|
||||
@@ -434,16 +516,20 @@
|
||||
}
|
||||
|
||||
async function loadCollection(ws: string, coll: string, includeArchived = false) {
|
||||
loading = true;
|
||||
metaLoading = true;
|
||||
try {
|
||||
const [collData, itemsData, viewsData, membersData] = await Promise.all([
|
||||
// Items now flow through localIndex (the `items` $derived
|
||||
// above reads `getByCollection`). We still fetch the
|
||||
// collection metadata + saved views + members directly,
|
||||
// AND ensure the workspace is bootstrapped — but the
|
||||
// bootstrap effect upstream is what drives the items
|
||||
// store, so the parallel work is metadata-only here.
|
||||
const [collData, viewsData, membersData] = await Promise.all([
|
||||
api.collections.get(ws, coll),
|
||||
fetchSkinnyItems(ws, coll, includeArchived),
|
||||
api.views.list(ws, coll).catch(() => [] as View[]),
|
||||
api.members.list(ws).catch(() => ({ members: [], invitations: [] }))
|
||||
api.members.list(ws).catch(() => ({ members: [], invitations: [] })),
|
||||
]);
|
||||
collection = collData;
|
||||
items = itemsData;
|
||||
savedViews = viewsData;
|
||||
workspaceMembers = membersData.members ?? [];
|
||||
activeViewId = null;
|
||||
@@ -484,21 +570,10 @@
|
||||
progressLabel = 'done';
|
||||
}
|
||||
|
||||
// Fetch plan names for relation display on task cards
|
||||
if (coll === 'tasks') {
|
||||
try {
|
||||
const plans = await fetchSkinnyItems(ws, 'plans', false);
|
||||
const labels: Record<string, string> = {};
|
||||
for (const p of plans) {
|
||||
labels[p.id] = p.title;
|
||||
}
|
||||
relationLabels = labels;
|
||||
} catch {
|
||||
relationLabels = {};
|
||||
}
|
||||
} else {
|
||||
relationLabels = {};
|
||||
}
|
||||
// `relationLabels` is computed reactively below — no need
|
||||
// to populate it here. (Pre-localIndex this was a one-shot
|
||||
// fetch; now plans flow into the local store and the
|
||||
// derived map below picks them up as they hydrate.)
|
||||
|
||||
// Set view mode: URL param > localStorage > collection default
|
||||
const settings = parseSettings(collData);
|
||||
@@ -510,9 +585,11 @@
|
||||
loadUrlFilters();
|
||||
} catch {
|
||||
collection = null;
|
||||
items = [];
|
||||
// `items` is derived from localIndex; nothing to clear here.
|
||||
// A missing collection shows the empty / not-found state via
|
||||
// the `collection` null branch in the template.
|
||||
} finally {
|
||||
loading = false;
|
||||
metaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,11 +757,9 @@
|
||||
const updated = await api.items.update(wsSlug, item.id, {
|
||||
fields: JSON.stringify(fields)
|
||||
});
|
||||
// Replace item in-place
|
||||
const idx = items.findIndex((i) => i.id === item.id);
|
||||
if (idx !== -1) {
|
||||
items[idx] = updated;
|
||||
}
|
||||
// Push the canonical post-update row into the local index;
|
||||
// the `items` derived view re-renders automatically.
|
||||
localIndex.upsert(wsSlug, updated);
|
||||
toastStore.show(`Moved to ${formatLabel(newValue)}`, 'success');
|
||||
} catch (e) {
|
||||
console.error('Failed to update item:', e);
|
||||
@@ -700,15 +775,32 @@
|
||||
for (const { slug, sort_order } of updates) {
|
||||
const item = items.find((i) => i.slug === slug || i.id === slug);
|
||||
if (item && item.sort_order !== sort_order) {
|
||||
item.sort_order = sort_order;
|
||||
// Optimistic local update: upsert into the local index
|
||||
// with the new sort_order BEFORE awaiting the API. The
|
||||
// caller (e.g. ListView) doesn't await onReorder, so it
|
||||
// resyncs its rendered groups from `items` immediately
|
||||
// after drag-end — without an optimistic write the rows
|
||||
// snap back to the old order until the network PATCH
|
||||
// returns. Clearing `seq` on the optimistic copy
|
||||
// bypasses the per-row seq guard so the real API
|
||||
// response (with a higher seq) wins on arrival
|
||||
// (Codex P2 round 3 of TASK-1357).
|
||||
localIndex.upsert(wsSlug, {
|
||||
...item,
|
||||
sort_order,
|
||||
seq: undefined,
|
||||
});
|
||||
dirty.push({ id: item.id, sort_order });
|
||||
}
|
||||
}
|
||||
if (dirty.length === 0) return;
|
||||
// Persist to API sequentially (SQLite can't handle concurrent writes)
|
||||
// Persist to API sequentially (SQLite can't handle concurrent
|
||||
// writes), and upsert each returned row so the local index
|
||||
// settles back to the canonical server seq.
|
||||
try {
|
||||
for (const { id, sort_order } of dirty) {
|
||||
await api.items.update(wsSlug, id, { sort_order });
|
||||
const updated = await api.items.update(wsSlug, id, { sort_order });
|
||||
localIndex.upsert(wsSlug, updated);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to persist sort order:', e);
|
||||
@@ -779,7 +871,7 @@
|
||||
fields: JSON.stringify(defaultFields),
|
||||
source: 'web'
|
||||
});
|
||||
items = [...items, item];
|
||||
localIndex.upsert(wsSlug, item);
|
||||
quickCreateTitle = '';
|
||||
toastStore.show(`Created "${title}"`, 'success');
|
||||
} catch (err: any) {
|
||||
@@ -892,8 +984,23 @@
|
||||
for (const item of itemsToArchive) {
|
||||
await api.items.delete(wsSlug, item.id);
|
||||
}
|
||||
items = items.filter((i) => !itemsToArchive.some((a) => a.id === i.id));
|
||||
toastStore.show(`Archived ${count} item${count !== 1 ? 's' : ''}`, 'success');
|
||||
// Server-side delete succeeded; pull the soft-delete
|
||||
// tombstones into the local index. Gate the success
|
||||
// toast on a successful deltaSync so the user sees the
|
||||
// rows actually disappear from the view (Codex P3 round 2
|
||||
// of TASK-1357). On deltaSync failure the deletes are
|
||||
// still real server-side — SSE catches the cache up — but
|
||||
// we surface a softer "queued" message so the user knows
|
||||
// the UI hasn't reflected it yet.
|
||||
const ok = await deltaSync(wsSlug);
|
||||
if (ok) {
|
||||
toastStore.show(`Archived ${count} item${count !== 1 ? 's' : ''}`, 'success');
|
||||
} else {
|
||||
toastStore.show(
|
||||
`Archived ${count} item${count !== 1 ? 's' : ''} (updating…)`,
|
||||
'success',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toastStore.show('Failed to archive some items', 'error');
|
||||
}
|
||||
@@ -903,10 +1010,7 @@
|
||||
if (!wsSlug) return;
|
||||
try {
|
||||
const restored = await api.items.restore(wsSlug, item.id);
|
||||
const idx = items.findIndex((i) => i.id === item.id);
|
||||
if (idx !== -1) {
|
||||
items[idx] = restored;
|
||||
}
|
||||
localIndex.upsert(wsSlug, restored);
|
||||
toastStore.show(`Restored "${item.title}"`, 'success');
|
||||
} catch {
|
||||
toastStore.show('Failed to restore item', 'error');
|
||||
@@ -1281,7 +1385,28 @@
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{#if items.length === 0}
|
||||
{#if (indexError || deltaSyncFailed) && items.length === 0}
|
||||
<!-- localIndex bootstrap failed and the cache is empty
|
||||
(e.g. transient /items-index failure on cold load,
|
||||
or auth revoked on /items-changes). Show a retry
|
||||
path instead of the misleading "No items yet" empty
|
||||
state OR a stuck-forever "Loading…" spinner. -->
|
||||
<div class="empty-state-box">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<h2>Couldn't load {collection.name.toLowerCase()}</h2>
|
||||
<p>Something went wrong while loading this workspace.</p>
|
||||
<button
|
||||
class="empty-cta"
|
||||
onclick={() => {
|
||||
deltaSyncFailed = false;
|
||||
localIndex.reset(wsSlug);
|
||||
localIndex.bootstrap(wsSlug, { userId: authStore.userId || null });
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="empty-state-box">
|
||||
<div class="empty-icon">{collection.icon || '📦'}</div>
|
||||
<h2>No {collection.name.toLowerCase()} yet</h2>
|
||||
|
||||
Reference in New Issue
Block a user