diff --git a/CLAUDE.md b/CLAUDE.md index 535f36c..02509e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ routes/ versions.js Version CRUD, baseline/reference load, data stream operations.js scale, recode, clone, undo — the core forecast ops log.js GET /api/versions/:id/log, DELETE /api/log/:logid + layouts.js Named pivot layouts — list per version, create, patch, delete lib/ sql_generator.js buildFilterClause, token substitution helpers auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI @@ -45,6 +46,7 @@ ui/src/ Baseline.jsx Version management, baseline workbench, reference load Forecast.jsx Perspective pivot, selection handling, operation dispatch components/ + LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps) Sidebar.jsx 3-step collapsible nav @@ -61,6 +63,7 @@ ui/src/ - **`pf.version`** — named forecast scenarios; `exclude_iters` (default `["reference"]`) blocks those iter values from all operations - **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`). Indexed on `pf_logid`, which is how undo, the change-log aggregate and the grain key all find their rows — without it each is a sequential scan of the whole table. Tables created before that index was added do not have it. - **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb +- **`pf.layout`** — named Perspective view configs; see §Pivot layouts - **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time - **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at` - **`pf.session`** — express-session store (connect-pg-simple layout) @@ -313,6 +316,58 @@ together. `columns` selects which *measures* appear, not individual split combinations. +## Pivot layouts + +A layout is a named `ViewConfig`, stored in **`pf.layout`** and owned by an +account. Two kinds, one table: + +- **published** — everyone on the forecast lists it and can apply it; only its + owner or an admin may change it +- **private** — yours, nobody else lists it + +Scope is the **version** by default, because that is where people enter the app. +`version_id IS NULL` means the layout applies to every version of the source; +that is where the old source default went, and what a brand-new version picks up +before anyone has published anything for it. `is_default` (at most one per scope, +by partial unique index) is what `initViewer()` restores on a first load — a +version-scoped default beating a source-wide one, the narrower answer winning. + +**Permissions are the `pf.log` rule verbatim** — your own, or an admin's +override, and the UI greys out the rest rather than offering a click that answers +403. `can_edit` rides on every row so the menu knows which. Applying is never +restricted: the guarantee is that a published layout cannot be *changed* out from +under people, not that it cannot be adapted — Save is withheld on a layout that +isn't yours, Save as… forks it into your own. + +**No territory clause.** A layout is display config, and territory restricts rows, +not columns; `cleanLayout()` already drops anything the live schema lacks. + +**What is still local.** `LAYOUT_KEY` (`pf_layout_v{vid}`) — the unnamed +last-used config — stays in `localStorage`, because it is per-browser session +continuity rather than a thing anyone names or shares. `LAYOUTS_KEY` +(`pf_layouts_v{vid}`) is the old named list; `loadLayouts()` lifts it into +`pf.layout` as private rows once per version and then clears the key. + +**The dirty dot is a comparison, not a flag.** `restore()` itself fires +`perspective-config-update`, so anything set unconditionally in that handler +would light up the moment a layout was applied. `activeConfigRef` holds what the +pivot last matched and `sameConfig()` compares against it, ignoring `table` +(the per-load table name, different on every refresh). + +**What this replaced.** Named layouts lived only in `localStorage` — invisible to +anyone else, gone on another machine. The one server-side layout was +`pf.source.default_layout`, a single anonymous blob that `PUT +/sources/:id/default-layout` let *any* account overwrite for *every* account: a +published layout with no owner. That route is gone; the column remains, migrated +and read by nothing. + +**Known gap:** `cleanLayout()` still does not guard `aggregates` +(PERSPECTIVE.md §5). Harmless while `save()` emits `aggregates: {}`, but a +published layout that adopts the weighted-mean pattern and then loses a column +would break the restore for everyone rather than for one browser. + +--- + ## Slice mechanics When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side). diff --git a/routes/layouts.js b/routes/layouts.js new file mode 100644 index 0000000..289cc36 --- /dev/null +++ b/routes/layouts.js @@ -0,0 +1,193 @@ +const express = require('express'); +const { sessionUser } = require('../lib/auth'); + +// Named Perspective view configs. Two kinds, one table: +// +// private — yours, nobody else lists it +// published — everyone on the version sees it; only the owner or an admin +// may change it, the same rule pf.log uses for its entries +// +// Scope is the version by default, because that is where people enter the app. +// A layout with version_id NULL applies to every version of the source, which +// is where the old pf.source.default_layout went and what a brand-new version +// picks up before anyone has published anything for it. +module.exports = function(pool) { + const router = express.Router(); + + const SELECT_COLS = `id, source_id, version_id, name, config, owner, + visibility, is_default, created_at, updated_at`; + + // What the client needs to know about a row it cannot write, so the UI can + // grey the control out rather than offer a click that answers 403. + const decorate = (row, req) => ({ + ...row, + scope: row.version_id == null ? 'source' : 'version', + can_edit: !!req.session?.user?.is_admin || row.owner === sessionUser(req) + }); + + // The owner/admin gate, shared by every write. Returns the row, or sends the + // response itself and returns null. + async function ownedLayout(req, res) { + const id = parseInt(req.params.lid); + const { rows } = await pool.query( + `SELECT ${SELECT_COLS} FROM pf.layout WHERE id = $1`, [id] + ); + if (!rows.length) { res.status(404).json({ error: 'Layout not found' }); return null; } + const row = rows[0]; + if (!req.session?.user?.is_admin && row.owner !== sessionUser(req)) { + res.status(403).json({ + error: `“${row.name}” belongs to ${row.owner} — only they or an administrator can change it` + }); + return null; + } + return row; + } + + // Everything applicable to a version: its own published layouts, the + // source-wide published ones, and the caller's own private layouts at either + // scope. Deliberately not territory-filtered — a layout is display config, + // and territory restricts rows, not columns. + router.get('/versions/:id/layouts', async (req, res) => { + const versionId = parseInt(req.params.id); + try { + const ver = await pool.query( + `SELECT source_id FROM pf.version WHERE id = $1`, [versionId] + ); + if (!ver.rows.length) return res.status(404).json({ error: 'Version not found' }); + const sourceId = ver.rows[0].source_id; + + const { rows } = await pool.query( + `SELECT ${SELECT_COLS} + FROM pf.layout + WHERE TRUE + AND source_id = $1 + AND (version_id = $2 OR version_id IS NULL) + AND (visibility = 'published' OR owner = $3) + ORDER BY visibility DESC, is_default DESC, lower(name)`, + [sourceId, versionId, sessionUser(req)] + ); + res.json(rows.map(r => decorate(r, req))); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + // Create. scope 'source' saves it against every version of the source; + // anything else is this version. + router.post('/versions/:id/layouts', async (req, res) => { + const versionId = parseInt(req.params.id); + const { name, config, visibility = 'private', scope = 'version' } = req.body || {}; + if (!name || !String(name).trim()) return res.status(400).json({ error: 'Name is required' }); + if (!config) return res.status(400).json({ error: 'Config is required' }); + if (!['private', 'published'].includes(visibility)) { + return res.status(400).json({ error: 'visibility must be private or published' }); + } + try { + const ver = await pool.query( + `SELECT source_id FROM pf.version WHERE id = $1`, [versionId] + ); + if (!ver.rows.length) return res.status(404).json({ error: 'Version not found' }); + + const { rows } = await pool.query( + `INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING ${SELECT_COLS}`, + [ver.rows[0].source_id, scope === 'source' ? null : versionId, + String(name).trim(), config, sessionUser(req), visibility] + ); + res.json(decorate(rows[0], req)); + } catch (err) { + // the partial unique indexes, reported in the terms the user typed + if (err.code === '23505') { + return res.status(409).json({ error: `A layout named “${name}” already exists here` }); + } + res.status(500).json({ error: err.message }); + } + }); + + // Update any of name / config / visibility / is_default. Each is optional; + // a missing key leaves the column alone, which is why the flag-and-value + // pair is used rather than COALESCE on the value. + router.patch('/layouts/:lid', async (req, res) => { + const { name, config, visibility, is_default, scope } = req.body || {}; + if (name === undefined && config === undefined && visibility === undefined + && is_default === undefined && scope === undefined) { + return res.status(400).json({ error: 'Nothing to update' }); + } + if (visibility !== undefined && !['private', 'published'].includes(visibility)) { + return res.status(400).json({ error: 'visibility must be private or published' }); + } + const client = await pool.connect(); + try { + const row = await ownedLayout(req, res); + if (!row) return; + + // Unpublishing a default would leave a default nobody can see, which + // the table's CHECK refuses; clear the flag with it rather than + // failing on a constraint the user never mentioned. + const clearsDefault = visibility === 'private'; + + // One default per scope, enforced by a partial unique index. Stand + // the others down first rather than letting the update collide -- + // and in one transaction, or a name clash on the second statement + // leaves the version with no default at all. + const target_version = scope === undefined ? row.version_id + : (scope === 'source' ? null : row.version_id); + await client.query('BEGIN'); + if (is_default === true) { + await client.query( + `UPDATE pf.layout SET is_default = false + WHERE TRUE + AND source_id = $1 + AND COALESCE(version_id, 0) = COALESCE($2::int, 0) + AND id <> $3 + AND is_default`, + [row.source_id, target_version, row.id] + ); + } + + const { rows } = await client.query( + `UPDATE pf.layout SET + name = CASE WHEN $2::bool THEN $3::text ELSE name END, + config = CASE WHEN $4::bool THEN $5::jsonb ELSE config END, + visibility = CASE WHEN $6::bool THEN $7::text ELSE visibility END, + is_default = CASE WHEN $10::bool THEN false + WHEN $8::bool THEN $9::bool ELSE is_default END, + version_id = CASE WHEN $11::bool THEN $12::int ELSE version_id END, + updated_at = now() + WHERE id = $1 + RETURNING ${SELECT_COLS}`, + [row.id, + name !== undefined, name !== undefined ? String(name).trim() : null, + config !== undefined, config !== undefined ? config : null, + visibility !== undefined, visibility !== undefined ? visibility : null, + is_default !== undefined, is_default === true, + clearsDefault, + scope !== undefined, target_version] + ); + await client.query('COMMIT'); + res.json(decorate(rows[0], req)); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + if (err.code === '23505') { + return res.status(409).json({ error: 'A layout with that name already exists here' }); + } + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } + }); + + router.delete('/layouts/:lid', async (req, res) => { + try { + const row = await ownedLayout(req, res); + if (!row) return; + await pool.query(`DELETE FROM pf.layout WHERE id = $1`, [row.id]); + res.json({ deleted: row.id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + return router; +}; diff --git a/routes/sources.js b/routes/sources.js index b01865b..1a514b3 100644 --- a/routes/sources.js +++ b/routes/sources.js @@ -449,23 +449,12 @@ module.exports = function(pool) { } }); - // set or clear the default Perspective layout for a source. - // Body: a Perspective view config (group_by, split_by, columns, plugin_config, …). - // Pass null or {} to clear. - router.put('/sources/:id/default-layout', async (req, res) => { - try { - const layout = req.body && Object.keys(req.body).length > 0 ? req.body : null; - const result = await pool.query( - `UPDATE pf.source SET default_layout = $1 WHERE id = $2 RETURNING *`, - [layout, req.params.id] - ); - if (result.rows.length === 0) return res.status(404).json({ error: 'Source not found' }); - res.json(result.rows[0]); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); - } - }); + // PUT /sources/:id/default-layout is gone. It wrote pf.source.default_layout, + // one anonymous blob per source that any account could overwrite for every + // other account -- a published layout with no owner. Its successor is + // pf.layout: named, owned, and writable only by its owner or an admin. The + // old column is left in place, already migrated into pf.layout by + // setup_sql/01_schema.sql, and read by nothing. // deregister a source — does not drop existing forecast tables router.get('/dim-period/cols', async (req, res) => { diff --git a/server.js b/server.js index dede991..2c2f543 100644 --- a/server.js +++ b/server.js @@ -80,6 +80,7 @@ app.use('/api', require('./routes/sources')(pool)); app.use('/api', require('./routes/versions')(pool)); app.use('/api', require('./routes/operations')(pool)); app.use('/api', require('./routes/log')(pool)); +app.use('/api', require('./routes/layouts')(pool)); const port = process.env.PORT || 3010; diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index c0a8169..96db788 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -203,3 +203,61 @@ CREATE TABLE IF NOT EXISTS pf.sql ( generated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (source_id, operation) ); + +-- pf.layout: named Perspective view configs. +-- +-- Replaces the per-browser localStorage lists (pf_layouts_v*) and the single +-- anonymous pf.source.default_layout. A layout is either `private` -- visible +-- only to its owner -- or `published`, visible to everyone on the version and +-- writable only by its owner or an admin, the same rule pf.log already uses. +CREATE TABLE IF NOT EXISTS pf.layout ( + id serial PRIMARY KEY, + source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, + -- null = applies to every version of the source; that is where the old + -- source default lives, and what a new version picks up before anyone has + -- published a layout of its own. + version_id integer REFERENCES pf.version(id) ON DELETE CASCADE, + name text NOT NULL, + config jsonb NOT NULL, + owner text NOT NULL, + visibility text NOT NULL DEFAULT 'private', -- private | published + is_default boolean NOT NULL DEFAULT false, -- applied on first load + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (visibility IN ('private', 'published')), + -- only a published layout can be the default: a private one would be a + -- default nobody else could see. + CHECK (NOT is_default OR visibility = 'published') +); + +-- COALESCE rather than the bare column: version_id is nullable, and in a unique +-- index NULLs are distinct, so source-wide rows would not be constrained at all. +CREATE UNIQUE INDEX IF NOT EXISTS layout_private_name + ON pf.layout (source_id, COALESCE(version_id, 0), owner, lower(name)) + WHERE visibility = 'private'; + +CREATE UNIQUE INDEX IF NOT EXISTS layout_published_name + ON pf.layout (source_id, COALESCE(version_id, 0), lower(name)) + WHERE visibility = 'published'; + +-- one default per scope +CREATE UNIQUE INDEX IF NOT EXISTS layout_one_default + ON pf.layout (source_id, COALESCE(version_id, 0)) + WHERE is_default; + +CREATE INDEX IF NOT EXISTS layout_lookup ON pf.layout (source_id, version_id); + +-- Carry the old single source default across as a published, source-wide row. +-- Idempotent: skipped once a default exists for that source. +INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility, is_default) +SELECT s.id, NULL, 'Source default', s.default_layout, COALESCE(s.created_by, 'admin'), 'published', true +FROM pf.source s +WHERE TRUE + AND s.default_layout IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM pf.layout l + WHERE TRUE + AND l.source_id = s.id + AND l.version_id IS NULL + AND l.is_default + ); diff --git a/ui/src/components/LayoutMenu.jsx b/ui/src/components/LayoutMenu.jsx new file mode 100644 index 0000000..c43dd20 --- /dev/null +++ b/ui/src/components/LayoutMenu.jsx @@ -0,0 +1,190 @@ +import { useEffect, useRef, useState } from 'react' + +// The pivot layout, as one control rather than a row of equal chips. +// +// Two lists, because a layout is one of two different things: Published, which +// everyone on this forecast sees and only its owner or an admin may change, and +// Mine, which nobody else lists. Everything you cannot write is still there to +// apply -- it is only the write controls that are withheld, so a click never +// answers 403. +export default function LayoutMenu({ + layouts = [], activeLayoutId, dirty, + onApply, onSave, onSaveAs, onSetVisibility, onSetDefault, onRename, onDelete, onReset +}) { + const [open, setOpen] = useState(false) + const [saveName, setSaveName] = useState('') + const [savePublic, setSavePublic] = useState(false) + const [renaming, setRenaming] = useState(null) + const [renameTo, setRenameTo] = useState('') + const boxRef = useRef(null) + + // Close on an outside click or Escape. Capture phase: the pivot lives in a + // shadow root and retargets its events, so a bubbled listener on document + // sees the host rather than the real target and cannot tell inside from out. + useEffect(() => { + if (!open) return + const onDown = (e) => { if (!boxRef.current?.contains(e.target)) setOpen(false) } + const onKey = (e) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', onDown, true) + window.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown, true) + window.removeEventListener('keydown', onKey) + } + }, [open]) + + const active = layouts.find(l => l.id === activeLayoutId) + const published = layouts.filter(l => l.visibility === 'published') + const mine = layouts.filter(l => l.visibility === 'private') + + const submitSaveAs = () => { + const name = saveName.trim() + if (!name) return + onSaveAs(name, savePublic ? 'published' : 'private') + setSaveName('') + setOpen(false) + } + + const submitRename = (l) => { + const name = renameTo.trim() + if (name && name !== l.name) onRename(l, name) + setRenaming(null) + } + + const iconBtn = 'px-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 leading-none' + + const row = (l) => ( +
{ if (renaming !== l.id) { onApply(l); setOpen(false) } }}> + + {renaming === l.id ? ( + e.stopPropagation()} + onChange={e => setRenameTo(e.target.value)} + onBlur={() => submitRename(l)} + onKeyDown={e => { + if (e.key === 'Enter') submitRename(l) + if (e.key === 'Escape') setRenaming(null) + }} + className="flex-1 min-w-0 border border-blue-300 rounded px-1 py-0 outline-none bg-white" /> + ) : ( + <> + {l.name} + {l.is_default && ( + + )} + {l.scope === 'source' && ( + ALL + )} + + )} + + {/* Withheld rather than offered-and-refused: the server allows a write only + to the owner or an admin, and can_edit says which this is. */} + {l.can_edit && renaming !== l.id && ( + e.stopPropagation()}> + + {l.visibility === 'private' ? ( + + ) : ( + <> + {!l.is_default && ( + + )} + + + )} + + + )} + + {!l.can_edit && ( + {l.owner} + )} +
+ ) + + return ( +
+ Layout + + + + {/* Save is the common action and stays outside the menu, but only when + there is something to save it to and it is yours to overwrite. */} + {active?.can_edit && dirty && ( + + )} + + {open && ( +
+ + {published.length > 0 && ( + <> +
Published
+
{published.map(row)}
+ + )} + + {mine.length > 0 && ( + <> +
Mine
+
{mine.map(row)}
+ + )} + + {!layouts.length && ( +
No saved layouts yet
+ )} + +
+
+ setSaveName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') submitSaveAs() }} + placeholder="Save current view as…" + className="flex-1 min-w-0 border border-gray-300 rounded px-1.5 py-0.5 + outline-none focus:border-blue-400" /> + +
+ +
+ +
+ +
+
+ )} +
+ ) +} diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index c7434bb..f75b805 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react' import useTheme from '../theme.jsx' +import LayoutMenu from '../components/LayoutMenu.jsx' import useAuth from '../auth.jsx' import OperationPanel from '../components/OperationPanel.jsx' import BridgeView from '../components/BridgeView.jsx' @@ -18,8 +19,11 @@ import '@perspective-dev/viewer/themes' // to log ids server-side. Mirrors COMPUTED_SLICE_COLS in lib/sql_generator.js. const COMPUTED_SLICE_COLS = new Set(['pf_segment', 'pf_bucket']) +// The unnamed last-used config stays in localStorage: it is per-browser session +// continuity, not a thing anyone names or shares. LAYOUTS_KEY is the old named +// list, read once per version and lifted into pf.layout (see loadLayouts). const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore) -const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list +const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // legacy named list, migrated away function cleanLayout(cfg, validCols) { if (!cfg) return cfg @@ -88,8 +92,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // layouts const [layouts, setLayouts] = useState([]) const [activeLayoutId, setActiveLayoutId] = useState(null) - const [showSaveAs, setShowSaveAs] = useState(false) - const [saveAsName, setSaveAsName] = useState('') + // Whether the pivot has drifted from the layout it was applied from. Compared + // rather than flagged: every restore fires perspective-config-update, so a + // flag set by the event alone would read dirty the moment a layout was applied. + const [layoutDirty, setLayoutDirty] = useState(false) + const activeConfigRef = useRef(null) // operation panel — a selection is a LIST of slices; one entry is the common case // The column hierarchy and how many of its levels are showing. Mirrored into @@ -406,8 +413,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio useEffect(() => { if (!versionId || !sourceId) return - loadLayouts(versionId) - initViewer(versionId, sourceId) + // The layouts decide which config the first load restores, so they are + // fetched before the viewer rather than beside it. + ;(async () => { + const list = await loadLayouts(versionId) + initViewer(versionId, sourceId, list) + })() }, [versionId, sourceId]) useEffect(() => { @@ -713,13 +724,58 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio return { buffer: merged.buffer, rowCount } } - function loadLayouts(vid) { - const stored = localStorage.getItem(LAYOUTS_KEY(vid)) - setLayouts(stored ? JSON.parse(stored) : []) - setActiveLayoutId(null) + // Named layouts live in pf.layout, not localStorage: a published one has to + // reach everyone on the forecast, and a private one has to survive a new + // browser. + async function loadLayouts(vid) { + try { + // One-time lift of whatever this browser accumulated under the old scheme. + // A name that already exists comes back 409 and is simply already + // migrated, so the key is cleared either way -- the alternative is + // re-offering the same failed migration on every load forever. + const legacy = localStorage.getItem(LAYOUTS_KEY(vid)) + if (legacy) { + for (const l of (JSON.parse(legacy) || [])) { + if (!l?.name || !l?.config) continue + try { + await fetch(`/api/versions/${vid}/layouts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: l.name, config: l.config, visibility: 'private' }) + }) + } catch { /* keep going: one bad row must not strand the rest */ } + } + localStorage.removeItem(LAYOUTS_KEY(vid)) + } + const list = await fetch(`/api/versions/${vid}/layouts`).then(r => r.ok ? r.json() : []) + setLayouts(list) + setActiveLayoutId(null) + setLayoutDirty(false) + return list + } catch (err) { + console.error('[loadLayouts]', err) + setLayouts([]) + return [] + } } - async function initViewer(vid, sid) { + // Config equality for the dirty dot. `table` is the per-load table name and + // differs on every refresh, so it is never a real difference. + function sameConfig(a, b) { + if (!a || !b) return false + const strip = ({ table, ...rest }) => JSON.stringify(rest) + return strip(a) === strip(b) + } + + // Remember what the pivot currently matches, so the dot can be computed + // against it rather than guessed from events. + function markClean(cfg, id) { + activeConfigRef.current = cfg || null + if (id !== undefined) setActiveLayoutId(id) + setLayoutDirty(false) + } + + async function initViewer(vid, sid, layoutList = []) { const viewer = viewerRef.current if (!viewer) return const myId = ++initIdRef.current @@ -859,7 +915,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1) else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) } else { - const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout + // The default is a published layout flagged is_default. A version-scoped + // one wins over a source-wide one: the narrower answer is the one + // somebody chose for this forecast specifically. + const defaults = layoutList.filter(l => l.is_default) + const defaultRow = defaults.find(l => l.version_id != null) || defaults[0] + const sourceDefault = defaultRow?.config let cfg if (sourceDefault && Object.keys(sourceDefault).length > 0) { const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols) @@ -875,6 +936,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } await viewer.restore(cfg) adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length) + // Name what we landed on, so the menu says which layout this is and the + // dirty dot has something to compare against. Read back rather than + // reusing cfg: restore() normalises, so the live config is what a later + // save() will be compared with. + if (defaultRow) markClean(await viewer.save(), defaultRow.id) } // Its own try: the restore above has one, but this sits outside it, and a @@ -898,6 +964,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio hideSplitTotal() const cfg = await captureConfig() if (cfg) await persistLayout(vid, cfg) + // Dirty is a comparison, not a flag: restore() itself fires this + // event, so anything set unconditionally here would light up the + // moment a layout was applied. + if (cfg) setLayoutDirty(!!activeConfigRef.current && !sameConfig(cfg, activeConfigRef.current)) } catch {} } viewer.addEventListener('perspective-config-update', viewer._pspUpdate) @@ -1363,48 +1433,66 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio localStorage.setItem(LAYOUT_KEY(vid), JSON.stringify(cfg)) } - async function handleSaveAs() { - const name = saveAsName.trim() - if (!name) return - const cfg = await captureConfig() - if (!cfg) return - const id = Date.now() - const updated = [...layouts, { id, name, config: cfg }] - localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated)) - await persistLayout(versionId, cfg) - setLayouts(updated) - setActiveLayoutId(id) - setShowSaveAs(false) - setSaveAsName('') - flash('Saved') + // One place every layout write goes through, so the 403 the server returns for + // somebody else's layout is reported the same way everywhere. + async function layoutFetch(url, opts) { + const res = await fetch(url, opts) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || 'Failed') + return data } - async function saveAsSourceDefault() { + async function handleSaveAs(name, visibility) { const cfg = await captureConfig() if (!cfg) return - const { table, ...rest } = cfg + const { table: _t, ...rest } = cfg try { - const res = await fetch(`/api/sources/${sourceId}/default-layout`, { - method: 'PUT', + const saved = await layoutFetch(`/api/versions/${versionId}/layouts`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(rest) + body: JSON.stringify({ name, config: rest, visibility }) }) - if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return } - if (refreshSources) await refreshSources() - flash('Saved as source default') + setLayouts(ls => [...ls, saved]) + markClean(cfg, saved.id) + await persistLayout(versionId, cfg) + flash(visibility === 'published' ? `Published “${saved.name}”` : 'Saved') } catch (err) { flash(err.message, 'error') } } - async function handleSaveOver() { - const layout = layouts.find(l => l.id === activeLayoutId) - if (!layout) return - const cfg = await captureConfig() + async function handleSaveOver(layout) { + const target = layout || layouts.find(l => l.id === activeLayoutId) + if (!target) return + const cfg = await captureConfig() if (!cfg) return - const updated = layouts.map(l => l.id === activeLayoutId ? { ...l, config: cfg } : l) - localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated)) - await persistLayout(versionId, cfg) - setLayouts(updated) - flash('Saved') + const { table: _t, ...rest } = cfg + try { + const saved = await layoutFetch(`/api/layouts/${target.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: rest }) + }) + setLayouts(ls => ls.map(l => l.id === saved.id ? saved : l)) + markClean(cfg, saved.id) + await persistLayout(versionId, cfg) + flash('Saved') + } catch (err) { flash(err.message, 'error') } + } + + // Publish / unpublish / rename / make default are all the same PATCH; the + // server decides whether this account may make it. + async function patchLayout(layout, body, ok) { + try { + const saved = await layoutFetch(`/api/layouts/${layout.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + // is_default is exclusive per scope, so the row the server stood down is + // stale here too -- reload rather than patching one row into the list. + if (body.is_default) await loadLayouts(versionId).then(() => setActiveLayoutId(saved.id)) + else setLayouts(ls => ls.map(l => l.id === saved.id ? saved : l)) + flash(ok) + } catch (err) { flash(err.message, 'error') } } async function applyLayout(layout) { @@ -1421,24 +1509,28 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio await ensureRowLabelWidth() - setActiveLayoutId(layout.id) // The persisted copy is taken after the restore, so it carries whatever - // cleanLayout dropped on the way in rather than the stale original. + // cleanLayout dropped on the way in rather than the stale original -- and + // that same copy is what the dirty dot compares against, so applying a + // layout whose config named a dropped column does not read as changed. const merged = await captureConfig() + markClean(merged || cfg, layout.id) await persistLayout(versionId, merged || cfg) } - function deleteLayout(id, e) { - e.stopPropagation() - const updated = layouts.filter(l => l.id !== id) - localStorage.setItem(LAYOUTS_KEY(versionId), JSON.stringify(updated)) - setLayouts(updated) - if (activeLayoutId === id) setActiveLayoutId(null) + async function deleteLayout(layout) { + if (layout.visibility === 'published' + && !window.confirm(`Delete “${layout.name}”? Everyone on this forecast loses it.`)) return + try { + await layoutFetch(`/api/layouts/${layout.id}`, { method: 'DELETE' }) + setLayouts(ls => ls.filter(l => l.id !== layout.id)) + if (activeLayoutId === layout.id) markClean(null, null) + } catch (err) { flash(err.message, 'error') } } function resetLayout() { localStorage.removeItem(LAYOUT_KEY(versionId)) - setActiveLayoutId(null) + markClean(null, null) const viewer = viewerRef.current if (viewer) viewer.restore({ settings: true }) } @@ -1796,44 +1888,20 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio {/* Toolbar */}
- {/* Layout group */} -
- Layout - {layouts.map(l => ( -
applyLayout(l)} - className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors - ${activeLayoutId === l.id ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}> - {l.name} - -
- ))} - {showSaveAs ? ( -
- setSaveAsName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }} - placeholder="Layout name…" className="border border-gray-300 rounded px-2 py-0.5 w-32 focus:outline-none focus:border-blue-400" /> - - -
- ) : ( - <> - {activeLayoutId !== null && ( - - )} - - - {activeLayoutId !== null && ( - - )} - - )} -
+ patchLayout(l, { visibility: v }, + v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)} + onSetDefault={l => patchLayout(l, { is_default: true }, + `“${l.name}” opens this forecast`)} + onRename={(l, name) => patchLayout(l, { name }, 'Renamed')} + onDelete={deleteLayout} + onReset={resetLayout} />