1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
|
import type {
TileLayoutData as ContractTileLayoutData,
TileGeometryMode,
} from "@dispatch/tile-layout-contract";
import { createLocalStore } from "../../adapters/local-storage";
import type { TileLayoutHttp } from "./adapter/tile-layout-http";
import {
type Cell,
type GridGeometry,
mergeLayout,
pinAtFirstEmpty,
placeAtCell,
resizeTile,
sanitizeLayout,
sortByRecentActivity,
type TileLayoutData,
type TileLayoutEntry,
} from "./logic/tile-layout";
/**
* Tile-layout store — the synced arrangement of the home grid, ONE shared
* document PER device orientation (`wide` = landscape/desktop, `tall` =
* portrait/mobile), persisted through the INJECTED `TileLayoutHttp` edge
* (the `home-tile-layout` plugin's well-known routes). Mirrors the layout
* feature's store semantics:
*
* - **Orientation selection** — `setMode(mode)` (the home page's effect on
* the resolved grid geometry) is the boot + orientation-change trigger:
* an orientation CHANGE seeds from its cached server layout and probes that
* orientation's document fresh. Same-mode calls are a no-op. The store
* probes its INITIAL mode on creation (the boot probe).
* - **Server-backed** — a successful probe applies the server document;
* edits apply locally IMMEDIATELY, then PUT the whole document (decision 7
* — no patch protocol) lightly debounced; a failed PUT on a present
* backend rolls back to the last server document with a quiet console
* warning (never an error UI).
* - **WS push** — `onServerTileLayout(mode, data)` applies a `tile-layout`
* broadcast, ignoring the OTHER orientation (the server fans out to all
* clients; the client filters by mode).
* - **Quiet degrade** — any probe/write failure (backend unreachable, routes
* not served) keeps the local state with EPHEMERAL edits (in-memory only —
* only server-confirmed layouts are cached); logged once, no error UI.
* - **Sync gating** — `sync(ids)` no-ops while the first probe for an
* orientation is in flight (`providerState === "unknown"`): merging the
* live workspace list into an unprobed empty document would PUT a wiped
* arrangement. The caller's effect re-runs when the state settles.
*
* No ambient state: one instance per home page, created by the page entry
* (or injected in tests).
*/
/** Whether the backend is serving the active orientation's document. */
export type TileLayoutProviderState = "unknown" | "present" | "absent";
export interface TileLayoutStore {
/** The persisted entries of the ACTIVE orientation, order-sorted. */
readonly entries: readonly TileLayoutEntry[];
/** The active orientation (the home page's resolved grid geometry mode). */
readonly mode: TileGeometryMode;
/** The backend's serving state for the ACTIVE orientation. */
readonly providerState: TileLayoutProviderState;
/**
* Select the active orientation and probe its document. Same-mode calls
* are a no-op (the boot probe fires on creation for the initial mode);
* an orientation CHANGE seeds from its cache + probes fresh.
*/
setMode(mode: TileGeometryMode): void;
/** Re-probe the ACTIVE orientation's document (reconnect catch-up). */
probe(): Promise<void>;
/** Server `tile-layout` broadcast. Other orientations are ignored. */
onServerTileLayout(mode: TileGeometryMode, data: ContractTileLayoutData): void;
/**
* Merge the live workspace ids into the layout: workspaces unknown to the
* layout append (unpinned — they flow to the first free cells), entries for
* deleted workspaces drop (their cells become gaps). A no-op (no write)
* when the merge changes nothing — so an effect that tracks both the
* workspace list and this store settles instead of looping.
*/
sync(workspaceIds: readonly string[]): void;
/** Resize a tile (persisted spans; render-time clamping is separate). */
resize(id: string, colSpan: number, rowSpan: number): void;
/**
* Drop a tile at a specific CELL (the drag commit): the contiguous filled
* run pushes forward from that cell (gaps absorb it), every resulting
* position pins. See `placeAtCell`.
*/
placeAtCell(id: string, cell: Cell, geometry: GridGeometry): void;
/**
* Pin (or re-pin) the tile at the FIRST EMPTY slot — the header-create
* flow. Idempotent (see `pinAtFirstEmpty`).
*/
placeFirstEmpty(id: string, geometry: GridGeometry): void;
/** Re-apply the recent-activity order AND clear all pins (the reset). */
sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void;
}
export interface TileLayoutStoreOptions {
/** The injected HTTP edge (the `home-tile-layout` plugin's routes). */
readonly http: TileLayoutHttp;
/** Initial viewport orientation (default wide). */
readonly initialMode?: TileGeometryMode;
/** Injected localStorage for server-layout snapshots; absent → no cache. */
readonly storage?: Storage | undefined;
/** Edit-PUT debounce window in ms (default 300; tests shrink it). */
readonly debounceMs?: number;
/** The quiet-failure hook (default `console.warn` — never an error UI). */
readonly warn?: (message: string) => void;
}
const DEFAULT_DEBOUNCE_MS = 300;
export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutStore {
const http = opts.http;
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
const warn = opts.warn ?? ((message: string) => console.warn(message));
const cache = {
wide: createLocalStore<TileLayoutData>("dispatch.tile-layout.wide", { storage: opts.storage }),
tall: createLocalStore<TileLayoutData>("dispatch.tile-layout.tall", { storage: opts.storage }),
};
const initialMode = opts.initialMode ?? "wide";
let mode = $state<TileGeometryMode>(initialMode);
// The ACTIVE orientation's document — the server's copy once a probe has
// landed (sanitized defensively; the backend validates strictly), the
// cached server snapshot (or empty default) until then. Never PUT the seed.
let data = $state<TileLayoutData>(sanitizeLayout(cache[initialMode].load()));
let providerState = $state<TileLayoutProviderState>("unknown");
/** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */
let lastServerLayout: TileLayoutData | null = null;
/** The pending edit-PUT timer (the "lightly debounced" write). */
let putTimer: ReturnType<typeof setTimeout> | null = null;
/** Logged-once guard for the degrade log (reset when the backend returns). */
let loggedAbsent = false;
function noteAbsent(reason: string): void {
providerState = "absent";
if (!loggedAbsent) {
loggedAbsent = true;
warn(
`tile-layout: backend not serving the layout (${reason}) — rendering the local default; edits are ephemeral`,
);
}
}
function notePresent(): void {
providerState = "present";
loggedAbsent = false;
}
function applyServerLayout(next: TileLayoutData): void {
lastServerLayout = next;
data = next;
cache[mode].save(next);
}
function clearPutTimer(): void {
if (putTimer !== null) {
clearTimeout(putTimer);
putTimer = null;
}
}
async function flushPut(): Promise<void> {
putTimer = null;
// Read the CURRENT document at fire time: a newer edit supersedes the
// debounced snapshot (one in-flight write per window, last wins).
const writtenMode = mode;
const result = await http.putLayout(writtenMode, { version: 2, tiles: data.tiles });
if (result.ok) cache[writtenMode].save(sanitizeLayout(result.layout));
if (writtenMode !== mode) return;
if (result.ok) {
notePresent();
// The server's validated echo is the new rollback target.
lastServerLayout = sanitizeLayout(result.layout);
return;
}
if (providerState !== "present") {
// The backend is gone (or never was): keep the local edit (ephemeral),
// no rollback — there is no server document to roll back TO.
noteAbsent(result.error);
return;
}
// A failed write on a PRESENT backend: roll back to the last server
// document, quiet console warning (never an error UI).
warn(
`tile-layout: persisting the layout failed (${result.error}) — rolled back to the last server document`,
);
if (lastServerLayout !== null) data = lastServerLayout;
}
async function runProbe(): Promise<void> {
// Capture the mode AT START: a setMode may happen while the fetch is in
// flight — only the CURRENT mode's result may land (the reset in
// setMode already staged the fresh default; a stale response must never
// overwrite it).
const probed = mode;
const result = await http.getLayout(mode);
if (probed !== mode) return;
if (result.ok) {
notePresent();
applyServerLayout(sanitizeLayout(result.layout));
} else {
// A real failure (5xx, 404 = routes not served, malformed, network)
// degrades quietly — the local default keeps rendering.
noteAbsent(result.error);
}
}
function commit(tiles: readonly TileLayoutEntry[]): void {
// A pure op that changed nothing (e.g. a no-op place) doesn't write.
if (sameEntries(tiles, data.tiles)) return;
data = { version: 2, tiles };
if (providerState === "unknown") return; // first probe still in flight
if (providerState === "absent") return; // ephemeral — no write, no warning
clearPutTimer();
putTimer = setTimeout(() => {
void flushPut().catch(() => {
// Network-grade failure: roll back to the last server document, quietly.
warn("tile-layout: persisting the layout failed — rolled back to the last server document");
if (lastServerLayout !== null) data = lastServerLayout;
});
}, debounceMs);
}
// Boot probe for the initial mode — the home page's setMode effect either
// confirms it (no-op) or switches it (fresh probe).
void runProbe();
return {
get entries(): readonly TileLayoutEntry[] {
return data.tiles;
},
get mode(): TileGeometryMode {
return mode;
},
get providerState(): TileLayoutProviderState {
return providerState;
},
setMode(next) {
if (next === mode) return;
mode = next;
// The document belongs to the OLD orientation now: seed from this
// mode's cache while the probe resolves (never flash the other
// orientation's arrangement), and drop any queued edit.
clearPutTimer();
lastServerLayout = null;
data = sanitizeLayout(cache[mode].load());
providerState = "unknown";
void runProbe();
},
async probe() {
await runProbe();
},
onServerTileLayout(pushMode, next) {
if (pushMode !== mode) return; // clients filter by orientation
notePresent();
clearPutTimer(); // the server's copy supersedes any queued edit
applyServerLayout(sanitizeLayout(next));
},
sync(workspaceIds): void {
// Never merge into an UNPROBED empty document: before the first probe
// resolves, "empty" is our placeholder, not a real arrangement — and
// merging live ids into it could PUT a wiped layout. The caller's
// effect re-runs when the state settles (it tracks providerState).
if (providerState === "unknown") return;
const merged = mergeLayout(data, workspaceIds);
if (!sameEntries(merged, data.tiles)) commit(merged);
},
resize(id, colSpan, rowSpan): void {
commit(resizeTile(data.tiles, id, colSpan, rowSpan));
},
placeAtCell(id, cell, geometry): void {
commit(placeAtCell(data.tiles, id, cell, geometry));
},
placeFirstEmpty(id, geometry): void {
commit(pinAtFirstEmpty(data.tiles, id, geometry));
},
sortByRecentActivity(lastActivityById): void {
commit(sortByRecentActivity(data.tiles, lastActivityById));
},
};
}
/** Structural equality over entries (ids, spans, orders, anchors). */
function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[]): boolean {
if (a.length !== b.length) return false;
return a.every((entry, index) => {
const other = b[index];
return (
other !== undefined &&
entry.id === other.id &&
entry.colSpan === other.colSpan &&
entry.rowSpan === other.rowSpan &&
entry.order === other.order &&
entry.col === other.col &&
entry.row === other.row
);
});
}
|