Compare commits

..

No commits in common. "master" and "light-dark-mode" have entirely different histories.

19 changed files with 434 additions and 1803 deletions

110
CLAUDE.md
View File

@ -1,110 +0,0 @@
# Pivot Forecast — CLAUDE.md
## What this app is
A web app for building named forecast scenarios against any PostgreSQL table. The workflow: load historical actuals as a baseline (optionally date-shifted into the forecast period), then apply incremental adjustments (scale, recode, clone) to build a plan. All changes are append-only, fully audited, and reversible by log entry.
Full spec: `pf_spec.md`
Data transport architecture options: `pf_perspective_options.md`
UX mockup: `pf_ux_mockup.md`
Open work: `todo.md`
---
## Tech stack
- **Backend:** Node.js / Express (`server.js`), runs on port 3010
- **Database:** PostgreSQL — isolated `pf` schema
- **Frontend:** React + Vite + Tailwind CSS in `ui/`; built output lands in `public/app/`
- **Pivot:** [Perspective](https://perspective.finos.org/) 4.4.0 loaded from CDN at runtime
- **Dev:** `npm run dev` (nodemon) in root; `npm run build` in `ui/`
---
## Project layout
```
server.js Express entry point; pg pool; type parsers for bigint/numeric
routes/
tables.js GET /api/tables, /api/tables/:schema/:tname/preview
sources.js Source registration, col_meta, SQL generation
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
lib/
sql_generator.js buildFilterClause, token substitution helpers
utils.js
setup_sql/
01_schema.sql pf schema DDL — run once to install
ui/src/
views/
Setup.jsx DB browser, source registration, col_meta editor
Baseline.jsx Version management, baseline workbench, reference load
Forecast.jsx Perspective pivot + operation panel (Scale/Recode/Clone)
Sidebar.jsx 3-step collapsible nav
StatusBar.jsx Source · version · row count · status
Timeline.jsx Date-range preview bar for baseline segments
```
---
## Database schema (`pf`)
- **`pf.source`** — registered source tables
- **`pf.col_meta`** — column roles: `dimension` | `value` | `units` | `date` | `filter` | `ignore`; `is_key` marks dimensions used in slice WHERE clauses
- **`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 (`iter = baseline|scale|recode|clone`) and reference rows (`iter = reference`)
- **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
- **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time
### Key token substitution tokens
`{{fc_table}}`, `{{where_clause}}`, `{{exclude_clause}}`, `{{logid}}`, `{{pf_user}}`, `{{value_incr}}`, `{{units_incr}}`, `{{pct}}`, `{{set_clause}}`, `{{scale_factor}}`, `{{date_offset}}`, `{{filter_clause}}`
---
## Core data flow
### Initial load (Forecast view)
`GET /api/versions/:id/data` → Arrow IPC binary stream → `worker.table(buffer)` in Perspective WASM
**Why one batch (not streaming):** pg returns `bigint`/`numeric` as strings by default — type parsers in `server.js` coerce them to numbers. Per-batch Arrow encoding creates independent dictionaries that cause Perspective WASM to crash on dictionary replacement messages. Server accumulates all rows, emits one record batch.
### Forecast operations
POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNING *` → new rows returned as JSON → `pspTable.update(rows)` — no full reload.
### Undo
`DELETE /api/log/:logid` → removes rows by logid → **full Perspective reload** (known wart).
---
## 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` columns are kept as the slice. This slice populates the operation panel and is sent as the `slice` object in all operation POST bodies.
**Limitation:** computed columns created by Perspective's split_by (e.g. Month, YearDate) don't map back to raw rows — only native dimension columns work for slice extraction.
---
## Operation SQL patterns
All three operations follow the same structure: insert a `pf.log` row in a CTE, then insert forecast rows referencing its id. `{{where_clause}}` is built from the slice; `{{exclude_clause}}` blocks `exclude_iters` rows.
- **Scale** — distributes `value_incr`/`units_incr` proportionally across rows in the slice using window functions
- **Recode** — inserts negative rows (zero out original) + positive rows with `{{set_clause}}` dimension overrides; both share the same logid
- **Clone** — copies the slice with `{{set_clause}}` overrides and `{{scale_factor}}` multiplier; original untouched
`build_where()` validates every slice key against col_meta (only `role = dimension` allowed). Values are escaped but not parameterized — consistent with existing patterns, debuggable in pg logs.
---
## Known issues / active work (see todo.md for detail)
- Operation panel (Scale/Recode/Clone) wiring to API is a stub — needs completion
- Status bar is hardcoded — needs to reflect actual selected source/version
- Load progress bar is jittery — needs throttle (~10 updates/sec)
- Default pivot layout should be configurable per source (currently hardcodes first 2 dimensions)
- Source/version selection doesn't persist across page reload
- Col_meta / version schema drift: if col_meta roles change after a version's forecast table is created, SQL and DDL go out of sync — workaround is to delete and recreate the version
## Deferred (not in v1)
Baseline replay (`replay: true` returns 501), approval workflow, territory filtering, export, version comparison, multi-DB connections.

View File

@ -69,9 +69,6 @@ SELECT count(*) AS rows_affected FROM ins`.trim();
}
function buildReference() {
const referenceSelect = dataCols.map(c =>
c === dateCol ? `(${q(c)} + '{{date_offset}}'::interval)::date` : q(c)
).join(', ');
return `
WITH
ilog AS (
@ -81,7 +78,7 @@ ilog AS (
)
,ins AS (
INSERT INTO {{fc_table}} (${insertCols})
SELECT ${referenceSelect}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM ${srcTable}
WHERE {{filter_clause}}
RETURNING *

1
package-lock.json generated
View File

@ -1105,6 +1105,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",

View File

@ -3,7 +3,6 @@
"version": "1.0.0",
"description": "Pivot Forecast Application",
"main": "server.js",
"license": "MIT",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",

344
pf.sh
View File

@ -1,344 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------------------
# pf.sh — Pivot Forecast management script
# Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config]
# ./pf.sh (interactive menu)
# ---------------------------------------------------------------------------
SERVICE_NAME="pf_app"
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
ENV_FILE="${APP_DIR}/.env"
MIN_NODE_MAJOR=20
# -- Colors ------------------------------------------------------------------
R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; NC='\033[0m'
bold() { echo -e "\033[1m$*\033[0m"; }
info() { echo -e "${B}==>${NC} $*"; }
success() { echo -e "${G}${NC} $*"; }
warn() { echo -e "${Y} !${NC} $*"; }
error() { echo -e "${R}${NC} $*" >&2; }
die() { error "$*"; exit 1; }
# -- Helpers -----------------------------------------------------------------
require_systemd() {
systemctl --version &>/dev/null || die "systemd not found on this system."
}
node_binary() {
command -v node 2>/dev/null || true
}
check_node() {
local node
node=$(node_binary)
[[ -z "$node" ]] && die "node not found. Install Node.js >= ${MIN_NODE_MAJOR}."
local ver
ver=$(node --version | sed 's/v//')
local major="${ver%%.*}"
if (( major < MIN_NODE_MAJOR )); then
die "Node.js ${ver} found; requires >= ${MIN_NODE_MAJOR}. Please upgrade."
fi
success "Node.js ${ver}"
}
require_env() {
[[ -f "$ENV_FILE" ]] || die ".env not found. Run: ./pf.sh config"
}
load_env() {
require_env
set -a; source "$ENV_FILE"; set +a
}
sudo_if_needed() {
# Returns "sudo" if we're not root, empty string if we are
[[ "$EUID" -eq 0 ]] && echo "" || echo "sudo"
}
service_installed() {
[[ -f "$SERVICE_FILE" ]]
}
require_service() {
service_installed || die "systemd service not installed. Run: ./pf.sh install-service"
}
db_ping() {
load_env
local url="${DATABASE_URL:-}"
[[ -z "$url" ]] && { warn "DATABASE_URL not set in .env"; return 1; }
# Use psql if available for a real connectivity check
if command -v psql &>/dev/null; then
psql "$url" -c "SELECT 1" &>/dev/null && return 0 || return 1
else
warn "psql not in PATH — skipping live DB check"
return 0
fi
}
# -- Commands ----------------------------------------------------------------
cmd_deploy() {
echo; bold "Deploying Pivot Forecast"
echo " App dir: $APP_DIR"
echo
check_node
require_env
info "Pulling latest from git…"
git -C "$APP_DIR" pull
info "Installing server dependencies…"
npm --prefix "$APP_DIR" install --omit=dev
info "Installing UI dependencies…"
npm --prefix "$APP_DIR/ui" install
info "Building UI…"
npm --prefix "$APP_DIR/ui" run build
if service_installed; then
info "Restarting service…"
cmd_restart
else
warn "Service not installed — server not started."
echo " Run: ./pf.sh install-service"
fi
success "Deploy complete."
}
cmd_start() {
require_systemd; require_service
info "Starting ${SERVICE_NAME}"
$(sudo_if_needed) systemctl start "$SERVICE_NAME"
success "Started."
}
cmd_stop() {
require_systemd; require_service
info "Stopping ${SERVICE_NAME}"
$(sudo_if_needed) systemctl stop "$SERVICE_NAME"
success "Stopped."
}
cmd_restart() {
require_systemd; require_service
info "Restarting ${SERVICE_NAME}"
$(sudo_if_needed) systemctl restart "$SERVICE_NAME"
success "Restarted."
}
cmd_status() {
require_systemd
echo
bold "System service"
if service_installed; then
systemctl status "$SERVICE_NAME" --no-pager -l || true
else
warn "Service not installed — run: ./pf.sh install-service"
fi
echo
bold "Database"
if db_ping; then
success "DB reachable"
else
error "DB not reachable (check DATABASE_URL in .env)"
fi
echo
bold "Git"
git -C "$APP_DIR" log -1 --format=" Commit: %h %s (%ar)"
local branch
branch=$(git -C "$APP_DIR" rev-parse --abbrev-ref HEAD)
echo " Branch: $branch"
}
cmd_logs() {
require_systemd; require_service
info "Streaming logs (Ctrl-C to exit)…"
journalctl -u "$SERVICE_NAME" -f --no-pager
}
cmd_db_setup() {
require_env; load_env
local url="${DATABASE_URL:-}"
[[ -z "$url" ]] && die "DATABASE_URL not set in .env"
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
echo
bold "DB Setup — will run: setup_sql/01_schema.sql"
warn "This creates the pf schema and tables. Safe to re-run (CREATE IF NOT EXISTS)."
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
psql "$url" -f "${APP_DIR}/setup_sql/01_schema.sql"
success "Schema applied."
}
cmd_config() {
echo
bold "Configure .env"
echo " File: $ENV_FILE"
echo
local current_url=""
local current_port=""
local current_user=""
if [[ -f "$ENV_FILE" ]]; then
current_url=$(grep -E '^DATABASE_URL=' "$ENV_FILE" | cut -d= -f2- | tr -d '"' || true)
current_port=$(grep -E '^PORT=' "$ENV_FILE" | cut -d= -f2- | tr -d '"' || true)
current_user=$(grep -E '^PF_USER=' "$ENV_FILE" | cut -d= -f2- | tr -d '"' || true)
fi
read -rp " DATABASE_URL [${current_url:-not set}]: " input_url
local url="${input_url:-$current_url}"
[[ -z "$url" ]] && die "DATABASE_URL is required."
read -rp " PORT [${current_port:-3010}]: " input_port
local port="${input_port:-${current_port:-3010}}"
read -rp " PF_USER [${current_user:-$USER}]: " input_user
local pf_user="${input_user:-${current_user:-$USER}}"
cat > "$ENV_FILE" <<EOF
DATABASE_URL=${url}
PORT=${port}
PF_USER=${pf_user}
EOF
chmod 600 "$ENV_FILE"
success ".env written."
if db_ping; then
success "Database connection verified."
else
warn "Could not reach the database — double-check DATABASE_URL."
fi
}
cmd_install_service() {
require_systemd
require_env
local node_path
node_path=$(node_binary)
[[ -z "$node_path" ]] && die "node not found — install Node.js first."
local run_user="$USER"
local s
s=$(sudo_if_needed)
echo
bold "Install systemd service"
echo " Service file : $SERVICE_FILE"
echo " Run as user : $run_user"
echo " App dir : $APP_DIR"
echo " Node binary : $node_path"
echo
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
$s tee "$SERVICE_FILE" > /dev/null <<EOF
[Unit]
Description=Pivot Forecast App
After=network.target
[Service]
Type=simple
User=${run_user}
WorkingDirectory=${APP_DIR}
EnvironmentFile=${ENV_FILE}
ExecStart=${node_path} ${APP_DIR}/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
[Install]
WantedBy=multi-user.target
EOF
$s systemctl daemon-reload
$s systemctl enable "$SERVICE_NAME"
success "Service installed and enabled."
echo " Start now with: ./pf.sh start"
}
cmd_uninstall_service() {
require_systemd
service_installed || { warn "Service not installed."; return; }
echo
warn "This will stop and remove the systemd service (does not touch app files)."
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
local s
s=$(sudo_if_needed)
$s systemctl stop "$SERVICE_NAME" 2>/dev/null || true
$s systemctl disable "$SERVICE_NAME" 2>/dev/null || true
$s rm -f "$SERVICE_FILE"
$s systemctl daemon-reload
success "Service removed."
}
# -- Interactive menu --------------------------------------------------------
interactive_menu() {
while true; do
echo
bold "Pivot Forecast — Management"
echo " 1) deploy pull + install + build + restart"
echo " 2) start start server"
echo " 3) stop stop server"
echo " 4) restart restart server"
echo " 5) status service + DB + git info"
echo " 6) logs tail journald logs"
echo " 7) db-setup apply setup_sql/01_schema.sql"
echo " 8) config set DATABASE_URL / PORT / PF_USER"
echo " 9) install-service create systemd unit file"
echo " 10) uninstall-service remove systemd unit file"
echo " q) quit"
echo
read -rp " Choice: " choice
case "$choice" in
1|deploy) cmd_deploy ;;
2|start) cmd_start ;;
3|stop) cmd_stop ;;
4|restart) cmd_restart ;;
5|status) cmd_status ;;
6|logs) cmd_logs ;;
7|db-setup) cmd_db_setup ;;
8|config) cmd_config ;;
9|install-service) cmd_install_service ;;
10|uninstall-service) cmd_uninstall_service ;;
q|Q|quit|exit) echo "Bye."; exit 0 ;;
*) warn "Unknown option: $choice" ;;
esac
done
}
# -- Dispatch ----------------------------------------------------------------
case "${1:-}" in
deploy) cmd_deploy ;;
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
status) cmd_status ;;
logs) cmd_logs ;;
db-setup) cmd_db_setup ;;
config) cmd_config ;;
install-service) cmd_install_service ;;
uninstall-service) cmd_uninstall_service ;;
"") interactive_menu ;;
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service" ;;
esac

