Compare commits
2 Commits
73e8f5d202
...
682968b820
| Author | SHA1 | Date | |
|---|---|---|---|
| 682968b820 | |||
| cf391286a2 |
11
CLAUDE.md
11
CLAUDE.md
@ -97,6 +97,17 @@ All three operations follow the same structure: insert a `pf.log` row in a CTE,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Light / dark mode
|
||||||
|
|
||||||
|
Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) with a `ThemeProvider` that wraps the app in `main.jsx`.
|
||||||
|
|
||||||
|
- **Storage key:** `pf_dark` in `localStorage`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')` on first visit
|
||||||
|
- **Toggle:** `setDark(d => !d)` in `StatusBar.jsx`; effect writes `localStorage` and toggles the `.dark` class on `<html>`
|
||||||
|
- **CSS:** `ui/src/index.css` defines CSS custom properties under `:root` (light) and `.dark`. All Tailwind color overrides are written as `.dark .bg-white { ... }` etc. — no Tailwind dark-mode config needed
|
||||||
|
- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`)
|
||||||
|
- **Perspective viewer:** `Forecast.jsx` calls `viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')` both on initial load and in a `useEffect([dark, versionId])` so the viewer stays in sync when the toggle fires
|
||||||
|
- **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()`
|
||||||
|
|
||||||
## Known issues / active work (see todo.md for detail)
|
## Known issues / active work (see todo.md for detail)
|
||||||
|
|
||||||
- Operation panel (Scale/Recode/Clone) wiring to API is a stub — needs completion
|
- Operation panel (Scale/Recode/Clone) wiring to API is a stub — needs completion
|
||||||
|
|||||||
@ -31,6 +31,7 @@ module.exports = function(pool) {
|
|||||||
);
|
);
|
||||||
const colMeta = colResult.rows;
|
const colMeta = colResult.rows;
|
||||||
const dimCols = colMeta.filter(c => c.role === 'dimension').map(c => c.cname);
|
const dimCols = colMeta.filter(c => c.role === 'dimension').map(c => c.cname);
|
||||||
|
const dateCols = colMeta.filter(c => c.role === 'date').map(c => c.cname);
|
||||||
const valueCol = colMeta.find(c => c.role === 'value')?.cname;
|
const valueCol = colMeta.find(c => c.role === 'value')?.cname;
|
||||||
const unitsCol = colMeta.find(c => c.role === 'units')?.cname;
|
const unitsCol = colMeta.find(c => c.role === 'units')?.cname;
|
||||||
|
|
||||||
@ -48,6 +49,8 @@ module.exports = function(pool) {
|
|||||||
table: fcTable(version.tname, version.id),
|
table: fcTable(version.tname, version.id),
|
||||||
colMeta,
|
colMeta,
|
||||||
dimCols,
|
dimCols,
|
||||||
|
dateCols,
|
||||||
|
filterCols: [...dimCols, ...dateCols],
|
||||||
valueCol,
|
valueCol,
|
||||||
unitsCol,
|
unitsCol,
|
||||||
sql: sqlResult.rows[0].sql
|
sql: sqlResult.rows[0].sql
|
||||||
@ -303,7 +306,7 @@ module.exports = function(pool) {
|
|||||||
const ctx = await getContext(parseInt(req.params.id), 'scale');
|
const ctx = await getContext(parseInt(req.params.id), 'scale');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
const whereClause = buildWhere(slice, ctx.dimCols);
|
const whereClause = buildWhere(slice, ctx.filterCols);
|
||||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||||
|
|
||||||
let absValueIncr = value_incr || 0;
|
let absValueIncr = value_incr || 0;
|
||||||
@ -361,7 +364,7 @@ module.exports = function(pool) {
|
|||||||
const ctx = await getContext(parseInt(req.params.id), 'recode');
|
const ctx = await getContext(parseInt(req.params.id), 'recode');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
const whereClause = buildWhere(slice, ctx.dimCols);
|
const whereClause = buildWhere(slice, ctx.filterCols);
|
||||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||||
const setClause = buildSetClause(ctx.dimCols, set);
|
const setClause = buildSetClause(ctx.dimCols, set);
|
||||||
|
|
||||||
@ -398,7 +401,7 @@ module.exports = function(pool) {
|
|||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
|
|
||||||
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
|
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
|
||||||
const whereClause = buildWhere(slice, ctx.dimCols);
|
const whereClause = buildWhere(slice, ctx.filterCols);
|
||||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||||
const setClause = buildSetClause(ctx.dimCols, set);
|
const setClause = buildSetClause(ctx.dimCols, set);
|
||||||
|
|
||||||
|
|||||||
@ -48,12 +48,20 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
|||||||
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
>
|
>
|
||||||
{dark ? (
|
{dark ? (
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8z"/>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M6 .278a.768.768 0 0 1 1.065.02A.75.75 0 0 1 5.792 15.5a.75.75 0 0 1-1.498-.075.768.768 0 0 1-.02-1.05A8 8 0 1 0 6.278 14.72a.768.768 0 0 1-1.055-.02A.75.75 0 0 1 2.5 13.75a.75.75 0 0 1 1.498.075A8 8 0 1 0 6 .278z"/>
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -114,10 +114,14 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
|||||||
if (!valueCol && !unitsCol) return
|
if (!valueCol && !unitsCol) return
|
||||||
try {
|
try {
|
||||||
const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
|
const dimNames = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
|
||||||
|
const dateNames = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
|
||||||
const filters = [
|
const filters = [
|
||||||
...Object.entries(sliceObj)
|
...Object.entries(sliceObj)
|
||||||
.filter(([col]) => dimNames.has(col))
|
.filter(([col]) => dimNames.has(col))
|
||||||
.map(([col, val]) => [col, '==', val]),
|
.map(([col, val]) => [col, '==', val]),
|
||||||
|
...Object.entries(sliceObj)
|
||||||
|
.filter(([col]) => dateNames.has(col))
|
||||||
|
.map(([col, val]) => [col, '==', Number(val)]),
|
||||||
['pf_iter', '!=', 'reference'],
|
['pf_iter', '!=', 'reference'],
|
||||||
]
|
]
|
||||||
const view = await tableRef.current.view({ filter: filters })
|
const view = await tableRef.current.view({ filter: filters })
|
||||||
@ -385,7 +389,9 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
|||||||
|
|
||||||
async function submitOp(op) {
|
async function submitOp(op) {
|
||||||
if (!Object.keys(slice).length) { flash('Select a slice first', 'error'); return }
|
if (!Object.keys(slice).length) { flash('Select a slice first', 'error'); return }
|
||||||
let body = { pf_user: 'admin', slice }
|
const effectiveSlice = buildEffectiveSlice(slice)
|
||||||
|
if (!Object.keys(effectiveSlice).length) { flash('No dimension or date columns in slice — check col_meta', 'error'); return }
|
||||||
|
let body = { pf_user: 'admin', slice: effectiveSlice }
|
||||||
|
|
||||||
if (op === 'scale') {
|
if (op === 'scale') {
|
||||||
let vi = null, ui = null
|
let vi = null, ui = null
|
||||||
@ -430,6 +436,50 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
|||||||
} catch (err) { flash(err.message, 'error') }
|
} catch (err) { flash(err.message, 'error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildEffectiveSlice(raw) {
|
||||||
|
const dimCols = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
|
||||||
|
const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
|
||||||
|
const out = {}
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
if (dimCols.has(k)) { out[k] = v; continue }
|
||||||
|
if (dateCols.has(k)) {
|
||||||
|
const ms = Number(v)
|
||||||
|
out[k] = isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(op) {
|
||||||
|
if (!Object.keys(slice).length) return null
|
||||||
|
const effectiveSlice = buildEffectiveSlice(slice)
|
||||||
|
let body = { pf_user: 'admin', slice: effectiveSlice }
|
||||||
|
if (op === 'scale') {
|
||||||
|
let vi = null, ui = null
|
||||||
|
if (scaleMode === 'target') {
|
||||||
|
const curValue = currentTotals?.total?.value
|
||||||
|
const curUnits = currentTotals?.total?.units
|
||||||
|
if (scalePrice !== '' && curUnits != null && curValue != null)
|
||||||
|
vi = (parseFloat(scalePrice) * curUnits) - curValue
|
||||||
|
if (scaleValue !== '' && curValue != null)
|
||||||
|
vi = parseFloat(scaleValue) - curValue
|
||||||
|
if (scaleUnits !== '' && curUnits != null)
|
||||||
|
ui = parseFloat(scaleUnits) - curUnits
|
||||||
|
} else {
|
||||||
|
if (scaleValue !== '') vi = parseFloat(scaleValue)
|
||||||
|
if (scaleUnits !== '') ui = parseFloat(scaleUnits)
|
||||||
|
}
|
||||||
|
body = { ...body, note: scaleNote || undefined, value_incr: vi, units_incr: ui, pct: scaleMode === 'delta' && scalePct }
|
||||||
|
} else if (op === 'recode') {
|
||||||
|
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
|
||||||
|
body = { ...body, note: recodeNote || undefined, set }
|
||||||
|
} else if (op === 'clone') {
|
||||||
|
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
|
||||||
|
body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 }
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
function flash(text, type = 'ok') {
|
function flash(text, type = 'ok') {
|
||||||
setMsg({ text, type })
|
setMsg({ text, type })
|
||||||
setTimeout(() => setMsg(null), 3000)
|
setTimeout(() => setMsg(null), 3000)
|
||||||
@ -785,6 +835,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Row label="Note"><input value={scaleNote} onChange={e => setScaleNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
<Row label="Note"><input value={scaleNote} onChange={e => setScaleNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<PayloadPreview payload={buildPayload('scale')} />
|
||||||
<Submit onClick={() => submitOp('scale')}>Apply Scale</Submit>
|
<Submit onClick={() => submitOp('scale')}>Apply Scale</Submit>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
@ -797,6 +848,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
|||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
<Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
<Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<PayloadPreview payload={buildPayload('recode')} />
|
||||||
<Submit onClick={() => submitOp('recode')}>Apply Recode</Submit>
|
<Submit onClick={() => submitOp('recode')}>Apply Recode</Submit>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
@ -810,6 +862,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
|
|||||||
))}
|
))}
|
||||||
<Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row>
|
<Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row>
|
||||||
<Row label="Note"><input value={cloneNote} onChange={e => setCloneNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
<Row label="Note"><input value={cloneNote} onChange={e => setCloneNote(e.target.value)} placeholder="optional" className={inp} /></Row>
|
||||||
|
<PayloadPreview payload={buildPayload('clone')} />
|
||||||
<Submit onClick={() => submitOp('clone')}>Apply Clone</Submit>
|
<Submit onClick={() => submitOp('clone')}>Apply Clone</Submit>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
@ -868,3 +921,12 @@ function Submit({ onClick, children }) {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PayloadPreview({ payload }) {
|
||||||
|
if (!payload) return null
|
||||||
|
return (
|
||||||
|
<pre className="text-xs font-mono text-gray-400 bg-gray-50 border border-gray-100 rounded p-2 overflow-auto max-h-36 leading-relaxed">
|
||||||
|
{JSON.stringify(payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user