Nav. The source and version dropdowns sat in a status bar that is otherwise read-only, so they did not read as the controls they were. The forecasts are now listed in the sidebar above the three steps, each row carrying its status and a star for the default. Not nested under the Forecast button, which is where this started: Baseline works on the same version, so hanging the list off one step would either misrepresent the scope or duplicate it under two. The source is gone from the nav entirely -- gs.osm_skinny means nothing to someone editing a forecast. The sidebar lists every source's versions and sets the source from the one picked, since a version carries its source_id; `versions` stays scoped to the selected source, because Baseline and Forecast are, so the sidebar gets its own list rather than widening what versions means everywhere. The picker only appears on Setup now, which is the one place the raw table name is the right label. The status bar keeps what follows FROM the selection -- the forecast's name and its row count. The table it writes to moved into the hover panel, still click-to-copy, since pasting it into psql is why it was ever on screen. Permissions. requireAdmin existed in lib/auth.js and was applied to nothing, so all 26 non-admin accounts could call every Setup and Baseline endpoint directly. It is now on source register/deregister, col_meta writes, Generate SQL, dim refresh, version create/delete/ close/reopen, PUT /versions/:id, all of plans.js, and the baseline and reference loads. Deliberately not on any read, nor on the forecast operations, which carry their own owner and territory rules. Both pages stay visible and go read-only, via one <fieldset disabled> each rather than a flag threaded through forty-five controls, with a banner saying why. The Tags panel on Baseline is excluded: its permission is ownership, not admin, and a rep may rename the initiatives they named themselves. The table browser reads are open to everyone, because gating what a page needs in order to render turned Setup into a 403 -- which it then rendered as a crash, since the error body was passed to .map during render and unmounting React blanks the app. Setup's list fetches now coerce to an array, so an unexpected response costs an empty panel and not the session. That fragility predated the gating. Toolbar. The zone labels were the same visual weight as the buttons they name, and the rules between groups were the same weight as the zone borders, so nothing separated at the level it belonged to. Labels are heavier and darker, group rules are stronger and taller, and the forecast zone has a container of its own instead of floating. Fit widths is removed, button and function. It measured the leaf header over viewport rows only, so anything below the fold never counted; it passed an option draw() does not have and raced draw()'s throttle, leaving columns cleared but unmeasured at their floor widths; and the persist that followed saved nothing, because saveColumnSizes returns only overrides, which resetAutoSize had just emptied. Recoverable from git if it is ever worth fixing rather than dropping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
8.4 KiB
JavaScript
196 lines
8.4 KiB
JavaScript
import { useState, useEffect, useCallback } from 'react'
|
|
import useTheme from '../theme.jsx'
|
|
import useAuth from '../auth.jsx'
|
|
|
|
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId }) {
|
|
const { dark, setDark } = useTheme()
|
|
const { user, logout } = useAuth()
|
|
const showVersion = view === 'baseline' || view === 'forecast'
|
|
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
|
|
|
|
const [info, setInfo] = useState(null)
|
|
const [showInfo, setShow] = useState(false)
|
|
const [copied, setCopied] = useState(false)
|
|
// Forecast.jsx mirrors the pivot's own updating state onto this event. The
|
|
// viewer shows the same thing as a beaker in its settings panel, which is
|
|
// exactly where you cannot see it once the panel is closed.
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const refreshInfo = useCallback(async () => {
|
|
if (!versionId || !showVersion) { setInfo(null); return }
|
|
try {
|
|
const r = await fetch(`/api/versions/${versionId}/table-info`)
|
|
setInfo(r.ok ? await r.json() : null)
|
|
} catch { setInfo(null) }
|
|
}, [versionId, showVersion])
|
|
|
|
useEffect(() => { refreshInfo() }, [refreshInfo])
|
|
|
|
// operations broadcast this after a write so the row count stays honest
|
|
useEffect(() => {
|
|
const onChange = () => refreshInfo()
|
|
window.addEventListener('pf-data-changed', onChange)
|
|
return () => window.removeEventListener('pf-data-changed', onChange)
|
|
}, [refreshInfo])
|
|
|
|
useEffect(() => {
|
|
const onBusy = (e) => setBusy(!!e.detail?.busy)
|
|
window.addEventListener('pf-pivot-busy', onBusy)
|
|
return () => window.removeEventListener('pf-pivot-busy', onBusy)
|
|
}, [])
|
|
|
|
async function copyTable() {
|
|
if (!info?.fc_table) return
|
|
try {
|
|
await navigator.clipboard.writeText(info.fc_table)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1200)
|
|
} catch {}
|
|
}
|
|
|
|
const fmt = (n) => n == null ? '—' : n.toLocaleString()
|
|
|
|
return (
|
|
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs relative">
|
|
{/* Source is a Setup concern: the raw table name means nothing to someone
|
|
editing a forecast, and a version carries its own source, so picking a
|
|
forecast in the sidebar settles it everywhere else. */}
|
|
{view === 'setup' && (
|
|
<>
|
|
<span className="text-gray-400">Source</span>
|
|
<select
|
|
value={sourceId || ''}
|
|
onChange={e => setSourceId(e.target.value)}
|
|
disabled={sources.length === 0}
|
|
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
|
|
>
|
|
{sources.length === 0
|
|
? <option value="">— no sources —</option>
|
|
: sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
|
|
</select>
|
|
</>
|
|
)}
|
|
|
|
{showVersion && (
|
|
<>
|
|
{/* What is open, and how big it is -- the two things a forecaster
|
|
wants from a status bar. The physical table it writes to is an
|
|
implementation detail and means nothing to a sales account, so it
|
|
has moved into the hover panel and onto the Baseline page, which
|
|
is where a version's plumbing is managed. */}
|
|
{info && (
|
|
<>
|
|
<span className="text-gray-200">|</span>
|
|
<span
|
|
onMouseEnter={() => setShow(true)}
|
|
onMouseLeave={() => setShow(false)}
|
|
className="font-medium text-gray-700 cursor-default"
|
|
title="Hover for the write target and row breakdown"
|
|
>
|
|
{selectedVersion?.name || '—'}
|
|
</span>
|
|
<span className={`font-mono ${info.exists ? 'text-gray-400' : 'text-amber-700'}`}>
|
|
{info.exists ? `${fmt(info.rows)} rows` : 'not created'}
|
|
</span>
|
|
|
|
{showInfo && (
|
|
<div className="absolute top-9 left-0 z-30 bg-white border border-gray-200 rounded shadow-lg p-3 text-xs min-w-[260px]">
|
|
<div className="text-gray-400 uppercase tracking-wide mb-2" style={{ fontSize: '10px' }}>Write target</div>
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
|
<span className="text-gray-400">table</span>
|
|
{/* Still copyable, just no longer the headline: pasting it
|
|
into psql is the reason it was ever on screen. */}
|
|
<button
|
|
onClick={copyTable}
|
|
title="Click to copy table name"
|
|
className="font-mono text-gray-700 text-left hover:text-blue-700"
|
|
>
|
|
{copied ? 'copied!' : info.fc_table}
|
|
</button>
|
|
<span className="text-gray-400">reads from</span>
|
|
<span className="font-mono text-gray-700">{info.source}</span>
|
|
<span className="text-gray-400">total rows</span>
|
|
<span className="font-mono text-gray-700">{fmt(info.rows)}</span>
|
|
</div>
|
|
{info.by_iter?.length > 0 && (
|
|
<>
|
|
<div className="text-gray-400 uppercase tracking-wide mt-3 mb-1" style={{ fontSize: '10px' }}>Rows by iter</div>
|
|
<table className="w-full">
|
|
<tbody>
|
|
{info.by_iter.map(r => (
|
|
<tr key={r.pf_iter}>
|
|
<td className="text-gray-500 capitalize pr-3">{r.pf_iter}</td>
|
|
<td className="text-right font-mono text-gray-700">{fmt(r.n)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Left of the ml-auto block, so it sits beside the pivot's own figures
|
|
rather than out among the account controls. Occupies no space at all
|
|
when idle: a permanently reserved slot reads as a thing that is broken
|
|
when empty. */}
|
|
{busy && view === 'forecast' && (
|
|
<span
|
|
className="flex items-center gap-1.5 text-blue-600"
|
|
title="The pivot is rebuilding its view — sorting, filtering, or changing depth on a large grain takes a moment"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
strokeWidth="3" strokeLinecap="round" className="animate-spin">
|
|
<path d="M12 3a9 9 0 1 0 9 9" />
|
|
</svg>
|
|
Calculating…
|
|
</span>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{user && (
|
|
<>
|
|
<span className="text-gray-500" title={`Signed in as ${user.username}`}>
|
|
{user.display_name || user.username}
|
|
</span>
|
|
<button
|
|
onClick={logout}
|
|
className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-0.5 rounded"
|
|
title="Sign out"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={() => setDark(d => !d)}
|
|
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100"
|
|
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
>
|
|
{dark ? (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="4"/>
|
|
<line x1="12" y1="2" x2="12" y2="5"/>
|
|
<line x1="12" y1="19" x2="12" y2="22"/>
|
|
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05"/>
|
|
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07"/>
|
|
<line x1="2" y1="12" x2="5" y2="12"/>
|
|
<line x1="19" y1="12" x2="22" y2="12"/>
|
|
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95"/>
|
|
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|