View File

@ -1,257 +0,0 @@
# Perspective Architecture Options
This document weighs how the Forecast view should source data for the Perspective
pivot. The current implementation hits practical limits on initial load
(~30s for 350k rows × ~55 cols), and growth is expected. Choosing an architecture
now should account for both **read** (initial pivot load + interaction) and
**write** (forecasting operations that mutate rows).
---
## Current architecture
### Data flow
- **Transport:** `GET /api/versions/:id/data` returns the full forecast table
as Apache Arrow IPC stream. Server-side: pg cursor (`FETCH 10000`) accumulates
all rows, `tableFromJSON` builds an Arrow table, `tableToIPC` produces one
record batch, response sent with `Content-Length`.
- **Joined columns:** `/data` LEFT JOINs `pf.log` to surface `pf_note` (the
user's note for the operation that produced each row) and `pf_op`
(baseline/scale/recode/clone). Joined at fetch time so note edits are
always live. (Added in `bf85f11`.)
- **Client:** Streams the response body to a `Uint8Array`, hands it to
Perspective's `worker.table()` (`@perspective-dev/client@4.4.0` from CDN).
Perspective's WASM engine owns the table in browser memory; all
pivots/filters/group-bys run locally.
- **Progress UI:** Forecast view reads the response body via
`response.body.getReader()` and shows received-bytes / total-bytes while
loading.
- **Forecasting writes:**
- `scale`/`recode`/`clone` POST → server INSERTs new rows with
`RETURNING *` → client receives JSON rows →
`tableRef.current.update(rows)` appends to Perspective's local table.
**Fast — no reload.**
- `undo` (DELETE) → server removes rows by `pf_logid` → client calls
`initViewer(...)` which **fully reloads** the table.
- `baseline` reload → currently also a full reload.
### Why this specific shape (the bug history)
The current "accumulate all rows, emit one record batch" approach is not
accidental. Two failure modes drove it:
1. **pg returns `bigint` (oid 20) and `numeric` (oid 1700) as JS strings by
default.** That made `tableFromJSON` infer `Dictionary<Utf8>` for ~50 of
55 columns. Fix in `server.js`: register type parsers that coerce both
to `Number` so Arrow infers `Int`/`Float64`.
2. **Per-batch `tableFromJSON` creates independent dictionaries.** When we
streamed batches, the writer emitted ~1230 dictionary REPLACEMENT
messages between batches. Perspective's WASM Arrow reader crashes on
those (`RuntimeError: memory access out of bounds`). Fix: accumulate
rows server-side, build one Arrow table, emit a single record batch.
Reference comment lives in `routes/operations.js` near the cursor loop.
These two bugs explain the ~1015s server stall before the progress bar
appears: the server can't send byte 1 until every row has been fetched,
encoded, and the buffer is sized for `Content-Length`. **Any redesign of
the read path needs to either solve the dictionary-replacement issue
(streaming with stable dictionary IDs declared up front) or replace the
transport entirely (e.g., Parquet, server-side virtual table).**
### Implication for any redesign
The incremental update path (`table.update(rows)`) is what makes
operations feel snappy today. Whatever architecture comes next, writes
need to stay incremental — or get even cheaper. Undo's full reload is
already a known wart.
---
## The options
### A. Stay client-side WASM; optimize the encode path
Keep the architecture. Replace the slow pieces.
- **Encode:** drop `tableFromJSON`. Build Arrow vectors directly from
`cols_meta` types (typed arrays for numerics, dictionary builders for
strings). Eliminates per-row type inference.
- **Stream:** declare schema up front, send dictionaries once, stream record
batches as they come off the cursor. Progress bar starts within ~1s.
- **Trim:** request-level `?cols=` parameter so the server can return only
the columns the active layout needs.
- **Writes:** unchanged — `table.update(rows)` keeps working.
- **Undo:** same path; same wart. Could be improved by surfacing a
`table.remove(pf_ids)` instead of `initViewer`.
| Aspect | Impact |
|---|---|
| Initial load | ~35× faster server encode + parallel transfer; bar appears in ~1s |
| Interaction | Unchanged (already instant) |
| Writes | Unchanged (already fast) |
| Browser memory ceiling | Still limited by Perspective WASM (~12M rows is the rough wall) |
| Code change | Medium: new builder code in `routes/operations.js`, schema declaration; UI mostly unchanged |
| New runtime deps | None |
**Right answer if:** dataset stays under ~1M rows and the goal is "make it
faster without rearchitecting."
---
### B. DuckDB-WASM in the browser (Parquet load + `DuckDBHandler`)
Replace the Arrow IPC payload with a Parquet file. Browser loads it into
DuckDB-WASM. Perspective's `DuckDBHandler` (from
`@perspective-dev/client/dist/esm/virtual_servers/duckdb.js`) backs the
viewer — every pivot interaction becomes a SQL query against the local
DuckDB-WASM instance. Perspective ships the view-config-to-SQL translator;
no custom code there.
- **Initial transfer:** Parquet for a forecast table is typically ~1030 MB
for 350k rows (vs. ~80150 MB for Arrow IPC). Smaller download, no
server-side `tableFromJSON`.
- **Encode:** server-side. DuckDB on the server can `COPY (SELECT ... FROM
postgres_scan(...)) TO 'foo.parquet'`, or pre-stage Parquet on each
forecast write. Either way, no Node-side Arrow encode.
- **Interaction:** instant — local SQL on a columnar engine. No round trips.
- **Writes:** **this is the hard part.** After a `scale`/`recode`/`clone`,
the server has new rows in pg but DuckDB-WASM has a stale snapshot.
Options:
1. **Server returns new rows as Arrow** → client does `INSERT INTO
forecast SELECT * FROM arrow_view` in DuckDB-WASM, then notifies the
`DuckDBHandler` to refresh views.
2. **Re-export Parquet** → re-fetch. Simple but wasteful for small
incremental ops.
3. **Maintain a delta log** → client replays inserts/deletes by `pf_logid`.
- **Undo:** `DELETE FROM forecast WHERE pf_logid = $1` against DuckDB-WASM,
then refresh. Strictly faster than the current full reload.
| Aspect | Impact |
|---|---|
| Initial load | Smaller payload + fast WASM ingest; likely 35× total |
| Interaction | Instant (local SQL) — same as today |
| Writes | New write-sync layer required (medium effort) |
| Browser memory ceiling | DuckDB-WASM handles 10M+ rows comfortably |
| Code change | Significant: new server route for Parquet, new client wiring, write-sync code |
| New runtime deps | DuckDB on server (Node-API or shell), `@duckdb/duckdb-wasm` on client |
**Right answer if:** dataset will grow past ~1M rows but you still want
local interaction speed, *and* you're willing to write the write-sync layer.
---
### C. Server-side DuckDB as a virtual server (no client load)
DuckDB lives on the Node server. Browser uses a `VirtualServerHandler`
implementation that proxies Perspective's view requests (`tableMakeView`,
`viewGetData`, `viewGetMinMax`, `tableSchema`) to a `/perspective` endpoint.
Server runs SQL against DuckDB which queries pg directly via
`postgres_scanner`, or against a Parquet copy.
- **Initial transfer:** essentially zero. Schema + first viewport only.
- **Interaction:** every drag/filter/group-by is a network round trip.
50200ms typical. Imperceptible for most operations; noticeable on
rapid drag interactions.
- **Writes:** simplest. Operations write to pg as today. DuckDB queries
pg live (via `postgres_scanner`) so it always sees current state. No
client-side state to sync.
- **Undo:** same as writes — server state is the source of truth.
| Aspect | Impact |
|---|---|
| Initial load | <1s regardless of dataset size |
| Interaction | 50200ms round trip per interaction |
| Writes | Simple — single source of truth on server |
| Browser memory ceiling | Irrelevant — data never enters the browser |
| Code change | Significant: custom `VirtualServerHandler` that talks to a new `/perspective` endpoint; server-side translator wiring |
| New runtime deps | DuckDB on server |
**Right answer if:** dataset will outgrow browser memory (10M+ rows) or
multiple users need to see real-time shared state. Pays an interaction
latency tax forever.
**Note:** Perspective-dev also ships a Python `virtual_servers/duckdb`.
If you're willing to add a Python sidecar, you may not need to write the
JS-side handler — just stand up the Python server. Significant infra
change for a Node-based app.
---
### D. Hybrid — DuckDB-WASM read, pg write, server-pushed deltas
Same browser stack as B, but writes flow differently. After a forecast
operation, the server pushes back an Arrow batch of new rows (or a list of
`pf_logid`s to delete for undo). The client applies it to DuckDB-WASM via
SQL and refreshes the Perspective view. No re-export of Parquet on every
write.
This is essentially B with the write-sync layer specified. Splitting it out
because the write contract is the architectural decision worth deciding
explicitly:
- **Insert deltas:** server returns new rows as Arrow IPC, client does
`INSERT INTO forecast SELECT * FROM arrow_view`. Already trivial in
DuckDB-WASM.
- **Delete deltas:** server returns `{deleted_logid: N}`, client does
`DELETE FROM forecast WHERE pf_logid = N`.
- **Replace deltas (e.g., note edits):** if `pf_note` is joined at fetch
time (current state after `bf85f11`), edits are invisible until refetch.
Either accept that, or store note on the row and `UPDATE`.
This is the cleanest end state for a forecasting app: bulk read once,
incremental sync after.
---
## Comparison
| | Current | A: optimize | B/D: DuckDB-WASM | C: server DuckDB |
|---|---|---|---|---|
| Initial load (350k rows) | ~30s | ~510s | ~38s | <1s |
| Interaction latency | 0 | 0 | 0 | 50200ms |
| Write feedback | instant | instant | instant (after sync) | instant |
| Undo cost | full reload | full reload (or fix) | local DELETE | server-side |
| Browser memory ceiling | ~1M rows | ~1M rows | 10M+ rows | none |
| New deps | — | — | DuckDB (server + WASM) | DuckDB (server) |
| Code change | — | medium | significant | significant |
| Risk surface | low | low | medium (write sync) | medium (translator wiring) |
---
## Open questions to resolve before choosing
1. **Expected dataset size 12 months out.** If it stays at ~350k1M rows,
option A is enough. If it goes to 5M+, A is dead in the water.
2. **Parquet caching strategy if going B/D.** Re-export on every write is
wasteful; delta replay is more code. Pick one explicitly before
building.
3. **Multi-user scenarios.** If two users edit the same version
concurrently, options B/D need a mechanism for one user's writes to
appear in another's local DuckDB-WASM. Option C gets this for free.
4. **Python-or-Node decision for server-side DuckDB.** Perspective-dev's
Python virtual server might let you skip writing a translator entirely
— at the cost of a Python runtime alongside Node. Worth investigating
before committing to a JS-side custom handler.
5. **Should the spec move?** The spec mentions DuckDB only as a faster
bulk-encode path (option A-ish, server-side). Options B/C/D are
architectural shifts the spec doesn't contemplate. Whatever's chosen
should be written into `pf_spec.md` so the reasoning isn't lost again.
---
## Recommendation framing (not a decision)
- **If the immediate problem is "30s loads feel bad":** option A. It's the
smallest change with the highest perceived impact and doesn't paint you
into an architectural corner.
- **If you're already planning for data growth:** option D (DuckDB-WASM +
delta sync). It's the right end state for a single-user-per-version
forecasting tool with mid-to-large datasets.
- **If multi-user real-time becomes a goal:** option C. Pay the latency
tax once and have a cleaner data model.
A reasonable phased path: do A first (fast, low risk, ships value this
week), live with it while planning, then move to D when row counts demand
it. C is a different shape and probably not warranted unless multi-user
emerges as a requirement.

