Compare commits
No commits in common. "master" and "light-dark-mode" have entirely different histories.
master
...
light-dark
110
CLAUDE.md
110
CLAUDE.md
@ -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.
|
||||
@ -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
1
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
344
pf.sh
@ -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
|
||||
@ -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 ~10–15s 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 | ~3–5× 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 (~1–2M 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 ~10–30 MB
|
||||
for 350k rows (vs. ~80–150 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 3–5× 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.
|
||||
50–200ms 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 | 50–200ms 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 | ~5–10s | ~3–8s | <1s |
|
||||
| Interaction latency | 0 | 0 | 0 | 50–200ms |
|
||||
| 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 ~350k–1M 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.
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
`);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
113
todo.md
@ -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 5–30s 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:155–161). 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.
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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' })
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user