import { useState, useEffect } from 'react' import Timeline from '../components/Timeline.jsx' import useAuth from '../auth.jsx' const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL'] function buildCondition(c) { const col = `"${c.col}"` if (c.op === 'IS NULL') return `${col} IS NULL` if (c.op === 'IS NOT NULL') return `${col} IS NOT NULL` if (c.op === 'BETWEEN') { const [a, b] = c.values if (!a || !b) return null return `${col} BETWEEN '${a}' AND '${b}'` } if (c.op === 'IN' || c.op === 'NOT IN') { const v = (c.values[0] || '').split(',').map(s => s.trim()).filter(Boolean) if (!v.length) return null return `${col} ${c.op} ('${v.join("','")}')` } if (!c.values[0]) return null return `${col} ${c.op} '${c.values[0]}'` } function buildFilterClause(groups) { if (!groups?.length) return null const parts = groups .map(g => g.map(buildCondition).filter(Boolean).join(' AND ')) .filter(s => s.length > 0) .map(s => `(${s})`) if (!parts.length) return null return parts.join(' OR ') } function parseDateRangeFromClause(clause) { if (!clause) return null const m = clause.match(/BETWEEN '(\d{4}-\d{2}-\d{2})' AND '(\d{4}-\d{2}-\d{2})'/) if (m) return { from: m[1], to: m[2] } return null } function getDateRange(groups) { for (const g of groups || []) { for (const c of g) { if (c.op === 'BETWEEN' && c.values[0] && c.values[1]) return { from: c.values[0], to: c.values[1] } if (c.op === '=' && c.values[0]) return { from: c.values[0], to: c.values[0] } } } return null } // The offset is stored and sent as a Postgres interval, so it is typed as one -- // "4 months", "1 year", "-90 days". This only parses it far enough to draw the // timeline preview; Postgres remains the authority on what is valid, and the // server rejects anything it will not accept. // // It replaced a pair of year/month number spinners, which could not express days // at all and were clamped at zero, so a segment could only ever be shifted // forward in whole months. export function parseInterval(str) { let months = 0, days = 0 if (!str) return { months, days } const re = /([+-]?\d+(?:\.\d+)?)\s*(years?|yrs?|y|months?|mons?|mo|weeks?|wks?|w|days?|d)\b/gi for (const [, n, unit] of str.matchAll(re)) { const v = parseFloat(n) const u = unit.toLowerCase() if (u.startsWith('y')) months += v * 12 else if (u.startsWith('mo') || u === 'mons' || u === 'mon') months += v else if (u.startsWith('w')) days += v * 7 else if (u.startsWith('d')) days += v else months += v // 'm' alone reads as months here } return { months, days } } function emptyCondition(cols) { return { col: cols[0]?.cname || '', op: 'BETWEEN', values: ['', ''] } } function emptyGroup(cols) { return [emptyCondition(cols)] } function normalizeFilters(stored) { // accept legacy flat shape and wrap as one group; fall back to a blank group if (!Array.isArray(stored) || stored.length === 0) return null if (Array.isArray(stored[0])) return stored if (stored[0]?.col != null) return [stored] return null } export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) { const { user: me } = useAuth() const [filterCols, setFilterCols] = useState([]) const [log, setLog] = useState([]) // new version form const [showNewVersion, setShowNewVersion] = useState(false) const [newVerName, setNewVerName] = useState('') const [newVerDesc, setNewVerDesc] = useState('') const [creatingVer, setCreatingVer] = useState(false) const [cloneFrom, setCloneFrom] = useState('') // Planned segments: a version's definition before it has been run. Separate // from `log`, which is what has actually happened. const [plans, setPlans] = useState([]) const [editingPlanId, setEditingPlanId] = useState(null) const [runningAll, setRunningAll] = useState(false) // Tags in use on this version, and the rename in progress. A tag is not a row // anywhere -- only a value repeated across pf.log -- so the list is derived // and editing one rewrites every entry that carries it. const [tags, setTags] = useState([]) const [editingTag, setEditingTag] = useState(null) // { from, text } const [savingTag, setSavingTag] = useState(false) // segment form const [segType, setSegType] = useState('baseline') const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]] const [useRaw, setUseRaw] = useState(false) const [rawSql, setRawSql] = useState('') const [offset, setOffset] = useState('0 days') const [segNote, setSegNote] = useState('') // Presentation, not definition: what the segment counts toward and how it is // labelled in the pivot. Safe to set at any time, unlike its filters. const [segBucket, setSegBucket] = useState('') const [segLabel, setSegLabel] = useState('') const [submitting, setSubmitting] = useState(false) const [editingLogId, setEditingLogId] = useState(null) const [showAddForm, setShowAddForm] = useState(false) const [hasForecastOps, setHasForecastOps] = useState(false) const [expandedId, setExpandedId] = useState(null) const [msg, setMsg] = useState(null) useEffect(() => { if (!sourceId) return fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => { // What a load is filtered by and what the pivot groups by are separate // concerns: a dimension is exactly the sort of thing a segment is cut on // (sseas, channel_new), and forcing it to role 'filter' to get it here // would cost it its place on the pivot. Only measures are excluded. const fc = cols.filter(c => ['date', 'filter', 'dimension'].includes(c.role)) setFilterCols(fc) setFilters(fc.length > 0 ? [emptyGroup(fc)] : []) }) }, [sourceId]) useEffect(() => { if (!versionId) { setLog([]); setPlans([]); setTags([]); return } loadLog() loadPlans() loadTags() }, [versionId]) // A segment's banner: what it counts toward, independent of pf_iter. Held // locally while typing so the field does not fight the fetched value, and // written on blur. const [buckets, setBuckets] = useState({}) // Buckets already in use on this version, for the datalist. There is no stored // bucket order any more: the order is whatever the typed text sorts as, so a // bucket is named "02 - Forecast" and that is the whole mechanism. const [bucketsInUse, setBucketsInUse] = useState([]) // Label and bucket are presentation, not definition: they change what the pivot // shows and what the segment counts toward, never which rows were loaded. So // they stay editable after adjustments exist, unlike the filters and offset, // where an edit would silently recalibrate scales sized against the old rows. // // Both are only read at load time, hence the reload in the confirmation: the // label is part of the aggregated row the pivot holds, not something it can // re-derive in place. // Same rule the server enforces: your own entries, or an admin's. A segment's // label and bucket name the pivot's columns for everyone in the version, so // they are not the private annotation they look like. const canEdit = (entry) => !!me && (me.is_admin || entry.pf_user === me.username) async function saveLogField(entry, field, value) { const next = value.trim() if (next === (entry[field] || '')) return try { const res = await fetch(`/api/log/${entry.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: next }), }) if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } loadLog() flash(next ? `Saved — reload the Forecast view to see it` : 'Cleared') } catch (err) { flash(err.message, 'error') } } // Column widths read off the content rather than guessed. ch is the width of a // '0', so for proportional text it runs slightly generous -- which is what is // wanted for an input you are about to type a longer name into. The floors keep // an empty table from collapsing its headers; the ceilings keep one long note // from pushing the numbers off the side. function widthCh(values, min, max) { const longest = values.reduce((n, v) => Math.max(n, String(v || '').length), 0) return `${Math.min(max, Math.max(min, longest + 2))}ch` } const labelW = widthCh(log.map(e => e.label || e.tag || e.note), 18, 34) const bucketW = widthCh(log.map(e => e.bucket), 16, 24) const noteW = widthCh(log.map(e => e.note), 22, 44) function loadLog() { fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => { setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference')) setHasForecastOps(data.some(e => ['scale', 'recode', 'clone'].includes(e.operation))) // Every bucket in use, adjustments included: typing one on a new segment // should offer the ones already there rather than inviting a near-miss // spelling, which would silently split the column in two. setBucketsInUse([...new Set(data.map(e => (e.bucket || '').trim()).filter(Boolean))].sort()) }) } function loadTags() { fetch(`/api/versions/${versionId}/tags`) .then(r => r.ok ? r.json() : []) .then(d => setTags(Array.isArray(d) ? d : [])) .catch(() => setTags([])) } // Renaming onto a tag that already exists merges the two, which is usually // the reason to be here at all -- so say so before it happens rather than // reporting it afterwards. async function saveTag(from, raw) { const to = (raw || '').trim() if (to === from) { setEditingTag(null); return } const merges = to && tags.some(t => t.tag === to) if (merges && !confirm(`Merge "${from}" into "${to}"? Both become one initiative in the bridge.`)) return if (!to && !confirm(`Clear "${from}" from every entry that carries it?`)) return setSavingTag(true) try { const res = await fetch(`/api/versions/${versionId}/tags`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to }), }) const d = await res.json() if (!res.ok) { flash(d.error, 'error'); return } setEditingTag(null) loadTags(); loadLog() flash(to ? `${d.rows_affected} ${d.rows_affected === 1 ? 'entry' : 'entries'} now tagged "${to}"` : `Cleared from ${d.rows_affected} ${d.rows_affected === 1 ? 'entry' : 'entries'}`) } catch (err) { flash(err.message, 'error') } finally { setSavingTag(false) } } function loadPlans() { fetch(`/api/versions/${versionId}/plans`) .then(r => r.ok ? r.json() : []) .then(setPlans) .catch(() => setPlans([])) } // Run one plan: the same call the Add Segment form makes, then stamp the log // id back onto the plan so the list can tell run from unrun. Returns the // error text, or null on success, so Run all can stop at the first failure // rather than carrying on down a definition that has gone wrong. async function runPlan(plan) { const body = { where_clause: plan.raw_where || buildFilterClause(normalizeFilters(plan.filters) || []), note: plan.note || '', date_offset: plan.date_offset || '0 days', label: plan.label || '', bucket: plan.bucket || '', ...(plan.raw_where ? { raw_where: plan.raw_where } : { filters: plan.filters }), } if (!body.where_clause) return 'No filters on this plan' try { const res = await fetch(`/api/versions/${versionId}/${plan.kind}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const data = await res.json() if (!res.ok) return data.error || 'Failed' const logId = data.log_id ?? data.id ?? null if (logId) { await fetch(`/api/plans/${plan.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ run_logid: logId }), }) } return null } catch (err) { return err.message } } async function runOnePlan(plan) { setSubmitting(true) const err = await runPlan(plan) setSubmitting(false) loadLog(); loadPlans() flash(err || `Loaded “${plan.label || plan.tag || plan.kind}”`, err ? 'error' : 'ok') } // In seq order, and stopping at the first failure: the segments of a // definition are not independent -- a baseline followed by references that // date-shift off it -- so carrying on past a broken one builds something // nobody described. async function runAllPlans() { const pending = plans.filter(p => !p.run_logid) if (!pending.length) return setRunningAll(true) let done = 0 for (const plan of pending) { const err = await runPlan(plan) if (err) { setRunningAll(false) loadLog(); loadPlans() flash(`Stopped after ${done} of ${pending.length}: ${err}`, 'error') return } done++ } setRunningAll(false) loadLog(); loadPlans() flash(`Loaded ${done} segment${done === 1 ? '' : 's'}`) } async function deletePlan(plan) { await fetch(`/api/plans/${plan.id}`, { method: 'DELETE' }) if (editingPlanId === plan.id) cancelEdit() loadPlans() } // A plan opens in the same form a segment does; the difference is only which // call the submit makes. function startEditPlan(plan) { setSegType(plan.kind) setSegNote(plan.note || '') setSegLabel(plan.label || '') setSegBucket(plan.bucket || '') setOffset(plan.date_offset || '0 days') const groups = normalizeFilters(plan.filters) if (groups) { setUseRaw(false); setRawSql(''); setFilters(groups) } else if (plan.raw_where) { setUseRaw(true); setRawSql(plan.raw_where) } else { setUseRaw(false); setRawSql(''); setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } setEditingLogId(null) setEditingPlanId(plan.id) setExpandedId(null) setTimeout(() => { document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 0) } // Save the definition without running it — the whole point of a plan. async function savePlan(opts = {}) { const plan = plans.find(p => p.id === editingPlanId) if (!plan) return null setSubmitting(true) try { const res = await fetch(`/api/plans/${editingPlanId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: segType, label: segLabel.trim(), bucket: segBucket.trim(), note: segNote.trim(), date_offset: offset.trim() || '0 days', ...(useRaw ? { raw_where: rawSql.trim(), filters: null } : { filters, raw_where: null }), }), }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return null } loadPlans() cancelEdit() if (!opts.returnRow) flash('Plan saved — not loaded yet') return data } catch (err) { flash(err.message, 'error'); return null } finally { setSubmitting(false) } } // Save what is in the form, then run what was saved. Reading the plan back // from the PATCH rather than reusing the row in state: the form is what the // user just edited, and running the stale copy would load the old definition // while showing the new one. async function saveAndRunPlan() { const saved = await savePlan({ returnRow: true }) if (!saved) return await runOnePlan(saved) } async function createVersion() { if (!newVerName.trim()) return setCreatingVer(true) try { const res = await fetch(`/api/sources/${sourceId}/versions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // created_by is ignored by the server, which takes the account from the // session; sending it would let any client claim to be anyone. body: JSON.stringify({ name: newVerName.trim(), description: newVerDesc, ...(cloneFrom ? { clone_from: parseInt(cloneFrom) } : {}), }) }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return } await refreshVersions(sourceId) setVersionId(String(data.id)) setShowNewVersion(false) setNewVerName('') setNewVerDesc('') setCloneFrom('') flash(data.cloned ? `Version "${data.name}" created from "${data.cloned.from_name}" — ${data.cloned.plans} segment${data.cloned.plans === 1 ? '' : 's'} planned, nothing loaded yet` : `Version "${data.name}" created`) } catch (err) { flash(err.message, 'error') } finally { setCreatingVer(false) } } async function loadSegment() { const clause = useRaw ? rawSql.trim() : buildFilterClause(filters) if (!clause) { flash(useRaw ? 'Enter a WHERE clause' : 'Add at least one filter', 'error'); return } const isRef = segType === 'reference' const offsetStr = offset.trim() || '0 days' const endpoint = isRef ? 'reference' : 'baseline' const body = { where_clause: clause, note: segNote, date_offset: offsetStr, label: segLabel.trim(), bucket: segBucket.trim(), ...(useRaw ? { raw_where: clause } : { filters }), } setSubmitting(true) try { const url = editingLogId ? `/api/versions/${versionId}/baseline/${editingLogId}` : `/api/versions/${versionId}/${endpoint}` const method = editingLogId ? 'PUT' : 'POST' const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return } flash(editingLogId ? `Updated — ${data.rows_deleted} rows replaced with ${data.rows_affected}` : `Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`) loadLog() cancelEdit() } catch (err) { flash(err.message, 'error') } finally { setSubmitting(false) } } function startEdit(entry) { if (hasForecastOps) { flash('Undo forecast operations first to edit segments', 'error') return } const params = entry.params || {} setSegType(entry.operation) setSegNote(entry.note || '') setSegLabel(entry.label || '') setSegBucket(entry.bucket || '') setOffset(params.date_offset || '0 days') const groups = normalizeFilters(params.filters) if (groups) { setUseRaw(false) setRawSql('') setFilters(groups) } else if (params.where_clause) { // legacy: only the compiled WHERE was stored. Open in raw mode. setUseRaw(true) setRawSql(params.where_clause) setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } else { setUseRaw(false) setRawSql('') setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } setEditingLogId(entry.id) setExpandedId(null) setTimeout(() => { document.getElementById('add-segment')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 0) } function cancelEdit() { setEditingLogId(null) setEditingPlanId(null) setShowAddForm(false) setSegNote('') setSegLabel('') setSegBucket('') setOffsetYr(0) setOffsetMo(0) setUseRaw(false) setRawSql('') setFilters(filterCols.length > 0 ? [emptyGroup(filterCols)] : []) } async function undoSegment(logid) { await fetch(`/api/log/${logid}`, { method: 'DELETE' }) loadLog() flash('Segment undone') } async function clearBaseline() { if (!confirm('Delete all baseline rows for this version?')) return const res = await fetch(`/api/versions/${versionId}/baseline`, { method: 'DELETE' }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return } loadLog() flash(`Cleared ${data.rows_deleted} rows`) } async function closeVersion() { const res = await fetch(`/api/versions/${versionId}/close`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return } await refreshVersions(sourceId) flash('Version closed') } async function reopenVersion() { const res = await fetch(`/api/versions/${versionId}/reopen`, { method: 'POST' }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return } await refreshVersions(sourceId) flash('Version reopened') } async function deleteVersion() { if (!confirm(`Delete version "${selectedVersion?.name}"? This drops the forecast table and cannot be undone.`)) return const res = await fetch(`/api/versions/${versionId}`, { method: 'DELETE' }) const data = await res.json() if (!res.ok) { flash(data.error, 'error'); return } const updated = await refreshVersions(sourceId) setVersionId(updated.length > 0 ? String(updated[0].id) : '') flash('Version deleted') } function flash(text, type = 'ok') { setMsg({ text, type }) setTimeout(() => setMsg(null), 3000) } // The names a row falls back to when nobody has named it. Blank means "use the // built-in", so these save on blur like the segment fields and an empty box is // a meaningful value rather than a missing one. async function saveVersionName(field, value, opts = {}) { const next = value.trim() if (next === (selectedVersion?.[field] || '')) return // The route COALESCEs a falsy name back to the existing one, so a blank box // would look saved and change nothing. Say so instead. if (field === 'name' && !next) { flash('A version needs a name', 'error') return } try { const res = await fetch(`/api/versions/${versionId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: next }), }) if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } await refreshVersions(sourceId) // A rename shows everywhere the version is listed and needs no reload; a // fallback name is baked into the aggregated row, so the pivot cannot // re-derive it in place. flash(opts.message || 'Saved — reload the Forecast view to see it') } catch (err) { flash(err.message, 'error') } } // The default is the source's, not this version's: setting it here clears it // wherever it was. Admin only, server-side as well as here. async function saveDefaultVersion(checked) { try { const res = await fetch(`/api/versions/${versionId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_default: checked }), }) if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } await refreshVersions(sourceId) flash(checked ? 'Default forecast — this is where anyone without a saved selection lands' : 'No default forecast — the newest one is used instead') } catch (err) { flash(err.message, 'error') } } const selectedVersion = versions.find(v => String(v.id) === versionId) return (
{/* No page-wide cap and no stretching: at max-w-4xl (896px) the eleven-column segment table always had something smashed, and uncapped it ran to the window. items-start makes each block as wide as its own content needs, which for the table is the measured column widths below. */}
{msg && (
{msg.text}
)} {/* Version actions */}
{versionId && (
{selectedVersion?.status === 'open' ? : }
)}
{/* Name and description. Nothing depends on the name -- the forecast table is fc_{tname}_{version_id}, pf.log and pf.layout key on the id, and the UI's localStorage keys are id-based -- so it is safe to change at any time, including on a closed version, where correcting a name is still legitimate. Saves on blur, like the fallback names below. */} {versionId && (
This forecast
saveVersionName('name', e.target.value, { message: 'Renamed' })} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }} className="border border-gray-200 rounded px-2 py-1 text-sm w-64" />
saveVersionName('description', e.target.value, { message: 'Saved' })} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm w-full" />
{/* Where an account with no saved selection of its own lands. One per source, so ticking it here unticks it wherever it was. */}
)} {/* Fallback names. Not part of any segment -- they are what the pivot shows for rows nobody has named, so they belong to the version rather than to a log entry. Blank falls back to the built-in in sql_generator's DISPLAY DEFAULTS block. */} {versionId && (
Fallback names {[ ['adjustment_segment', 'Adjustment segment', '99 - Adjustments'], ['adjustment_bucket', 'Adjustment bucket', '04 - Forecast'], ['unlabeled_load', 'Unlabeled load', 'Unlabeled'], ].map(([field, label, builtin]) => (
saveVersionName(field, e.target.value)} placeholder={builtin} className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
))}
)} {/* Tags. Not a table anywhere -- a tag is a value repeated across this version's pf.log entries, and the bridge groups on it. So renaming one here rewrites every entry that carries it, and renaming onto an existing tag merges two initiatives that were only ever one. Editing a single entry's tag is still the Change log, on the Forecast page; this is the whole-version view of the same column. */} {versionId && (
Tags — the initiatives named on this forecast {tags.length === 0 ? ( No tags yet. One is created by typing it on a scale, recode or clone in the Operations panel. ) : ( {tags.map(t => ( ))}
Tag Adjustments Last used
{editingTag?.from === t.tag ? ( setEditingTag({ from: t.tag, text: e.target.value })} onBlur={e => saveTag(t.tag, e.target.value)} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() if (e.key === 'Escape') setEditingTag(null) }} className="border border-gray-200 rounded px-2 py-0.5 text-sm w-64" /> ) : ( )} {t.uses} {t.last_used ? new Date(t.last_used).toLocaleDateString() : ''}
)} {/* Completing a rename against the list is what makes a merge deliberate rather than a near-miss spelling. */} {tags.map(t =>
)} {showNewVersion && (
setNewVerName(e.target.value)} placeholder="e.g. FY2026 Plan" className="border border-gray-200 rounded px-2 py-1 text-sm w-48" />
setNewVerDesc(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1 text-sm" />
{cloneFrom ? <>Copies that version's settings, its saved layouts, and its load segments as plans — nothing is loaded, so the dates can be changed before anything runs. Adjustments are not copied: a scale's target was sized against the rows that were there. : null} Creates a forecast table pf.fc_{sources.find(s=>String(s.id)===sourceId)?.tname}_<id> in the database from the current col meta. If col meta changes after creation the table and SQL will be out of sync — delete and recreate the version to realign.
)} {versionId && <> {/* Planned segments — the definition, before it has been run. Only shown when there are any: a version built by hand has none, and an empty panel would be one more thing to read past. */} {plans.length > 0 && (
Planned segments {plans.filter(p => !p.run_logid).length} of {plans.length} not yet loaded
{plans.map((p, i) => { const where = p.raw_where || buildFilterClause(normalizeFilters(p.filters) || []) || '—' return ( ) })}
# kind label counts toward offset where rows
{i + 1} {p.kind} {p.label || unnamed} {p.bucket || '—'} {p.date_offset} {where} {p.run_logid ? (p.run_rows ?? '').toLocaleString?.() || p.run_rows : ''} {p.run_logid ? ( loaded ) : ( <> )}
)} {/* Segments loaded */}
Segments loaded
{[...new Set([...bucketsInUse, '01 - Prior Prior Year', '02 - Prior Year', '03 - Plan', '04 - Forecast'])].map(b => ( {log.length === 0 && ( )} {!showAddForm && !editingLogId && ( )} {log.map((entry, i) => { const isOpen = expandedId === entry.id const view = segmentValuesFor(entry, filterCols) return ( <> setExpandedId(isOpen ? null : entry.id)} className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${editingLogId === entry.id ? 'bg-amber-50 ring-1 ring-amber-300 ring-inset' : isOpen ? 'bg-blue-50' : ''}`} > {/* The operation badge gets its own column. Sharing one with the note put "reference" hard against "YTD Sales" as soon as the note column lost width to label and bucket. */} {/* One line, clipped against the measured width above. The note is provenance and can run long, so left to itself it wrapped and pushed every row to two or three lines. Expanding the row shows it in full. The cap is on the div, not the cell: a max-width on a cell in an auto-layout table is only a hint, and the column can still collapse to min-content or grow past it. */} {isOpen && ( )} ) })}
# kind label note counts toward rows {log[0]?.value_col || 'value'} by when
No segments loaded yet
{isOpen ? '▾' : '▸'} {log.length - i} {entry.operation} e.stopPropagation()}> saveLogField(entry, 'label', e.target.value)} readOnly={!canEdit(entry)} title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`} placeholder={entry.tag || entry.note || '—'} className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> {entry.note ?
{entry.note}
: —}
e.stopPropagation()}> setBuckets(b => ({ ...b, [entry.id]: e.target.value }))} onBlur={e => saveLogField(entry, 'bucket', e.target.value)} readOnly={!canEdit(entry)} title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`} placeholder="—" className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> {entry.row_count != null ? entry.row_count.toLocaleString() : '—'} {entry.value_total != null ? entry.value_total.toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'} {entry.pf_user} {new Date(entry.stamp).toLocaleDateString()} {!hasForecastOps && ( )}
{/* Add / Edit Segment */} {(showAddForm || editingLogId || editingPlanId) && (
{(() => { if (editingPlanId) { const idx = plans.findIndex(p => p.id === editingPlanId) const plan = plans[idx] return plan ? `Edit plan ${idx + 1} — ${plan.kind}${plan.label ? ` — ${plan.label}` : ''} — not loaded yet` : 'Edit plan' } if (!editingLogId) return 'Add Segment' const idx = log.findIndex(e => e.id === editingLogId) if (idx < 0) return 'Edit Segment' const entry = log[idx] const segNum = log.length - idx const label = entry.operation === 'reference' ? 'reference' : 'baseline' return entry.note ? `Edit segment #${segNum} — ${label} — ${entry.note}` : `Edit segment #${segNum} — ${label}` })()}
)} }
) } // derive view-mode props for a saved segment function segmentValuesFor(entry, filterCols) { const params = entry.params || {} const groups = normalizeFilters(params.filters) return { segType: entry.operation === 'reference' ? 'reference' : 'baseline', filters: groups || (filterCols.length > 0 ? [emptyGroup(filterCols)] : []), useRaw: !groups && !!params.where_clause, rawSql: params.where_clause || '', segNote: entry.note || '', segBucket: entry.bucket || '', segLabel: entry.label || '', offset: params.date_offset || '0 days', } } function SegmentForm({ mode, // 'view' | 'edit' segType, setSegType, filters, setFilters, useRaw, setUseRaw, rawSql, setRawSql, segNote, setSegNote, segBucket, setSegBucket, segLabel, setSegLabel, offset, setOffset, filterCols, onSubmit, submitting, editing, // A plan saves without running, so its primary action is named for that and // running is the secondary. Both null for an ordinary segment, where loading // is the only thing submitting can mean. submitLabel = null, onSecondary = null, secondaryLabel = null, }) { const disabled = mode === 'view' const compiled = useRaw ? rawSql : (buildFilterClause(filters) || '') const dateRange = useRaw ? parseDateRangeFromClause(rawSql) : getDateRange(filters) function setGroup(gi, fn) { setFilters(prev => prev.map((g, i) => i === gi ? fn(g) : g)) } function addCondition(gi) { setGroup(gi, g => [...g, emptyCondition(filterCols)]) } function removeCondition(gi, ci) { setFilters(prev => { const next = prev.map((g, i) => i === gi ? g.filter((_, j) => j !== ci) : g) return next.filter(g => g.length > 0) }) } function updateCondition(gi, ci, field, value) { setGroup(gi, g => g.map((c, j) => { if (j !== ci) return c if (field === 'op') { const needsTwo = value === 'BETWEEN' const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value) return { ...c, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : [''] } } return { ...c, [field]: value } })) } function updateConditionValue(gi, ci, vi, value) { setGroup(gi, g => g.map((c, j) => { if (j !== ci) return c const vals = [...c.values]; vals[vi] = value return { ...c, values: vals } })) } function addGroup() { setFilters(prev => [...prev, emptyGroup(filterCols)]) } function removeGroup(gi) { setFilters(prev => prev.filter((_, i) => i !== gi)) } function toggleRaw() { if (useRaw) { setUseRaw(false) setRawSql('') } else { setRawSql(compiled) setUseRaw(true) } } const baseInp = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white disabled:bg-gray-50 disabled:text-gray-500' return (
{/* Type */}
{['baseline', 'reference'].map(t => ( ))}
{/* Filters */}
{!disabled && ( )}
{!useRaw && (
{filters.map((group, gi) => (
{gi === 0 ? 'Group 1' : `Group ${gi + 1} — OR`} {!disabled && (
{filters.length > 1 && ( )}
)}
{group.map((c, ci) => { const isDateCol = filterCols.find(fc => fc.cname === c.col)?.role === 'date' return (
{c.op === 'BETWEEN' && <> updateConditionValue(gi, ci, 0, e.target.value)} placeholder="from" className={`${baseInp} w-36 font-mono`} /> and updateConditionValue(gi, ci, 1, e.target.value)} placeholder="to" className={`${baseInp} w-36 font-mono`} /> } {(c.op === '=' || c.op === '!=') && ( updateConditionValue(gi, ci, 0, e.target.value)} placeholder="value" className={`${baseInp} w-36 font-mono`} /> )} {(c.op === 'IN' || c.op === 'NOT IN') && ( updateConditionValue(gi, ci, 0, e.target.value)} placeholder="val1, val2, …" className={`${baseInp} w-48 font-mono`} /> )} {!disabled && group.length > 1 && ( )}
) })}
))} {!disabled && ( )} {filters.length === 0 && ( No filters — at least one is required )}
)} {useRaw && (