View File

@ -49,11 +49,7 @@ module.exports = function(pool) {
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
await client.query('COMMIT');
res.json({
message: 'Operation undone',
rows_deleted: del.rowCount,
pf_ids: del.rows.map(r => r.pf_id)
});
res.json({ message: 'Operation undone', rows_deleted: del.rowCount });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);

View File

@ -1,5 +1,5 @@
const express = require('express');
const { tableFromJSON, tableToIPC } = require('apache-arrow');
const { tableFromJSON, RecordBatchStreamWriter, RecordBatch } = require('apache-arrow');
const { applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc } = require('../lib/sql_generator');
const { fcTable } = require('../lib/utils');
@ -86,29 +86,23 @@ module.exports = function(pool) {
client = await pool.connect();
await client.query('BEGIN');
await client.query(`
DECLARE pf_cur CURSOR FOR
SELECT f.*, l.note AS pf_note, l.operation AS pf_op
FROM ${tbl} f
LEFT JOIN pf.log l ON l.id = f.pf_logid
`);
await client.query(`DECLARE pf_cur CURSOR FOR SELECT * FROM ${tbl}`);
// Accumulate rows from the cursor, then emit a single Arrow record batch.
// Per-batch tableFromJSON() builds independent dictionaries, which forces the
// writer to emit dictionary REPLACEMENT messages between batches — Perspective's
// WASM Arrow reader crashes on those (memory access out of bounds).
const allRows = [];
const writer = RecordBatchStreamWriter.throughNode();
writer.pipe(res);
let schema = null;
while (true) {
const { rows } = await client.query('FETCH 10000 FROM pf_cur');
if (!rows.length) break;
for (const r of rows) allRows.push(r);
const t = tableFromJSON(rows);
if (!schema) { schema = t.schema; writer.write(schema); }
for (const rb of t.batches) writer.write(new RecordBatch(schema, rb.data));
}
writer.end();
await client.query('COMMIT');
committed = true;
const buf = tableToIPC(tableFromJSON(allRows), 'stream');
res.setHeader('Content-Length', String(buf.byteLength));
res.end(Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength));
} catch (err) {
console.error(err);
if (!res.headersSent) res.status(err.status || 500).json({ error: err.message });
@ -123,23 +117,18 @@ module.exports = function(pool) {
// load baseline rows from source table — additive, no delete
router.post('/versions/:id/baseline', async (req, res) => {
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const { where_clause, date_offset, pf_user, note } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const filterClause = (where_clause || '').trim() || 'TRUE';
try {
const ctx = await getContext(parseInt(req.params.id), 'baseline');
if (!guardOpen(ctx.version, res)) return;
const paramsJson = JSON.stringify({
where_clause: filterClause,
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(paramsJson),
params: esc(JSON.stringify({ where_clause: filterClause, date_offset: dateOffset })),
filter_clause: filterClause,
date_offset: esc(dateOffset)
});
@ -152,82 +141,6 @@ module.exports = function(pool) {
}
});
// edit a baseline or reference segment in place — only allowed before any
// scale/recode/clone has been applied on this version, since those would
// have been calibrated against the old segment's totals.
router.put('/versions/:id/baseline/:logid', async (req, res) => {
const versionId = parseInt(req.params.id);
const logid = parseInt(req.params.logid);
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const client = await pool.connect();
try {
const logResult = await client.query(
`SELECT * FROM pf.log WHERE id = $1 AND version_id = $2`,
[logid, versionId]
);
if (logResult.rows.length === 0) {
return res.status(404).json({ error: 'Log entry not found' });
}
const oldLog = logResult.rows[0];
if (!['baseline', 'reference'].includes(oldLog.operation)) {
return res.status(400).json({ error: 'Only baseline or reference segments can be edited' });
}
const opsResult = await client.query(
`SELECT COUNT(*)::int AS n FROM pf.log
WHERE version_id = $1 AND operation IN ('scale', 'recode', 'clone')`,
[versionId]
);
if (opsResult.rows[0].n > 0) {
return res.status(409).json({
error: 'Cannot edit segments after forecast operations have been applied. Undo the operations first.'
});
}
const ctx = await getContext(versionId, oldLog.operation);
if (!guardOpen(ctx.version, res)) return;
const paramsJson = JSON.stringify({
where_clause: filterClause,
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
});
await client.query('BEGIN');
const delRows = await client.query(
`DELETE FROM ${ctx.table} WHERE pf_logid = $1 RETURNING pf_id`,
[logid]
);
await client.query(`DELETE FROM pf.log WHERE id = $1`, [logid]);
const insResult = await client.query(sql);
await client.query('COMMIT');
res.json({
rows_deleted: delRows.rowCount,
pf_ids: delRows.rows.map(r => r.pf_id),
rows_affected: insResult.rows[0]?.rows_affected ?? 0
});
} catch (err) {
try { await client.query('ROLLBACK'); } catch {}
console.error(err);
res.status(err.status || 500).json({ error: err.message });
} finally {
client.release();
}
});
// delete all baseline rows and log entries for a version
router.delete('/versions/:id/baseline', async (req, res) => {
const versionId = parseInt(req.params.id);
@ -245,11 +158,7 @@ module.exports = function(pool) {
[versionId]
);
await client.query('COMMIT');
res.json({
rows_deleted: delRows.rowCount,
log_entries_deleted: delLog.rowCount,
pf_ids: delRows.rows.map(r => r.pf_id)
});
res.json({ rows_deleted: delRows.rowCount, log_entries_deleted: delLog.rowCount });
} catch (err) {
await client.query('ROLLBACK');
throw err;
@ -264,25 +173,18 @@ module.exports = function(pool) {
// load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => {
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
const { where_clause, pf_user, note } = req.body;
const filterClause = (where_clause || '').trim() || 'TRUE';
try {
const ctx = await getContext(parseInt(req.params.id), 'reference');
if (!guardOpen(ctx.version, res)) return;
const paramsJson = JSON.stringify({
where_clause: filterClause,
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
params: esc(JSON.stringify({ where_clause: filterClause })),
filter_clause: filterClause
});
const result = await runSQL(sql);
@ -342,8 +244,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'scale' }));
res.json({ rows, rows_affected: rows.length });
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -378,8 +279,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'recode' }));
res.json({ rows, rows_affected: rows.length });
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -416,8 +316,7 @@ module.exports = function(pool) {
});
const result = await runSQL(sql);
const rows = result.rows.map(r => ({ ...r, pf_note: note || null, pf_op: 'clone' }));
res.json({ rows, rows_affected: rows.length });
res.json({ rows: result.rows, rows_affected: result.rows.length });
} catch (err) {
console.error(err);
res.status(err.status || 500).json({ error: err.message });
@ -429,36 +328,19 @@ module.exports = function(pool) {
const versionId = parseInt(req.params.id);
try {
const verResult = await pool.query(
`SELECT v.*, s.tname, s.id AS source_id FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
`SELECT v.*, s.tname FROM pf.version v JOIN pf.source s ON s.id = v.source_id WHERE v.id = $1`,
[versionId]
);
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
const { tname, source_id } = verResult.rows[0];
const table = fcTable(tname, versionId);
const colMeta = await pool.query(
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1 AND role IN ('value', 'units')`,
[source_id]
);
const valueCol = colMeta.rows.find(c => c.role === 'value')?.cname;
const unitsCol = colMeta.rows.find(c => c.role === 'units')?.cname;
const aggCols = [
`count(f.pf_id)::int AS row_count`,
valueCol ? `sum(f."${valueCol}")::float8 AS value_total` : `NULL::float8 AS value_total`,
unitsCol ? `sum(f."${unitsCol}")::float8 AS units_total` : `NULL::float8 AS units_total`
].join(', ');
const table = fcTable(verResult.rows[0].tname, versionId);
const result = await pool.query(`
SELECT l.*, ${aggCols},
$2::text AS value_col,
$3::text AS units_col
SELECT l.*, count(f.pf_id)::int AS row_count
FROM pf.log l
LEFT JOIN ${table} f ON f.pf_logid = l.id
WHERE l.version_id = $1
GROUP BY l.id
ORDER BY l.id DESC
`, [versionId, valueCol || null, unitsCol || null]);
`, [versionId]);
res.json(result.rows);
} catch (err) {
console.error(err);
@ -489,10 +371,7 @@ module.exports = function(pool) {
);
await client.query('DELETE FROM pf.log WHERE id = $1', [logId]);
await client.query('COMMIT');
res.json({
rows_deleted: deleted.rowCount,
pf_ids: deleted.rows.map(r => r.pf_id)
});
res.json({ rows_deleted: deleted.rowCount });
} catch (err) {
await client.query('ROLLBACK');
throw err;

View File

@ -222,24 +222,6 @@ 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 });
}
});
// deregister a source — does not drop existing forecast tables
router.delete('/sources/:id', async (req, res) => {
try {

View File

@ -12,8 +12,8 @@ module.exports = function(pool) {
t.table_name AS tname,
c.reltuples::bigint AS row_estimate
FROM information_schema.tables t
LEFT JOIN pg_namespace n ON n.nspname = t.table_schema
LEFT JOIN pg_class c ON c.relname = t.table_name AND c.relnamespace = n.oid
LEFT JOIN pg_class c ON c.relname = t.table_name
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema', 'pf')
ORDER BY t.table_schema, t.table_name
`);

View File

@ -1,17 +1,14 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { Pool, types } = require('pg');
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
// so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>.
types.setTypeParser(20, v => v === null ? null : Number(v));
types.setTypeParser(1700, v => v === null ? null : Number(v));
const { Pool } = require('pg');
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
app.use(express.static('public/app'));
app.get('/', (req, res) => res.sendFile(__dirname + '/public/app/index.html'));
const pool = new Pool({
host: process.env.DB_HOST,

View File

@ -4,20 +4,16 @@
CREATE SCHEMA IF NOT EXISTS pf;
CREATE TABLE IF NOT EXISTS pf.source (
id serial PRIMARY KEY,
schema text NOT NULL,
tname text NOT NULL,
label text,
status text NOT NULL DEFAULT 'active', -- active | archived
default_layout jsonb, -- Perspective view config used as the per-source default
created_at timestamptz NOT NULL DEFAULT now(),
created_by text,
id serial PRIMARY KEY,
schema text NOT NULL,
tname text NOT NULL,
label text,
status text NOT NULL DEFAULT 'active', -- active | archived
created_at timestamptz NOT NULL DEFAULT now(),
created_by text,
UNIQUE (schema, tname)
);
-- backfill column for existing installs
ALTER TABLE pf.source ADD COLUMN IF NOT EXISTS default_layout jsonb;
CREATE TABLE IF NOT EXISTS pf.col_meta (
id serial PRIMARY KEY,
source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE,

113
todo.md
View File

@ -1,113 +0,0 @@
- [ ] when you enter the forecast, be able to enter in a context so you dont have to open the whole thing (should show in status bar and be a filter for SQL and spi calls)
- [x] should be able to edit and revise forecase segments that constitute baseline or reference. if you edit, maybe a warning that your forecast values wont mean a lot, and have an option to delete them.
Notes: A baseline/reference segment is a `pf.log` row plus the
forecast rows it produced (joined by pf_logid). Editing has the
shape of a delete-then-replay: drop the rows by pf_logid, drop the
log entry, re-run the segment with the new params (offset, filter,
iter type), insert the new log entry. New endpoint:
`PUT /versions/:id/baseline/:logid` (and the same for reference).
UI: an Edit button on each segment in Baseline view, populating the
form with the original `params`.
Cascade warning: if any scale/recode/clone log entries exist *after*
this segment was added, those operations were calibrated against
the old totals and will no longer reconcile cleanly. Show a banner
like "3 forecast operations applied after this segment may be
invalidated. View / Delete / Continue." Probably want a CASCADE
option that deletes downstream forecast entries too, plus a plain
"edit only" option for the user who knows what they're doing.
Implementation order: API + cascade detection first (compare
pf.log.stamp ordering); UI second.
- [~] be able to copy an existing forecast and it's segments to adjust some parameters without having to start from scrath.
Notes: A version is the unit of copy. Need a `POST /versions/:id/copy`
endpoint that creates a new pf.version row with the same source/
col_meta, creates the new fc_<tname>_<id> table via the same DDL
path, and replays each pf.log entry's INSERT against the new table
(preserving stamp ordering). Each log entry gets re-inserted
pointing at the new version_id; the new pf_logid feeds the row
inserts. Notes/users come along.
UI: "Copy" button next to each version in Baseline. Copy modal
asks for a new name and optional description, then runs the API
call (likely 530s for a 350k-row version since every segment is
re-evaluated). Show progress.
Two design questions worth deciding up front:
- Copy as-of-now (re-fetch source data, so freshly-arrived rows
show up in baseline)? Or freeze (replay from existing forecast
rows, i.e. clone the forecast table directly)? Different
semantics, different SQL — pick one before building.
- Should the copy track its origin? A `parent_version_id` column
on pf.version makes "show me variants of FY2026 Plan" easy.
- [x] need the list of filters to have an and/or specification
Notes: Spec already covers this in `pf_spec.md:245``filters` is
an array of groups; conditions within a group are AND-ed, groups
OR-ed. Backend has `buildFilterClause` in
`lib/sql_generator.js:247` but it's not wired into the routes
(baseline currently takes raw `where_clause`). Wiring + UI is the
remaining work.
UI: each group is a card with a header ("Group 1", "Group 2 — OR"),
rows of `column / operator / values`, a `+ Add condition` link,
and a `+ Add OR group` button at the bottom. The Baseline view
already has a single-group filter builder; extend it to wrap the
current rows in a group container and allow adding more groups.
- [x] the filters should have the option to just write the WHERE clause SQL
Notes: Spec covers this too (`pf_spec.md:251`, `:454`) as the
`raw_where` admin-only escape hatch. The current baseline endpoint
*already* takes `where_clause` as a raw string — so the API is
effectively in "raw only" mode today; it's the structured side
that's missing. Two things to add:
- Once structured `filters` is wired in, gate `raw_where` behind
an admin check (`pf_user` in admin list — needs admin list
config) and reject 400 if both are sent.
- UI toggle: a "Switch to manual SQL" link in the Baseline filter
builder swaps the structured rows for a `<textarea>`; warning
banner: "Raw SQL is not validated. You are responsible for
correctness and security."
- [ ] load status bar is super jittery and the numbers wildly change
Notes: `setLoadProgress` fires per chunk in the body-stream reader
(Forecast.jsx:155161). On localhost or fast connections the
reader yields chunks in tight bursts and React re-renders the
overlay text on each — the visible bytes value flickers because
paints land out of order with respect to setState batching.
Fix: throttle to ~10 updates/sec. Either `if (now - lastUpdate >
100)` before `setLoadProgress`, or accumulate received bytes into
a ref and flush on `requestAnimationFrame`. Five-line change.
- [ ] default layout for the pivot should be sales_usd group by pending_rep, split by pf_iter
Notes: Default-layout logic lives in `initViewer` (Forecast.jsx
~line 240) and currently picks `group_by = first 2 dimensions`,
`split_by = date column`. `sales_usd` / `pending_rep` are
source-specific, so hardcoding them in the view would break for
any other source.
Two paths:
- **Quick**: hardcode for the current source. Cheap, but rots
the moment a second source comes along.
- **Right**: store a default-layout config on `pf.source` (e.g., a
JSON `default_view` column with `{ group_by, split_by, columns }`)
and let initViewer read it. Setup view gets a small "Default
pivot" editor — pick a value column, group_by columns, split_by
column.
Suggest the right version since the spec already implies
per-source customization in col_meta, and you're going to want
this for any future source you register. The col_meta path is
even tighter: extend col_meta with role flags like `default_value`,
`default_group`, `default_split` that initViewer reads.

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect } from 'react'
import Sidebar from './components/Sidebar.jsx'
import StatusBar from './components/StatusBar.jsx'
import Setup from './views/Setup.jsx'
@ -9,66 +9,20 @@ export default function App() {
const [view, setView] = useState(() => localStorage.getItem('pf_view') || 'forecast')
const [sidebarExpanded, setSidebarExpanded] = useState(() => localStorage.getItem('pf_sidebar') !== 'collapsed')
const [sources, setSources] = useState([])
const [sourceId, setSourceId] = useState(() => localStorage.getItem('pf_sourceId') || '')
const [versions, setVersions] = useState([])
const [versionId, setVersionId] = useState(() => localStorage.getItem('pf_versionId') || '')
useEffect(() => { localStorage.setItem('pf_view', view) }, [view])
useEffect(() => { localStorage.setItem('pf_sidebar', sidebarExpanded ? 'expanded' : 'collapsed') }, [sidebarExpanded])
useEffect(() => { localStorage.setItem('pf_sourceId', sourceId || '') }, [sourceId])
useEffect(() => { localStorage.setItem('pf_versionId', versionId || '') }, [versionId])
const refreshSources = useCallback(async () => {
const data = await fetch('/api/sources').then(r => r.json())
setSources(data)
return data
}, [])
const refreshVersions = useCallback(async (sid) => {
const id = sid ?? sourceId
if (!id) { setVersions([]); return [] }
const data = await fetch(`/api/sources/${id}/versions`).then(r => r.json())
setVersions(data)
return data
}, [sourceId])
useEffect(() => {
refreshSources().then(data => {
if (data.length === 0) { setSourceId(''); return }
if (!sourceId || !data.some(s => String(s.id) === String(sourceId))) {
setSourceId(String(data[0].id))
}
})
}, [])
useEffect(() => {
if (!sourceId) { setVersions([]); setVersionId(''); return }
refreshVersions(sourceId).then(data => {
if (data.length === 0) { setVersionId(''); return }
if (!versionId || !data.some(v => String(v.id) === String(versionId))) {
setVersionId(String(data[0].id))
}
})
}, [sourceId])
const ctx = {
sources, sourceId, setSourceId,
versions, versionId, setVersionId,
refreshSources, refreshVersions, setVersions,
}
return (
<div className="flex h-screen w-full text-sm overflow-hidden">
<Sidebar view={view} setView={setView} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="flex flex-col flex-1 overflow-hidden min-w-0">
<StatusBar view={view} {...ctx} />
<StatusBar />
<div className="flex-1 overflow-hidden">
{view === 'setup' && <Setup {...ctx} />}
{view === 'baseline' && <Baseline {...ctx} />}
{view === 'forecast' && <Forecast {...ctx} />}
{view === 'setup' && <Setup />}
{view === 'baseline' && <Baseline />}
{view === 'forecast' && <Forecast />}
</div>
</div>
</div>
)
}
}

View File

@ -1,46 +1,21 @@
import useTheme from '../theme.jsx'
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
export default function StatusBar() {
const { dark, setDark } = useTheme()
const showVersion = view === 'baseline' || view === 'forecast'
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
return (
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs">
<div className="bg-white border-b border-gray-200 px-4 h-8 flex items-center gap-4 shrink-0 text-xs">
<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 && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400">Version</span>
<select
value={versionId || ''}
onChange={e => setVersionId(e.target.value)}
disabled={versions.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
>
{versions.length === 0
? <option value=""> no versions </option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
{selectedVersion && (
<span className={`font-medium ${selectedVersion.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion.status}
</span>
)}
</>
)}
<span className="font-medium text-gray-700">sales_orders</span>
<span className="text-gray-200">|</span>
<span className="text-gray-400">Version</span>
<span className="font-medium text-gray-700">FY2026 Plan</span>
<span className="text-gray-200">|</span>
<span className="text-gray-400">Baseline</span>
<span className="font-medium text-gray-700">44,313 rows</span>
<span className="text-gray-200">|</span>
<span className="text-gray-400">Status</span>
<span className="text-green-600 font-medium">open</span>
<div className="ml-auto">
<button
onClick={() => setDark(d => !d)}
@ -60,4 +35,4 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
</div>
</div>
)
}
}

View File

@ -13,20 +13,17 @@
--accent-text: #1d4ed8;
}
/* Dark palette tuned to Perspective's "Pro Dark" theme:
bg #242526, tooltip #2a2c2f, gridline #3b3f46, inactive #61656e,
inactive border #4c505b, active #2770a9, legend #c5c9d0. */
.dark {
--bg-primary: #242526;
--bg-secondary: #2a2c2f;
--bg-tertiary: #3b3f46;
--text-primary: #ffffff;
--text-secondary: #c5c9d0;
--text-muted: #61656e;
--border-color: #4c505b;
--border-light: #3b3f46;
--accent-bg: rgba(39, 113, 170, 0.32);
--accent-text: #4778c2;
--bg-primary: #111827;
--bg-secondary: #1f2937;
--bg-tertiary: #374151;
--text-primary: #f9fafb;
--text-secondary: #e5e7eb;
--text-muted: #6b7280;
--border-color: #374151;
--border-light: #1f2937;
--accent-bg: #1e3a5f;
--accent-text: #60a5fa;
}
body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary); }
@ -50,29 +47,19 @@ body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary
.dark .text-blue-700 { color: var(--accent-text); }
.dark .border-blue-300 { border-color: var(--accent-text); }
.dark .hover\:bg-blue-50:hover { background-color: var(--accent-bg); }
/* Status accents — desaturated to sit on Pro Dark's neutral background */
.dark .bg-green-50 { background-color: #1a3d2c; }
.dark .text-green-600 { color: #6ee7b7; }
.dark .text-green-700 { color: #6ee7b7; }
.dark .text-green-400 { color: #6ee7b7; }
.dark .bg-yellow-50 { background-color: #3a2e14; }
.dark .text-yellow-700 { color: #f5c66f; }
.dark .bg-amber-50 { background-color: #3a2e14; }
.dark .border-amber-200 { border-color: #5a4a26; }
.dark .text-amber-700 { color: #f5c66f; }
.dark .text-amber-800 { color: #f5c66f; }
.dark .bg-purple-50 { background-color: #2a1f3d; }
.dark .text-purple-600 { color: #c4a8e8; }
.dark .text-purple-700 { color: #c4a8e8; }
.dark .bg-red-50 { background-color: #3d1f1f; }
.dark .text-red-600 { color: #ff9485; }
.dark .text-red-700 { color: #ff9485; }
.dark .bg-green-50 { background-color: #064e3b; }
.dark .text-green-600 { color: #34d399; }
.dark .text-green-700 { color: #34d399; }
.dark .text-green-400 { color: #34d399; }
.dark .bg-yellow-50 { background-color: #451a03; }
.dark .text-yellow-700 { color: #fbbf24; }
.dark .bg-purple-50 { background-color: #1e1b4b; }
.dark .text-purple-700 { color: #a78bfa; }
.dark .bg-red-50 { background-color: #450a0a; }
.dark .text-red-700 { color: #f87171; }
.dark .border-gray-100 { border-color: var(--border-light); }
.dark .border-gray-200 { border-color: var(--border-color); }
.dark .border-gray-300 { border-color: var(--border-color); }
.dark .border-blue-100 { border-color: var(--border-color); }
.dark .border-b { border-color: var(--border-color); }
.dark .border-t { border-color: var(--border-color); }
.dark .border-r { border-color: var(--border-color); }
@ -86,8 +73,8 @@ body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary
.dark .hover\:border-gray-300:hover { border-color: var(--border-color); }
.dark .hover\:border-gray-400:hover { border-color: var(--border-color); }
.dark .focus\:border-gray-300:focus { border-color: var(--border-color); }
.dark ::selection { background-color: var(--accent-bg); color: var(--text-primary); }
.dark input { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark select { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark textarea { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark ::selection { background-color: var(--accent-bg); color: var(--accent-text); }
.dark input { background-color: var(--bg-secondary); color: var(--text-primary); }
.dark select { background-color: var(--bg-secondary); color: var(--text-primary); }
.dark textarea { background-color: var(--bg-secondary); color: var(--text-primary); }
.dark .bg-transparent { background-color: transparent; }

View File

@ -3,48 +3,44 @@ import Timeline from '../components/Timeline.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(filters) {
if (!filters.length) return null
const parts = filters.map(f => {
const col = `"${f.col}"`
const op = f.op
if (op === 'IS NULL') return `${col} IS NULL`
if (op === 'IS NOT NULL') return `${col} IS NOT NULL`
if (op === 'BETWEEN') {
const [a, b] = f.values
return `${col} BETWEEN '${a}' AND '${b}'`
}
if (op === 'IN' || op === 'NOT IN') {
const vals = f.values.join("','")
return `${col} ${op} ('${vals}')`
}
return `${col} ${op} '${f.values[0]}'`
})
return parts.join(' AND ')
}
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 getDateRange(filters) {
for (const f of filters) {
if (f.op === 'BETWEEN' && f.values[0] && f.values[1]) {
return { from: f.values[0], to: f.values[1] }
}
if (f.op === '=' && f.values[0]) {
return { from: f.values[0], to: f.values[0] }
}
}
return null
}
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] }
}
}
const m2 = clause.match(/>= ?'(\d{4}-\d{2}-\d{2})'.+<= ?'(\d{4}-\d{2}-\d{2})'/)
if (m2) return { from: m2[1], to: m2[2] }
return null
}
@ -55,20 +51,15 @@ function parseOffset(offsetStr) {
return { yr, mo }
}
function emptyCondition(cols) {
function emptyFilter(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 }) {
export default function Baseline() {
const [sources, setSources] = useState([])
const [sourceId, setSourceId] = useState('')
const [versions, setVersions] = useState([])
const [versionId, setVersionId] = useState('')
const [filterCols, setFilterCols] = useState([])
const [log, setLog] = useState([])
@ -78,29 +69,36 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
const [newVerDesc, setNewVerDesc] = useState('')
const [creatingVer, setCreatingVer] = useState(false)
// segment form
// add segment form
const [segType, setSegType] = useState('baseline')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState([]) // [[cond,...], [cond,...]]
const [useRaw, setUseRaw] = useState(false)
const [rawSql, setRawSql] = useState('')
const [filters, setFilters] = useState([])
const [offsetYr, setOffsetYr] = useState(0)
const [offsetMo, setOffsetMo] = useState(0)
const [segNote, setSegNote] = 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(() => {
fetch('/api/sources').then(r => r.json()).then(data => {
setSources(data)
if (data.length > 0) setSourceId(String(data[0].id))
})
}, [])
useEffect(() => {
if (!sourceId) return
fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => {
setVersions(data)
if (data.length > 0) setVersionId(String(data[0].id))
else setVersionId('')
})
fetch(`/api/sources/${sourceId}/cols`).then(r => r.json()).then(cols => {
const fc = cols.filter(c => c.role === 'date' || c.role === 'filter')
setFilterCols(fc)
setFilters(fc.length > 0 ? [emptyGroup(fc)] : [])
setFilters(fc.length > 0 ? [emptyFilter(fc)] : [])
})
}, [sourceId])
@ -112,7 +110,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
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)))
})
}
@ -127,7 +124,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
await refreshVersions(sourceId)
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
setVersions(updated)
setVersionId(String(data.id))
setShowNewVersion(false)
setNewVerName('')
@ -140,37 +138,59 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
}
}
function addFilter() {
setFilters(f => [...f, emptyFilter(filterCols)])
}
function removeFilter(i) {
setFilters(f => f.filter((_, idx) => idx !== i))
}
function updateFilter(i, field, value) {
setFilters(f => f.map((row, idx) => {
if (idx !== i) return row
if (field === 'op') {
const needsTwo = value === 'BETWEEN'
const needsOne = ['=', '!='].includes(value)
const needsMany = ['IN', 'NOT IN'].includes(value)
const needsNone = ['IS NULL', 'IS NOT NULL'].includes(value)
return { ...row, op: value, values: needsNone ? [] : needsTwo ? ['', ''] : needsMany ? [''] : [''] }
}
return { ...row, [field]: value }
}))
}
function updateFilterValue(i, vi, value) {
setFilters(f => f.map((row, idx) => {
if (idx !== i) return row
const vals = [...row.values]
vals[vi] = value
return { ...row, values: vals }
}))
}
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 clause = buildFilterClause(filters)
if (!clause) { flash('Add at least one filter', 'error'); return }
const isRef = segType === 'reference'
const offsetStr = [offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days'
const offsetStr = isRef ? '0 days' : ([offsetYr > 0 ? `${offsetYr} year` : '', offsetMo > 0 ? `${offsetMo} month` : ''].filter(Boolean).join(' ') || '0 days')
const endpoint = isRef ? 'reference' : 'baseline'
const body = {
where_clause: clause,
pf_user: 'admin',
note: description || segNote,
date_offset: offsetStr,
...(useRaw ? { raw_where: clause } : { filters }),
}
const body = isRef
? { where_clause: clause, pf_user: 'admin', note: description || segNote }
: { where_clause: clause, date_offset: offsetStr, pf_user: 'admin', note: description || segNote }
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,
const res = await fetch(`/api/versions/${versionId}/${endpoint}`, {
method: 'POST',
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`)
flash(`Loaded ${data.rows_affected ?? data.row_count ?? ''} rows`)
loadLog()
cancelEdit()
setDescription(''); setSegNote(''); setOffsetYr(0); setOffsetMo(0)
setFilters(filterCols.length > 0 ? [emptyFilter(filterCols)] : [])
} catch (err) {
flash(err.message, 'error')
} finally {
@ -178,52 +198,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
}
}
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 || '')
setDescription('')
const off = parseOffset(params.date_offset)
setOffsetYr(off.yr)
setOffsetMo(off.mo)
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)
setShowAddForm(false)
setDescription('')
setSegNote('')
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()
@ -246,7 +220,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
})
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
await refreshVersions(sourceId)
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
setVersions(updated)
flash('Version closed')
}
@ -254,7 +229,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
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)
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
setVersions(updated)
flash('Version reopened')
}
@ -263,7 +239,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
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)
const updated = await fetch(`/api/sources/${sourceId}/versions`).then(r => r.json())
setVersions(updated)
setVersionId(updated.length > 0 ? String(updated[0].id) : '')
flash('Version deleted')
}
@ -273,25 +250,47 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
setTimeout(() => setMsg(null), 3000)
}
const dateRange = getDateRange(filters)
const selectedVersion = versions.find(v => String(v.id) === versionId)
return (
<div className="h-full overflow-y-auto bg-gray-50">
<div className="p-4 flex flex-col gap-4 max-w-4xl">
{/* Flash */}
{msg && (
<div className={`px-3 py-2 text-xs rounded font-medium ${msg.type === 'error' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
{msg.text}
</div>
)}
{/* Version actions */}
{/* Source + Version bar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Source</span>
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Version</span>
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={versions.length === 0}>
{versions.length === 0
? <option value=""> no versions </option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)
}
</select>
{versionId && (
<span className={`text-xs font-medium ${selectedVersion?.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion?.status}
</span>
)}
</div>
<button onClick={() => setShowNewVersion(v => !v)} className="text-xs text-blue-600 hover:text-blue-700 border border-blue-200 px-2 py-1 rounded">
+ New version
</button>
{versionId && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 ml-2">
{selectedVersion?.status === 'open'
? <button onClick={closeVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Close</button>
: <button onClick={reopenVersion} className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-1 rounded">Reopen</button>
@ -301,6 +300,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
)}
</div>
{/* New version inline form */}
{showNewVersion && (
<div className="bg-white border border-gray-200 rounded p-3 flex flex-col gap-3">
<div className="flex items-end gap-3">
@ -337,8 +337,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<th className="px-3 py-1.5 font-medium w-6"></th>
<th className="px-3 py-1.5 font-medium">#</th>
<th className="px-3 py-1.5 font-medium">note</th>
<th className="px-3 py-1.5 font-medium text-right">rows</th>
<th className="px-3 py-1.5 font-medium text-right">{log[0]?.value_col || 'value'}</th>
<th className="px-3 py-1.5 font-medium">by</th>
<th className="px-3 py-1.5 font-medium">when</th>
<th className="px-3 py-1.5 font-medium"></th>
@ -346,31 +344,23 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</thead>
<tbody>
{log.length === 0 && (
<tr><td colSpan={8} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
)}
{!showAddForm && !editingLogId && (
<tr className="border-t border-gray-100">
<td colSpan={8} className="p-0">
<button
onClick={() => setShowAddForm(true)}
className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium"
>
+ Add segment
</button>
</td>
</tr>
<tr><td colSpan={6} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
)}
{log.map((entry, i) => {
const isOpen = expandedId === entry.id
const view = segmentValuesFor(entry, filterCols)
const params = entry.params || {}
const dr = parseDateRangeFromClause(params.where_clause)
const off = parseOffset(params.date_offset)
return (
<>
<tr
key={entry.id}
onClick={() => 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' : ''}`}
className={`border-t border-gray-50 cursor-pointer hover:bg-gray-50 ${isOpen ? 'bg-blue-50' : ''}`}
>
<td className="px-3 py-2 text-gray-400 w-6"><span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span></td>
<td className="px-3 py-2 text-gray-400 w-6">
<span className="text-gray-300 text-xs">{isOpen ? '▾' : '▸'}</span>
</td>
<td className="px-3 py-2 text-gray-400">{log.length - i}</td>
<td className="px-3 py-2">
<span className={`inline-block mr-2 px-1.5 py-0.5 rounded text-xs font-medium ${entry.operation === 'reference' ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600'}`}>
@ -378,26 +368,29 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</span>
{entry.note || <span className="text-gray-300"></span>}
</td>
<td className="px-3 py-2 text-right text-gray-700 font-mono">
{entry.row_count != null ? entry.row_count.toLocaleString() : '—'}
</td>
<td className="px-3 py-2 text-right text-gray-700 font-mono">
{entry.value_total != null ? entry.value_total.toLocaleString(undefined, { maximumFractionDigits: 0 }) : '—'}
</td>
<td className="px-3 py-2 text-gray-500">{entry.pf_user}</td>
<td className="px-3 py-2 text-gray-400">{new Date(entry.stamp).toLocaleDateString()}</td>
<td className="px-3 py-2 text-right">
{!hasForecastOps && (
<button onClick={e => { e.stopPropagation(); startEdit(entry) }} className="text-gray-400 hover:text-blue-600 text-xs mr-3">Edit</button>
)}
<button onClick={e => { e.stopPropagation(); undoSegment(entry.id) }} className="text-gray-400 hover:text-red-500 text-xs">Undo</button>
</td>
</tr>
{isOpen && (
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
<td colSpan={6} className="px-2 py-2">
<div className="bg-white border border-gray-200 rounded">
<SegmentForm mode="view" {...view} filterCols={filterCols} />
<td colSpan={6} className="px-4 py-3">
<div className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<span className="text-xs text-gray-400 w-24 shrink-0 pt-0.5">WHERE</span>
<code className="text-xs font-mono text-gray-700 bg-white border border-gray-200 rounded px-2 py-1 break-all">{params.where_clause || '—'}</code>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 w-24 shrink-0">offset</span>
<span className="text-xs font-mono text-gray-600">{params.date_offset || '0 days'}</span>
</div>
{dr && (
<div className="mt-1">
<Timeline dateFrom={dr.from} dateTo={dr.to} offsetYr={off.yr} offsetMo={off.mo} type={entry.operation === 'reference' ? 'reference' : 'baseline'} />
</div>
)}
</div>
</td>
</tr>
@ -409,42 +402,115 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</table>
</div>
{/* Add / Edit Segment */}
{(showAddForm || editingLogId) && (
<div id="add-segment" className={`bg-white border rounded ${editingLogId ? 'border-amber-300' : 'border-gray-200'}`}>
<div className={`px-3 py-2 border-b text-xs font-medium uppercase tracking-wide flex items-center justify-between ${editingLogId ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-white border-gray-100 text-gray-500'}`}>
<span>{(() => {
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}`
})()}</span>
<button onClick={cancelEdit} className="text-gray-400 hover:text-gray-600 normal-case font-normal">
{editingLogId ? 'Cancel edit' : 'Close'}
{/* Add Segment */}
<div className="bg-white border border-gray-200 rounded">
<div className="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">
Add Segment
</div>
<div className="p-4 flex flex-col gap-4">
{/* Type toggle */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
{['baseline', 'reference'].map(t => (
<button
key={t}
onClick={() => { setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
>{t}</button>
))}
</div>
{segType === 'reference' && (
<span className="text-xs text-gray-400">dates land verbatim no offset applied</span>
)}
</div>
{/* Description */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
</div>
{/* Filters */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-gray-500 w-28 shrink-0">Filters</label>
<button onClick={addFilter} className="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
</div>
<div className="flex flex-col gap-1.5 ml-28">
{filters.map((f, i) => {
const isDateCol = filterCols.find(c => c.cname === f.col)?.role === 'date'
return (
<div key={i} className="flex items-center gap-2 flex-wrap">
<select value={f.col} onChange={e => updateFilter(i, 'col', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
{filterCols.map(c => <option key={c.cname} value={c.cname}>{c.cname}</option>)}
</select>
<select value={f.op} onChange={e => updateFilter(i, 'op', e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
</select>
{f.op === 'BETWEEN' && <>
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="from" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
<span className="text-gray-400 text-xs">and</span>
<input type={isDateCol ? 'date' : 'text'} value={f.values[1]} onChange={e => updateFilterValue(i, 1, e.target.value)} placeholder="to" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
</>}
{(f.op === '=' || f.op === '!=') && (
<input type={isDateCol ? 'date' : 'text'} value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="value" className="border border-gray-200 rounded px-2 py-1 text-xs w-36 font-mono bg-white" />
)}
{(f.op === 'IN' || f.op === 'NOT IN') && (
<input value={f.values[0]} onChange={e => updateFilterValue(i, 0, e.target.value)} placeholder="val1, val2, …" className="border border-gray-200 rounded px-2 py-1 text-xs w-48 font-mono bg-white" />
)}
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-red-400 text-xs"></button>
</div>
)
})}
{filters.length === 0 && (
<span className="text-xs text-gray-300 italic">No filters at least one is required</span>
)}
</div>
</div>
{/* Date offset — baseline only */}
{segType === 'baseline' && (
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
<div className="flex items-center gap-2">
<input type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
<span className="text-xs text-gray-500">yr</span>
<input type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
<span className="text-xs text-gray-500">mo</span>
</div>
</div>
)}
{/* Timeline */}
{dateRange && (
<div className="ml-28">
<div className="bg-gray-50 border border-gray-200 rounded p-3">
<Timeline
dateFrom={dateRange.from}
dateTo={dateRange.to}
offsetYr={segType === 'baseline' ? offsetYr : 0}
offsetMo={segType === 'baseline' ? offsetMo : 0}
type={segType}
/>
</div>
</div>
)}
{/* Note + submit */}
<div className="flex items-end gap-3">
<div className="flex flex-col gap-1 flex-1 max-w-xs">
<label className="text-xs text-gray-500">Note</label>
<input value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className="border border-gray-200 rounded px-2 py-1.5 text-sm" />
</div>
<button onClick={loadSegment} disabled={submitting || filters.length === 0} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
{submitting ? 'Loading…' : `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`}
</button>
</div>
<SegmentForm
mode="edit"
segType={segType} setSegType={setSegType}
filters={filters} setFilters={setFilters}
useRaw={useRaw} setUseRaw={setUseRaw}
rawSql={rawSql} setRawSql={setRawSql}
description={description} setDescription={setDescription}
segNote={segNote} setSegNote={setSegNote}
offsetYr={offsetYr} setOffsetYr={setOffsetYr}
offsetMo={offsetMo} setOffsetMo={setOffsetMo}
filterCols={filterCols}
onSubmit={loadSegment}
submitting={submitting}
editing={!!editingLogId}
/>
</div>
)}
</div>
</>}
@ -452,248 +518,3 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
</div>
)
}
// derive view-mode props for a saved segment
function segmentValuesFor(entry, filterCols) {
const params = entry.params || {}
const off = parseOffset(params.date_offset)
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 || '',
description: '',
segNote: entry.note || '',
offsetYr: off.yr,
offsetMo: off.mo,
}
}
function SegmentForm({
mode, // 'view' | 'edit'
segType, setSegType,
filters, setFilters,
useRaw, setUseRaw,
rawSql, setRawSql,
description, setDescription,
segNote, setSegNote,
offsetYr, setOffsetYr,
offsetMo, setOffsetMo,
filterCols,
onSubmit,
submitting,
editing,
}) {
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 (
<div className="p-4 flex flex-col gap-4">
{/* Type */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Type</label>
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
{['baseline', 'reference'].map(t => (
<button
key={t}
disabled={disabled}
onClick={() => { if (disabled) return; setSegType(t); if (t === 'reference') { setOffsetYr(0); setOffsetMo(0) } }}
className={`px-3 py-1 capitalize ${segType === t ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'} disabled:opacity-60 disabled:cursor-default`}
>{t}</button>
))}
</div>
</div>
{/* Description (edit only) */}
{mode === 'edit' && (
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} placeholder="e.g. FY25 actuals +1yr" className="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
</div>
)}
{/* Filters */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-gray-500 w-28 shrink-0">Filters</label>
{!disabled && (
<button onClick={toggleRaw} className="text-blue-600 hover:text-blue-700 text-xs font-medium">
{useRaw ? '← Back to filters' : 'Switch to manual SQL →'}
</button>
)}
</div>
{!useRaw && (
<div className="flex flex-col gap-3 ml-28">
{filters.map((group, gi) => (
<div key={gi} className="border border-gray-200 rounded">
<div className="flex items-center justify-between px-2 py-1 bg-gray-50 border-b border-gray-100">
<span className="text-xs text-gray-500">
{gi === 0 ? 'Group 1' : `Group ${gi + 1} — OR`}
</span>
{!disabled && (
<div className="flex items-center gap-3">
<button onClick={() => addCondition(gi)} className="text-blue-600 hover:text-blue-700 text-xs">+ AND condition</button>
{filters.length > 1 && (
<button onClick={() => removeGroup(gi)} className="text-gray-300 hover:text-red-400 text-xs">remove group</button>
)}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 p-2">
{group.map((c, ci) => {
const isDateCol = filterCols.find(fc => fc.cname === c.col)?.role === 'date'
return (
<div key={ci} className="flex items-center gap-2 flex-wrap">
<select disabled={disabled} value={c.col} onChange={e => updateCondition(gi, ci, 'col', e.target.value)} className={baseInp}>
{filterCols.map(fc => <option key={fc.cname} value={fc.cname}>{fc.cname}</option>)}
</select>
<select disabled={disabled} value={c.op} onChange={e => updateCondition(gi, ci, 'op', e.target.value)} className={baseInp}>
{OPERATORS.map(op => <option key={op} value={op}>{op}</option>)}
</select>
{c.op === 'BETWEEN' && <>
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="from" className={`${baseInp} w-36 font-mono`} />
<span className="text-gray-400 text-xs">and</span>
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[1] || ''} onChange={e => updateConditionValue(gi, ci, 1, e.target.value)} placeholder="to" className={`${baseInp} w-36 font-mono`} />
</>}
{(c.op === '=' || c.op === '!=') && (
<input disabled={disabled} type={isDateCol ? 'date' : 'text'} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="value" className={`${baseInp} w-36 font-mono`} />
)}
{(c.op === 'IN' || c.op === 'NOT IN') && (
<input disabled={disabled} value={c.values[0] || ''} onChange={e => updateConditionValue(gi, ci, 0, e.target.value)} placeholder="val1, val2, …" className={`${baseInp} w-48 font-mono`} />
)}
{!disabled && group.length > 1 && (
<button onClick={() => removeCondition(gi, ci)} className="text-gray-300 hover:text-red-400 text-xs"></button>
)}
</div>
)
})}
</div>
</div>
))}
{!disabled && (
<button onClick={addGroup} className="self-start text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add OR group</button>
)}
{filters.length === 0 && (
<span className="text-xs text-gray-300 italic">No filters at least one is required</span>
)}
</div>
)}
{useRaw && (
<div className="ml-28">
<textarea
disabled={disabled}
value={rawSql}
onChange={e => setRawSql(e.target.value)}
placeholder="WHERE clause body (no WHERE keyword) — e.g. (status = 'OPEN' AND order_date BETWEEN '2024-01-01' AND '2024-12-31') OR id IS NULL"
rows={3}
className={`w-full border border-gray-200 rounded px-2 py-1.5 text-xs font-mono bg-white disabled:bg-gray-50 disabled:text-gray-500`}
/>
{!disabled && (
<p className="text-xs text-amber-700 mt-1">Raw SQL is not validated. You are responsible for correctness and security.</p>
)}
</div>
)}
{/* Compiled SQL preview (only meaningful when not in raw mode) */}
{!useRaw && (
<div className="ml-28 mt-2 flex items-start gap-2">
<span className="text-xs text-gray-400 w-12 shrink-0 pt-0.5">SQL</span>
<code className="text-xs font-mono text-gray-700 bg-gray-50 border border-gray-200 rounded px-2 py-1 flex-1 break-all">
{compiled || <span className="text-gray-300 not-italic"> add a condition </span>}
</code>
</div>
)}
</div>
{/* Date offset */}
<div className="flex items-center gap-3">
<label className="text-xs text-gray-500 w-28 shrink-0">Date offset</label>
<div className="flex items-center gap-2">
<input disabled={disabled} type="number" value={offsetYr} min={0} onChange={e => setOffsetYr(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
<span className="text-xs text-gray-500">yr</span>
<input disabled={disabled} type="number" value={offsetMo} min={0} max={11} onChange={e => setOffsetMo(parseInt(e.target.value) || 0)} className={`${baseInp} text-sm w-16 text-center`} />
<span className="text-xs text-gray-500">mo</span>
</div>
</div>
{/* Timeline */}
{dateRange && (
<div className="ml-28">
<div className="bg-gray-50 border border-gray-200 rounded p-3">
<Timeline
dateFrom={dateRange.from}
dateTo={dateRange.to}
offsetYr={offsetYr}
offsetMo={offsetMo}
type={segType}
/>
</div>
</div>
)}
{/* Note + submit */}
<div className="flex items-end gap-3">
<div className="flex flex-col gap-1 flex-1 max-w-xs">
<label className="text-xs text-gray-500">Note</label>
<input disabled={disabled} value={segNote} onChange={e => setSegNote(e.target.value)} placeholder="optional" className={`${baseInp} text-sm py-1.5`} />
</div>
{mode === 'edit' && (
<button onClick={onSubmit} disabled={submitting || (!useRaw && filters.length === 0)} className="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 disabled:opacity-50 shrink-0">
{submitting
? (editing ? 'Saving…' : 'Loading…')
: (editing
? `Save ${segType === 'reference' ? 'Reference' : 'Segment'}`
: `Load ${segType === 'reference' ? 'Reference' : 'Segment'}`)}
</button>
)}
</div>
</div>
)
}

View File

@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx'
const LAYOUT_KEY = (vid) => `pf_layout_v${vid}` // last-used layout (auto restore)
const LAYOUTS_KEY = (vid) => `pf_layouts_v${vid}` // named layout list
@ -29,11 +28,13 @@ function cleanLayout(cfg, validCols) {
return c
}
export default function Forecast({ sources = [], sourceId, versionId, refreshSources }) {
const { dark } = useTheme()
export default function Forecast() {
const [sources, setSources] = useState([])
const [sourceId, setSourceId] = useState('')
const [versions, setVersions] = useState([])
const [versionId, setVersionId] = useState('')
const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false)
const [loadProgress, setLoadProgress] = useState(null) // { received, total }
const [msg, setMsg] = useState(null)
// layouts
@ -49,7 +50,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const [scaleMode, setScaleMode] = useState('target') // 'target' | 'delta'
const [scaleValue, setScaleValue] = useState('')
const [scaleUnits, setScaleUnits] = useState('')
const [scalePrice, setScalePrice] = useState('')
const [scalePct, setScalePct] = useState(false)
const [scaleNote, setScaleNote] = useState('')
const [recodeSet, setRecodeSet] = useState({})
@ -72,7 +72,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const tableRef = useRef(null)
const colMetaRef = useRef([])
const expandDepthRef = useRef(null)
const initIdRef = useRef(0)
function onDragStart(e) {
e.preventDefault()
@ -84,25 +83,33 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
window.addEventListener('mouseup', onUp)
}
useEffect(() => {
fetch('/api/sources').then(r => r.json()).then(data => {
setSources(data)
if (data.length > 0) setSourceId(String(data[0].id))
})
}, [])
useEffect(() => {
if (!sourceId) return
fetch(`/api/sources/${sourceId}/versions`).then(r => r.json()).then(data => {
setVersions(data)
setVersionId(data.length > 0 ? String(data[0].id) : '')
})
}, [sourceId])
useEffect(() => {
if (!versionId || !sourceId) return
loadLayouts(versionId)
initViewer(versionId, sourceId)
}, [versionId, sourceId])
useEffect(() => {
if (viewerRef.current) {
viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
}
}, [dark, versionId])
useEffect(() => {
const blank = Object.fromEntries(Object.keys(slice).map(k => [k, '']))
setRecodeSet(blank)
setCloneSet(blank)
setScaleValue('')
setScaleUnits('')
setScalePrice('')
if (Object.keys(slice).length > 0) fetchCurrentTotals(slice)
else setCurrentTotals(null)
}, [slice])
@ -123,22 +130,8 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const view = await tableRef.current.view({ filter: filters })
const rows = await view.to_json()
await view.delete()
const buckets = new Map()
for (const r of rows) {
const k = r.pf_iter || '?'
const t = buckets.get(k) || { value: 0, units: 0 }
if (valueCol) t.value += parseFloat(r[valueCol]) || 0
if (unitsCol) t.units += parseFloat(r[unitsCol]) || 0
buckets.set(k, t)
}
const ITER_ORDER = ['baseline', 'scale', 'recode', 'clone']
const byIter = Array.from(buckets, ([iter, t]) => ({ iter, ...t }))
.sort((a, b) => {
const ai = ITER_ORDER.indexOf(a.iter), bi = ITER_ORDER.indexOf(b.iter)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
})
const total = byIter.reduce((s, r) => ({ value: s.value + (r.value || 0), units: s.units + (r.units || 0) }), { value: 0, units: 0 })
setCurrentTotals({ byIter, total, valueCol, unitsCol })
const sum = (col) => col ? rows.reduce((s, r) => s + (parseFloat(r[col]) || 0), 0) : null
setCurrentTotals({ value: sum(valueCol), units: sum(unitsCol), valueCol, unitsCol })
} catch {
setCurrentTotals(null)
}
@ -153,10 +146,8 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
async function initViewer(vid, sid) {
const viewer = viewerRef.current
if (!viewer) return
const myId = ++initIdRef.current
setLoading(true)
setLargeDataset(false)
setLoadProgress(null)
setSlice({})
expandDepthRef.current = null
try {
@ -165,22 +156,8 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
fetch(`/api/versions/${vid}/data`).then(async r => {
if (!r.ok) { const { error } = await r.json(); throw new Error(error || 'Failed to load data') }
const rowCount = parseInt(r.headers.get('X-Row-Count') || '0')
const total = parseInt(r.headers.get('Content-Length') || '0') || null
const reader = r.body.getReader()
const chunks = []
let received = 0
setLoadProgress({ received: 0, total })
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.byteLength
setLoadProgress({ received, total })
}
const merged = new Uint8Array(received)
let pos = 0
for (const c of chunks) { merged.set(c, pos); pos += c.byteLength }
return { buffer: merged.buffer, rowCount }
const buffer = await r.arrayBuffer()
return { buffer, rowCount }
}),
fetch(`/api/sources/${sid}/cols`).then(r => r.json()),
])
@ -195,78 +172,34 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
if (rowCount >= 500000) setLargeDataset(true)
if (myId !== initIdRef.current) return
if (!workerRef.current) workerRef.current = await perspective.worker()
const worker = workerRef.current
if (tableRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
}
const opts = { name: tableName, index: 'pf_id' }
const makeTable = async () => rowCount > 0 ? worker.table(buffer, opts) : worker.table([], opts)
try {
tableRef.current = await makeTable()
} catch (err) {
if (/already exists/i.test(String(err?.message || err))) {
try {
const existing = await worker.open_table(tableName)
if (existing) await existing.delete()
} catch {}
tableRef.current = await makeTable()
} else {
throw err
}
}
if (myId !== initIdRef.current) {
try { await tableRef.current.delete() } catch {}
tableRef.current = null
return
}
if (workerRef.current) { try { workerRef.current.terminate() } catch {} }
const worker = await perspective.worker()
workerRef.current = worker
tableRef.current = rowCount > 0
? await worker.table(buffer, { name: tableName })
: await worker.table([], { name: tableName })
await viewer.load(worker)
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
// restore last-used layout or build default
const saved = localStorage.getItem(LAYOUT_KEY(vid))
if (saved) {
const cfg = cleanLayout(JSON.parse(saved), validCols)
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
await viewer.restore(cfg)
const plugin = await viewer.getPlugin()
await plugin.restore({ edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) })
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else {
const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout
let cfg
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
cfg = cleanLayout(sourceDefault, validCols)
cfg.table = tableName
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
} else {
const valueCol = meta.find(c => c.role === 'value')?.cname
cfg = {
table: tableName,
settings: false,
group_by: ['pf_iter'],
columns: valueCol ? [valueCol] : [],
plugin_config: { edit_mode: 'SELECT_REGION' }
}
}
const dims = meta.filter(c => c.role === 'dimension').map(c => c.cname)
const dateCol = meta.find(c => c.role === 'date')?.cname
const cfg = { table: tableName, settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } }
if (dims.length) cfg.group_by = dims.slice(0, 2)
if (dateCol) cfg.split_by = [dateCol]
await viewer.restore(cfg)
const plugin = await viewer.getPlugin()
await plugin.restore({ edit_mode: 'SELECT_REGION' })
}
// auto-persist viewer state (formatting, columns, etc.) to the last-used cache
if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate)
viewer._pspUpdate = async () => {
try {
const cfg = await captureConfig()
if (cfg) await persistLayout(vid, cfg)
} catch {}
}
viewer.addEventListener('perspective-config-update', viewer._pspUpdate)
// click slice via event filters (Perspective encodes row position as [col,'==',val] triples)
if (viewer._pspClick) viewer.removeEventListener('perspective-click', viewer._pspClick)
viewer._pspClick = async (e) => {
@ -304,8 +237,9 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
async function captureConfig() {
const viewer = viewerRef.current
if (!viewer) return null
const cfg = await viewer.save()
return { ...cfg, expand_depth: expandDepthRef.current }
const plugin = await viewer.getPlugin()
const [cfg, pluginCfg] = await Promise.all([viewer.save(), plugin.save()])
return { ...cfg, plugin_config: pluginCfg, expand_depth: expandDepthRef.current }
}
async function persistLayout(vid, cfg) {
@ -328,22 +262,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
flash('Saved')
}
async function saveAsSourceDefault() {
const cfg = await captureConfig()
if (!cfg) return
const { table, expand_depth, ...rest } = cfg
try {
const res = await fetch(`/api/sources/${sourceId}/default-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rest)
})
if (!res.ok) { const data = await res.json(); flash(data.error || 'Failed', 'error'); return }
if (refreshSources) await refreshSources()
flash('Saved as source default')
} catch (err) { flash(err.message, 'error') }
}
async function handleSaveOver() {
const layout = layouts.find(l => l.id === activeLayoutId)
if (!layout) return
@ -359,10 +277,13 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
async function applyLayout(layout) {
const viewer = viewerRef.current
if (!viewer) return
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
const validCols = new Set(tableRef.current ? Object.keys(await (await tableRef.current.view()).schema()) : [])
const cfg = cleanLayout(layout.config, validCols)
cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) }
await viewer.restore(cfg)
if (cfg.plugin_config) {
const plugin = await viewer.getPlugin()
await plugin.restore(cfg.plugin_config)
}
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
setActiveLayoutId(layout.id)
await persistLayout(versionId, cfg)
@ -390,16 +311,10 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
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) {
// hold units constant; new value = price × current units
vi = (parseFloat(scalePrice) * curUnits) - curValue
}
if (scaleValue !== '' && curValue != null)
vi = parseFloat(scaleValue) - curValue
if (scaleUnits !== '' && curUnits != null)
ui = parseFloat(scaleUnits) - curUnits
if (scaleValue !== '' && currentTotals?.value != null)
vi = parseFloat(scaleValue) - currentTotals.value
if (scaleUnits !== '' && currentTotals?.units != null)
ui = parseFloat(scaleUnits) - currentTotals.units
} else {
if (scaleValue !== '') vi = scalePct ? parseFloat(scaleValue) : parseFloat(scaleValue)
if (scaleUnits !== '') ui = scalePct ? parseFloat(scaleUnits) : parseFloat(scaleUnits)
@ -424,7 +339,7 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
if (!res.ok) { flash(data.error, 'error'); return }
if (data.rows?.length && tableRef.current) await tableRef.current.update(data.rows)
flash(`${op}: ${data.rows_affected ?? data.rows?.length ?? ''} rows`)
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScalePrice(''); setScaleNote(''); fetchCurrentTotals(slice) }
if (op === 'scale') { setScaleValue(''); setScaleUnits(''); setScaleNote(''); fetchCurrentTotals(slice) }
if (op === 'recode') { setRecodeNote('') }
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
} catch (err) { flash(err.message, 'error') }
@ -455,10 +370,8 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const data = await res.json()
if (!res.ok) { flash(data.error, 'error'); return }
setLogEntries(prev => prev.filter(e => e.id !== logId))
if (data.pf_ids?.length && tableRef.current) {
await tableRef.current.remove(data.pf_ids)
}
flash(`Undone — ${data.rows_deleted} rows removed`)
initViewer(versionId, sourceId)
} catch (err) {
flash(err.message, 'error')
} finally {
@ -480,12 +393,36 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
}
}
const selectedVersion = versions.find(v => String(v.id) === versionId)
const dimCols = colMetaRef.current.filter(c => c.role === 'dimension')
const hasSlice = Object.keys(slice).length > 0
return (
<div className="h-full flex flex-col">
{/* Source / version bar */}
<div className="px-3 py-2 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Source</span>
<select value={sourceId} onChange={e => setSourceId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
{sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Version</span>
<select value={versionId} onChange={e => setVersionId(e.target.value)} className="border border-gray-200 rounded px-2 py-1 text-sm bg-white" disabled={!versions.length}>
{versions.length === 0
? <option value=""> no versions </option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
{selectedVersion && (
<span className={`text-xs font-medium ${selectedVersion.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion.status}
</span>
)}
</div>
</div>
{/* Toolbar */}
<div className="px-3 py-1.5 border-b border-gray-200 bg-white flex items-center gap-3 shrink-0 flex-wrap text-xs">
@ -516,11 +453,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
<button onClick={() => setShowSaveAs(true)} className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5">
Save as
</button>
<button onClick={saveAsSourceDefault} disabled={!sourceId}
className="border border-dashed border-gray-200 text-gray-400 hover:text-gray-600 rounded px-2 py-0.5 disabled:opacity-40"
title="Use this layout as the default for new versions of this source">
Set source default
</button>
{activeLayoutId !== null && (
<button onClick={resetLayout} className="text-gray-300 hover:text-red-400">Reset</button>
)}
@ -645,24 +577,8 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
{/* Perspective viewer */}
<div className="relative flex-1 min-w-0">
{loading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-50 z-10 gap-2">
<div className="absolute inset-0 flex items-center justify-center bg-gray-50 z-10">
<span className="text-sm text-gray-400">Loading</span>
{loadProgress && (
<>
<span className="text-xs text-gray-400 font-mono">
{fmtBytes(loadProgress.received)}
{loadProgress.total ? ` / ${fmtBytes(loadProgress.total)}` : ''}
</span>
{loadProgress.total > 0 && (
<div className="w-48 h-1 bg-gray-200 rounded overflow-hidden">
<div
className="h-full bg-blue-400 transition-all"
style={{ width: `${Math.min(100, (loadProgress.received / loadProgress.total) * 100)}%` }}
/>
</div>
)}
</>
)}
</div>
)}
{!loading && largeDataset && (
@ -694,40 +610,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
)}
</div>
{hasSlice && currentTotals?.byIter?.length > 0 && (
<div className="px-3 py-2 border-b border-gray-100">
<div className="font-medium text-gray-400 uppercase tracking-wide mb-1.5" style={{fontSize:'10px'}}>Current</div>
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400" style={{fontSize:'10px'}}>
<th className="text-left font-normal pb-1"></th>
{currentTotals.valueCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.valueCol}</th>}
{currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">{currentTotals.unitsCol}</th>}
{currentTotals.valueCol && currentTotals.unitsCol && <th className="text-right font-normal pb-1 pl-1">price</th>}
</tr>
</thead>
<tbody>
{currentTotals.byIter.map(r => (
<tr key={r.iter}>
<td className="text-gray-500 capitalize pr-1">{r.iter}</td>
{currentTotals.valueCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.value)}</td>}
{currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units)}</td>}
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono text-gray-700 pl-1">{fmtNum(r.units ? r.value / r.units : null, 4)}</td>}
</tr>
))}
{currentTotals.byIter.length > 1 && (
<tr className="border-t border-gray-100">
<td className="text-gray-600 font-medium pt-1 pr-1">total</td>
{currentTotals.valueCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.value)}</td>}
{currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units)}</td>}
{currentTotals.valueCol && currentTotals.unitsCol && <td className="text-right font-mono font-semibold text-gray-800 pt-1 pl-1">{fmtNum(currentTotals.total.units ? currentTotals.total.value / currentTotals.total.units : null, 4)}</td>}
</tr>
)}
</tbody>
</table>
</div>
)}
{hasSlice && (
<>
<div className="flex border-b border-gray-100">
@ -744,38 +626,39 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden">
{[['target','= Target'],['delta','Δ Increment']].map(([m, label]) => (
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits(''); setScalePrice('') }}
<button key={m} onClick={() => { setScaleMode(m); setScaleValue(''); setScaleUnits('') }}
className={`flex-1 py-1 text-xs ${scaleMode === m ? 'bg-blue-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
{/* Value row */}
{currentTotals?.valueCol && (
<Row label={currentTotals.valueCol}>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-gray-400">
<span>{currentTotals.valueCol}</span>
<span className="font-mono">{currentTotals.value?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
</div>
<input type="number" step="any" value={scaleValue}
onChange={e => setScaleValue(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</Row>
</div>
)}
{/* Units row */}
{currentTotals?.unitsCol && (
<Row label={currentTotals.unitsCol}>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-gray-400">
<span>{currentTotals.unitsCol}</span>
<span className="font-mono">{currentTotals.units?.toLocaleString(undefined, {maximumFractionDigits:2})}</span>
</div>
<input type="number" step="any" value={scaleUnits}
onChange={e => setScaleUnits(e.target.value)}
placeholder={scaleMode === 'target' ? 'target total' : '+ / amount'}
className={inp} />
</Row>
)}
{scaleMode === 'target' && currentTotals?.valueCol && currentTotals?.unitsCol && (
<Row label="price">
<input type="number" step="any" value={scalePrice}
onChange={e => setScalePrice(e.target.value)}
placeholder="target price (holds units)"
className={inp} />
</Row>
</div>
)}
{scaleMode === 'delta' && (
@ -823,17 +706,6 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
const inp = 'border border-gray-200 rounded px-2 py-1 text-xs flex-1 bg-white min-w-0'
function fmtBytes(n) {
if (n < 1024) return `${n} B`
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1048576).toFixed(1)} MB`
}
function fmtNum(n, decimals = 2) {
if (n == null || !isFinite(n)) return '—'
return n.toLocaleString(undefined, { maximumFractionDigits: decimals })
}
function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}

View File

@ -11,7 +11,7 @@ const ROLE_STYLE = {
ignore: 'bg-gray-100 text-gray-400',
}
export default function Setup({ refreshSources }) {
export default function Setup() {
const [tables, setTables] = useState([])
const [sources, setSources] = useState([])
const [selectedSource, setSelectedSource] = useState(null)
@ -40,7 +40,6 @@ export default function Setup({ refreshSources }) {
})
})
}).catch(console.error)
refreshSources?.()
}
function selectSource(source) {