diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 19:16:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 19:16:08 +0900 |
| commit | 9c201c60a727106f1e3bf8c2af5f86aa7427098f (patch) | |
| tree | 37c0fa07913154386785126bd75093866f05c975 /src/features/workspaces/adapter | |
| parent | 61733924352285df6c44f66edf69ea6f738fae3e (diff) | |
| download | dispatch-web-9c201c60a727106f1e3bf8c2af5f86aa7427098f.tar.gz dispatch-web-9c201c60a727106f1e3bf8c2af5f86aa7427098f.zip | |
feat(workspaces): sync the home tile layout per device orientation
The Win8-Start home grid's arrangement now syncs from the backend's
home-tile-layout plugin: ONE shared document per device orientation
(wide = landscape/desktop, tall = portrait/mobile) — every landscape
device renders the same layout, every portrait device the same other
one. localStorage persistence is retired (no migration — devices start
from the shared document; the first arrangement creates it).
- adapter/tile-layout-http.ts: the injected HTTP edge (GET/PUT/DELETE
/home/tile-layout/:mode), the features/layout adapter's pattern
- tile-layout-store: server-backed, keyed by geometry mode — boot +
orientation-change probes (stale in-flight responses never land),
locally-applied edits + debounced full-document PUT with rollback to
the last server document, WS push filtered to the active orientation,
quiet ephemeral degrade when the backend is unreachable (no error UI),
sync gated while a probe is in flight
- WorkspacesHome locks the store's orientation to the resolved grid
geometry (the landscape/portrait split IS the device-type split)
- app store: the tile store lives beside the layout store; the WS
dispatch feeds 'tile-layout' broadcasts into it; 'tiles' prop from
App.svelte (the page entry owns the httpBase)
- ws adapter: the 'tile-layout' server message (parse guard) + socket
handler; wire conformance case
Diffstat (limited to 'src/features/workspaces/adapter')
| -rw-r--r-- | src/features/workspaces/adapter/tile-layout-http.ts | 117 |
1 files changed, 117 insertions, 0 deletions
diff --git a/src/features/workspaces/adapter/tile-layout-http.ts b/src/features/workspaces/adapter/tile-layout-http.ts new file mode 100644 index 0000000..58af9d8 --- /dev/null +++ b/src/features/workspaces/adapter/tile-layout-http.ts @@ -0,0 +1,117 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; + +/** + * Home tile-layout HTTP effects — the injected edge that talks to the + * `home-tile-layout` plugin's well-known routes: + * - `GET /home/tile-layout/:mode` → `{ layout }` (a fresh orientation reads + * as the seeded empty document) + * - `PUT /home/tile-layout/:mode` body `{ layout }` → 200 `{ layout }` / + * 400 `{ error }` + * - `DELETE /home/tile-layout/:mode` → the fresh empty document (reset) + * + * Mirrors the layout adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). A 404 is NOT a "no document" signal (the + * backend never 404s a fresh orientation — it seeds): any non-2xx / network + * failure means "the routes aren't served" (an old backend) or "backend + * unreachable" — both degrade the store to its local state with ephemeral + * edits, never an error UI. + */ + +/** Outcome of a tile-layout read/write. */ +export type TileLayoutHttpResult = + | { readonly ok: true; readonly layout: TileLayoutData } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface TileLayoutHttp { + getLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; + putLayout(mode: TileGeometryMode, layout: TileLayoutData): Promise<TileLayoutHttpResult>; + deleteLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a failure — never poisons the store. */ +function malformed(): TileLayoutHttpResult { + return { ok: false, notFound: false, error: "Malformed tile-layout response" }; +} + +function route(httpBase: string, mode: TileGeometryMode): string { + return `${httpBase}/home/tile-layout/${encodeURIComponent(mode)}`; +} + +export function createTileLayoutHttp(httpBase: string, fetchImpl: typeof fetch): TileLayoutHttp { + return { + async getLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode)); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async putLayout(mode, layout): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ layout }), + }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async deleteLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { method: "DELETE" }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + }; +} |
