pf_app/public/mockup.html
Paul Trowbridge dd993e989c Add UX mockup and update spec with navigation direction
- HTML mockup with collapsible side nav, 3-step flow (Setup/Baseline/Forecast)
- Canvas-based timeline preview in baseline segment form
- Table peek modal, status bar, help popovers
- Spec updated: 3-step mental model, AG Grid replacement note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:56:29 -04:00

889 lines
48 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pivot Forecast — Mockup</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.help-popup { display:none; position:absolute; z-index:50; }
.help-popup.open { display:block; }
#sidebar { transition: width 150ms ease; }
#sidebar.expanded { width: 200px; }
#sidebar.collapsed { width: 48px; }
#sidebar .nav-label { transition: opacity 100ms ease; }
#sidebar.collapsed .nav-label { opacity:0; pointer-events:none; width:0; overflow:hidden; }
#sidebar.expanded .nav-label { opacity:1; }
#sidebar .app-title { transition: opacity 100ms ease; }
#sidebar.collapsed .app-title { opacity:0; pointer-events:none; width:0; overflow:hidden; }
</style>
</head>
<body class="bg-gray-100 text-sm text-gray-800 font-sans">
<!-- Help popups (global, positioned by JS) -->
<div id="help-overlay" class="fixed inset-0 z-40 hidden" onclick="closeHelp()"></div>
<div id="help-box" class="help-popup w-72 bg-gray-900 text-gray-100 text-xs rounded-lg shadow-xl p-4 leading-relaxed"></div>
<div class="flex h-screen">
<!-- Side Nav -->
<div id="sidebar" class="expanded bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden">
<!-- Logo / toggle -->
<div class="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0">
<button onclick="toggleSidebar()" class="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0" title="Toggle sidebar">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="14" height="1.5" rx="0.75" fill="currentColor"/><rect x="1" y="7.25" width="14" height="1.5" rx="0.75" fill="currentColor"/><rect x="1" y="11.5" width="14" height="1.5" rx="0.75" fill="currentColor"/></svg>
</button>
<span class="app-title text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap">Pivot Forecast</span>
</div>
<!-- Nav items -->
<nav class="flex flex-col gap-0.5 p-2 flex-1">
<button onclick="show('setup')" id="tab-setup" class="nav-item flex items-center gap-3 px-2 py-2 rounded text-gray-500 hover:bg-gray-100 hover:text-gray-800 text-left w-full" title="Setup">
<!-- sliders / config icon -->
<svg class="shrink-0" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<line x1="3" y1="5" x2="17" y2="5"/><circle cx="7" cy="5" r="1.8" fill="white" stroke="currentColor" stroke-width="1.6"/>
<line x1="3" y1="10" x2="17" y2="10"/><circle cx="13" cy="10" r="1.8" fill="white" stroke="currentColor" stroke-width="1.6"/>
<line x1="3" y1="15" x2="17" y2="15"/><circle cx="8" cy="15" r="1.8" fill="white" stroke="currentColor" stroke-width="1.6"/>
</svg>
<span class="nav-label text-sm whitespace-nowrap">Setup</span>
</button>
<button onclick="show('baseline')" id="tab-baseline" class="nav-item flex items-center gap-3 px-2 py-2 rounded text-gray-500 hover:bg-gray-100 hover:text-gray-800 text-left w-full" title="Baseline">
<!-- layers / stack icon -->
<svg class="shrink-0" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<polygon points="10,2 18,7 10,12 2,7"/>
<polyline points="2,12 10,17 18,12"/>
</svg>
<span class="nav-label text-sm whitespace-nowrap">Baseline</span>
</button>
<button onclick="show('forecast')" id="tab-forecast" class="nav-item flex items-center gap-3 px-2 py-2 rounded text-gray-500 hover:bg-gray-100 hover:text-gray-800 text-left w-full" title="Forecast">
<!-- trending up / chart icon -->
<svg class="shrink-0" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<polyline points="2,15 7,9 11,12 18,4"/>
<polyline points="14,4 18,4 18,8"/>
</svg>
<span class="nav-label text-sm whitespace-nowrap">Forecast</span>
</button>
</nav>
</div>
<div class="flex-1 overflow-hidden flex flex-col">
<!-- Status bar -->
<div class="bg-white border-b border-gray-100 px-4 h-8 flex items-center gap-4 shrink-0 text-xs">
<span class="text-gray-400">Source</span>
<span class="font-medium text-gray-700">sales_orders</span>
<span class="text-gray-200">|</span>
<span class="text-gray-400">Version</span>
<span class="font-medium text-gray-700">FY2026 Plan</span>
<span class="text-gray-200">|</span>
<span class="text-gray-400">Baseline</span>
<span class="font-medium text-gray-700">44,313 rows</span>
<span class="text-gray-200">|</span>
<span class="text-gray-400">Status</span>
<span class="text-green-600 font-medium">open</span>
</div>
<!-- ① SETUP -->
<div id="view-setup" class="hidden h-full flex gap-0 overflow-hidden">
<!-- All Tables -->
<div class="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0">
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide">All Tables</div>
<div class="overflow-y-auto flex-1">
<table class="w-full text-xs">
<thead class="sticky top-0 bg-gray-50">
<tr class="text-left text-gray-400 border-b border-gray-100">
<th class="px-3 py-1.5 font-medium">schema</th>
<th class="px-3 py-1.5 font-medium">table</th>
<th class="px-3 py-1.5 font-medium text-right">rows</th>
</tr>
</thead>
<tbody>
<tr onclick="peekTable('sales_orders')" class="border-t border-gray-50 hover:bg-blue-50 cursor-pointer bg-blue-50">
<td class="px-3 py-1.5 text-gray-400">public</td>
<td class="px-3 py-1.5 font-medium text-blue-600">sales_orders</td>
<td class="px-3 py-1.5 text-right text-gray-500">48,291</td>
</tr>
<tr onclick="peekTable('invoices')" class="border-t border-gray-50 hover:bg-blue-50 cursor-pointer">
<td class="px-3 py-1.5 text-gray-400">public</td>
<td class="px-3 py-1.5">invoices</td>
<td class="px-3 py-1.5 text-right text-gray-500">12,004</td>
</tr>
<tr onclick="peekTable('products')" class="border-t border-gray-50 hover:bg-blue-50 cursor-pointer">
<td class="px-3 py-1.5 text-gray-400">public</td>
<td class="px-3 py-1.5">products</td>
<td class="px-3 py-1.5 text-right text-gray-500">891</td>
</tr>
<tr onclick="peekTable('summary_mv')" class="border-t border-gray-50 hover:bg-blue-50 cursor-pointer">
<td class="px-3 py-1.5 text-gray-400">rpt</td>
<td class="px-3 py-1.5">summary_mv</td>
<td class="px-3 py-1.5 text-right text-gray-500">3,442</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex-1 flex flex-col gap-4 overflow-hidden min-w-0 p-4">
<div class="bg-white border border-gray-200 rounded shrink-0">
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
<span>Registered Sources</span>
<button class="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Register table</button>
</div>
<table class="w-full text-xs">
<tbody>
<tr class="border-t border-gray-50 hover:bg-gray-50 cursor-pointer bg-blue-50">
<td class="px-3 py-2 font-medium text-blue-600">sales_orders</td>
<td class="px-3 py-2 text-green-600 font-medium">✓ SQL ready</td>
<td class="px-3 py-2 text-gray-400 text-right">public</td>
</tr>
<tr class="border-t border-gray-50 hover:bg-gray-50 cursor-pointer">
<td class="px-3 py-2 font-medium">invoices</td>
<td class="px-3 py-2 text-green-600 font-medium">✓ SQL ready</td>
<td class="px-3 py-2 text-gray-400 text-right">public</td>
</tr>
</tbody>
</table>
</div>
<div class="bg-white border border-gray-200 rounded flex flex-col flex-1 overflow-hidden">
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between shrink-0">
<span>Col Meta — <span class="text-gray-700">sales_orders</span></span>
<button class="bg-blue-600 text-white text-xs px-3 py-1 rounded hover:bg-blue-700">Generate SQL</button>
</div>
<div class="overflow-y-auto flex-1">
<table class="w-full text-xs">
<thead class="sticky top-0 bg-gray-50">
<tr class="text-left text-gray-400 border-b border-gray-100">
<th class="px-3 py-1.5 font-medium">column</th>
<th class="px-3 py-1.5 font-medium">role</th>
<th class="px-3 py-1.5 font-medium text-center">key</th>
<th class="px-3 py-1.5 font-medium">label</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">customer</td><td class="px-3 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">dimension</span></td><td class="px-3 py-1.5 text-center text-green-500"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">channel</td><td class="px-3 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">dimension</span></td><td class="px-3 py-1.5 text-center text-green-500"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">part</td><td class="px-3 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">dimension</span></td><td class="px-3 py-1.5 text-center text-gray-300"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">geography</td><td class="px-3 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">dimension</span></td><td class="px-3 py-1.5 text-center text-gray-300"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">order_date</td><td class="px-3 py-1.5"><span class="bg-purple-50 text-purple-700 px-1.5 py-0.5 rounded">date</span></td><td class="px-3 py-1.5 text-center text-gray-300"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">ship_date</td><td class="px-3 py-1.5"><span class="bg-yellow-50 text-yellow-700 px-1.5 py-0.5 rounded">filter</span></td><td class="px-3 py-1.5 text-center text-gray-300"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">status</td><td class="px-3 py-1.5"><span class="bg-yellow-50 text-yellow-700 px-1.5 py-0.5 rounded">filter</span></td><td class="px-3 py-1.5 text-center text-gray-300"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">units</td><td class="px-3 py-1.5"><span class="bg-green-50 text-green-700 px-1.5 py-0.5 rounded">units</span></td><td class="px-3 py-1.5 text-center text-gray-300"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">revenue</td><td class="px-3 py-1.5"><span class="bg-green-50 text-green-700 px-1.5 py-0.5 rounded">value</span></td><td class="px-3 py-1.5 text-center text-gray-300"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
<tr class="border-t border-gray-50"><td class="px-3 py-1.5 font-mono text-gray-700">internal_id</td><td class="px-3 py-1.5"><span class="bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">ignore</span></td><td class="px-3 py-1.5 text-center text-gray-300"></td><td class="px-3 py-1.5 text-gray-400"></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ② BASELINE -->
<div id="view-baseline" class="hidden h-full overflow-y-auto">
<div class="p-4 flex flex-col gap-4">
<!-- Version bar -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Source</span>
<select class="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
<option>sales_orders</option><option>invoices</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Version</span>
<select class="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
<option>FY2026 Plan</option><option>FY2026 Conservative</option>
</select>
</div>
<button class="text-blue-600 hover:text-blue-700 text-xs font-medium border border-blue-200 px-2 py-1 rounded">+ New version</button>
</div>
<!-- Segments loaded -->
<div class="bg-white border border-gray-200 rounded">
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
<span>Segments loaded</span>
<button class="text-red-500 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
</div>
<table class="w-full text-xs">
<thead class="bg-gray-50">
<tr class="text-left text-gray-400 border-b border-gray-100">
<th class="px-3 py-1.5 font-medium">#</th>
<th class="px-3 py-1.5 font-medium">description</th>
<th class="px-3 py-1.5 font-medium text-right">rows</th>
<th class="px-3 py-1.5 font-medium">by</th>
<th class="px-3 py-1.5 font-medium">date</th>
<th class="px-3 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-50">
<td class="px-3 py-2 text-gray-400">1</td>
<td class="px-3 py-2">FY25 actuals +1yr</td>
<td class="px-3 py-2 text-right font-mono">41,204</td>
<td class="px-3 py-2 text-gray-500">paul</td>
<td class="px-3 py-2 text-gray-400">Apr 24</td>
<td class="px-3 py-2 text-right"><button class="text-gray-400 hover:text-red-500 text-xs">Undo</button></td>
</tr>
<tr class="border-t border-gray-50">
<td class="px-3 py-2 text-gray-400">2</td>
<td class="px-3 py-2">Open orders</td>
<td class="px-3 py-2 text-right font-mono">3,109</td>
<td class="px-3 py-2 text-gray-500">paul</td>
<td class="px-3 py-2 text-gray-400">Apr 24</td>
<td class="px-3 py-2 text-right"><button class="text-gray-400 hover:text-red-500 text-xs">Undo</button></td>
</tr>
</tbody>
<tfoot>
<tr class="border-t border-gray-100 bg-gray-50">
<td colspan="2" class="px-3 py-1.5 text-gray-500 text-xs">Total baseline rows</td>
<td class="px-3 py-1.5 text-right font-mono font-medium">44,313</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
<!-- Add Segment -->
<div class="bg-white border border-gray-200 rounded">
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
<span>Add Segment</span>
<button onclick="showHelp(this,'segment')" class="text-gray-300 hover:text-gray-500 text-xs normal-case font-normal">? Help</button>
</div>
<div class="p-4 flex flex-col gap-4">
<div class="flex items-center gap-3">
<label class="text-xs text-gray-500 w-28">Description</label>
<input type="text" placeholder="e.g. FY25 actuals shifted +1yr" class="border border-gray-200 rounded px-2 py-1.5 text-sm flex-1 max-w-sm" />
</div>
<!-- Description -->
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">Description</label>
<input type="text" placeholder="e.g. FY25 actuals shifted +1yr" class="border border-gray-200 rounded px-2 py-1.5 text-sm bg-white w-full max-w-sm" />
</div>
<!-- Filters -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs text-gray-500">Filters</label>
<div class="flex items-center gap-2">
<button onclick="showHelp(this,'filters')" class="text-gray-300 hover:text-gray-500 text-xs">?</button>
<button class="text-blue-600 hover:text-blue-700 text-xs font-medium">+ Add filter</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<select class="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
<option>order_date</option><option>ship_date</option><option>status</option>
</select>
<select class="border border-gray-200 rounded px-2 py-1 text-xs bg-white">
<option>BETWEEN</option><option>=</option><option>IN</option><option>NOT IN</option>
</select>
<input id="date-from" type="text" value="2025-01-01" oninput="drawTimeline()" class="border border-gray-200 rounded px-2 py-1 text-xs w-24 font-mono bg-white" />
<span class="text-gray-400 text-xs">and</span>
<input id="date-to" type="text" value="2025-12-31" oninput="drawTimeline()" class="border border-gray-200 rounded px-2 py-1 text-xs w-24 font-mono bg-white" />
<button class="text-gray-300 hover:text-red-400 text-xs"></button>
</div>
</div>
</div>
<!-- Date offset -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs text-gray-500">Date offset</label>
<button onclick="showHelp(this,'offset')" class="text-gray-300 hover:text-gray-500 text-xs">?</button>
</div>
<div class="flex items-center gap-2">
<input id="offset-yr" type="number" value="1" min="0" oninput="drawTimeline()" class="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
<span class="text-xs text-gray-500">yr</span>
<input id="offset-mo" type="number" value="0" min="0" max="11" oninput="drawTimeline()" class="border border-gray-200 rounded px-2 py-1 text-sm w-16 text-center bg-white" />
<span class="text-xs text-gray-500">mo</span>
</div>
</div>
<!-- Timeline -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-xs text-gray-500">Timeline preview</label>
<button onclick="showHelp(this,'timeline')" class="text-gray-300 hover:text-gray-500 text-xs">?</button>
</div>
<div class="bg-white border border-gray-200 rounded p-3">
<canvas id="timeline-canvas" height="90" style="width:100%;display:block;"></canvas>
</div>
</div>
<!-- Note + submit -->
<div class="flex items-end gap-3">
<div class="flex flex-col gap-1 flex-1 max-w-xs">
<label class="text-xs text-gray-500">Note</label>
<input type="text" placeholder="optional" class="border border-gray-200 rounded px-2 py-1.5 text-sm bg-white" />
</div>
<button class="bg-blue-600 text-white text-xs px-5 py-2 rounded hover:bg-blue-700 shrink-0">Load Segment</button>
</div>
</div>
<!-- Reference -->
<div class="bg-white border border-gray-200 rounded">
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
<span>Reference <span class="text-gray-300 font-normal normal-case">optional</span></span>
<button onclick="showHelp(this,'reference')" class="text-gray-300 hover:text-gray-500 text-xs normal-case font-normal">?</button>
</div>
<div class="p-4 flex items-center gap-3">
<label class="text-xs text-gray-500 w-28">Date range</label>
<input type="text" value="2024-01-01" class="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
<span class="text-xs text-gray-400">to</span>
<input type="text" value="2024-12-31" class="border border-gray-200 rounded px-2 py-1 text-xs w-28 font-mono" />
<input type="text" placeholder="note" class="border border-gray-200 rounded px-2 py-1 text-sm flex-1 max-w-xs" />
<button class="border border-gray-200 text-gray-600 text-xs px-4 py-1.5 rounded hover:bg-gray-50">Load Reference</button>
</div>
</div>
</div>
</div>
<!-- ③ FORECAST -->
<div id="view-forecast" class="hidden h-full flex flex-col overflow-hidden">
<div class="bg-white border-b border-gray-200 px-4 py-2 flex items-center gap-3 shrink-0">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">Version</span>
<select class="border border-gray-200 rounded px-2 py-1 text-sm bg-white">
<option>FY2026 Plan</option>
<option>FY2026 Conservative</option>
</select>
</div>
<div class="flex-1"></div>
<button class="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-3 py-1 rounded">Refresh</button>
<button class="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-3 py-1 rounded">Save layout</button>
<button class="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-3 py-1 rounded">Reset layout</button>
</div>
<div class="flex flex-1 overflow-hidden gap-0">
<div class="flex-1 overflow-hidden flex flex-col bg-white border-r border-gray-200">
<div class="flex-1 overflow-hidden relative">
<table class="w-full text-xs">
<thead class="sticky top-0 bg-gray-50 border-b border-gray-200">
<tr class="text-right text-gray-500">
<th class="px-3 py-2 text-left text-gray-600 font-medium">channel</th>
<th class="px-3 py-2 font-medium">Jan 2026</th>
<th class="px-3 py-2 font-medium">Feb 2026</th>
<th class="px-3 py-2 font-medium">Mar 2026</th>
<th class="px-3 py-2 font-medium">Apr 2026</th>
<th class="px-3 py-2 font-medium">May 2026</th>
<th class="px-3 py-2 font-medium">Jun 2026</th>
<th class="px-3 py-2 font-medium text-gray-800">Total</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer">
<td class="px-3 py-2 font-medium">DIR</td>
<td class="px-3 py-2 text-right font-mono">412,000</td>
<td class="px-3 py-2 text-right font-mono">388,000</td>
<td class="px-3 py-2 text-right font-mono">425,000</td>
<td class="px-3 py-2 text-right font-mono">401,000</td>
<td class="px-3 py-2 text-right font-mono">390,000</td>
<td class="px-3 py-2 text-right font-mono">410,000</td>
<td class="px-3 py-2 text-right font-mono font-medium">2,426,000</td>
</tr>
<tr class="border-t border-gray-100 hover:bg-blue-50 cursor-pointer bg-blue-50">
<td class="px-3 py-2 font-medium text-blue-700">WHS ◂</td>
<td class="px-3 py-2 text-right font-mono text-blue-700">290,000</td>
<td class="px-3 py-2 text-right font-mono text-blue-700">310,000</td>
<td class="px-3 py-2 text-right font-mono text-blue-700">298,000</td>
<td class="px-3 py-2 text-right font-mono text-blue-700">315,000</td>
<td class="px-3 py-2 text-right font-mono text-blue-700">305,000</td>
<td class="px-3 py-2 text-right font-mono text-blue-700">320,000</td>
<td class="px-3 py-2 text-right font-mono font-medium text-blue-700">1,838,000</td>
</tr>
<tr class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer">
<td class="px-3 py-2 font-medium">ECOM</td>
<td class="px-3 py-2 text-right font-mono">155,000</td>
<td class="px-3 py-2 text-right font-mono">162,000</td>
<td class="px-3 py-2 text-right font-mono">170,000</td>
<td class="px-3 py-2 text-right font-mono">158,000</td>
<td class="px-3 py-2 text-right font-mono">165,000</td>
<td class="px-3 py-2 text-right font-mono">175,000</td>
<td class="px-3 py-2 text-right font-mono font-medium">985,000</td>
</tr>
<tr class="border-t-2 border-gray-200 bg-gray-50">
<td class="px-3 py-2 font-medium text-gray-700">Total</td>
<td class="px-3 py-2 text-right font-mono font-medium">857,000</td>
<td class="px-3 py-2 text-right font-mono font-medium">860,000</td>
<td class="px-3 py-2 text-right font-mono font-medium">893,000</td>
<td class="px-3 py-2 text-right font-mono font-medium">874,000</td>
<td class="px-3 py-2 text-right font-mono font-medium">860,000</td>
<td class="px-3 py-2 text-right font-mono font-medium">905,000</td>
<td class="px-3 py-2 text-right font-mono font-medium">5,249,000</td>
</tr>
</tbody>
</table>
<div class="absolute bottom-2 right-3 text-xs text-gray-300 italic">Perspective viewer</div>
</div>
</div>
<div class="w-56 bg-white flex flex-col shrink-0">
<div class="px-3 py-2 border-b border-gray-100 text-xs font-medium text-gray-500 uppercase tracking-wide flex items-center justify-between">
<span>Operations</span>
<button onclick="showHelp(this,'operations')" class="text-gray-300 hover:text-gray-500 text-xs">?</button>
</div>
<div class="px-3 py-2 border-b border-gray-100">
<div class="text-xs text-gray-400 mb-1.5">Slice</div>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500">channel</span>
<span class="font-mono text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">WHS</span>
</div>
</div>
</div>
<div class="flex border-b border-gray-100 text-xs">
<button class="flex-1 py-1.5 text-center bg-blue-50 text-blue-700 font-medium border-b-2 border-blue-500">Scale</button>
<button class="flex-1 py-1.5 text-center text-gray-400 hover:text-gray-600">Recode</button>
<button class="flex-1 py-1.5 text-center text-gray-400 hover:text-gray-600">Clone</button>
</div>
<div class="px-3 py-3 flex flex-col gap-3 text-xs flex-1">
<div class="flex flex-col gap-1">
<label class="text-gray-400">Value increment</label>
<input type="text" placeholder="e.g. 50000" class="border border-gray-200 rounded px-2 py-1 text-sm font-mono" />
</div>
<div class="flex flex-col gap-1">
<label class="text-gray-400">Units increment</label>
<input type="text" placeholder="e.g. 500" class="border border-gray-200 rounded px-2 py-1 text-sm font-mono" />
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="pct" class="rounded" />
<label for="pct" class="text-gray-500">% of slice total</label>
</div>
<div class="flex flex-col gap-1">
<label class="text-gray-400">Note</label>
<input type="text" placeholder="optional" class="border border-gray-200 rounded px-2 py-1 text-sm" />
</div>
</div>
<div class="px-3 py-3 border-t border-gray-100">
<button class="w-full bg-blue-600 text-white text-xs py-1.5 rounded hover:bg-blue-700">Submit</button>
</div>
</div>
</div>
<div class="bg-white border-t border-gray-200 shrink-0">
<button onclick="toggleLog()" class="w-full px-4 py-2 text-left text-xs text-gray-500 hover:bg-gray-50 flex items-center gap-2">
<span id="log-arrow"></span>
<span>Change log (12 entries)</span>
</button>
<div id="log-panel" class="hidden overflow-auto max-h-40">
<table class="w-full text-xs">
<thead class="bg-gray-50 sticky top-0">
<tr class="text-left text-gray-400 border-b border-gray-100">
<th class="px-4 py-1.5 font-medium">id</th>
<th class="px-4 py-1.5 font-medium">operation</th>
<th class="px-4 py-1.5 font-medium">by</th>
<th class="px-4 py-1.5 font-medium">slice</th>
<th class="px-4 py-1.5 font-medium">note</th>
<th class="px-4 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-50"><td class="px-4 py-1.5 text-gray-400 font-mono">12</td><td class="px-4 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">scale</span></td><td class="px-4 py-1.5 text-gray-500">paul</td><td class="px-4 py-1.5 font-mono text-gray-600">channel=WHS geo=WEST</td><td class="px-4 py-1.5 text-gray-400">10% lift Q3</td><td class="px-4 py-1.5 text-right"><button class="text-gray-300 hover:text-red-500">Undo</button></td></tr>
<tr class="border-t border-gray-50"><td class="px-4 py-1.5 text-gray-400 font-mono">11</td><td class="px-4 py-1.5"><span class="bg-orange-50 text-orange-700 px-1.5 py-0.5 rounded">recode</span></td><td class="px-4 py-1.5 text-gray-500">paul</td><td class="px-4 py-1.5 font-mono text-gray-600">part=OLD-SKU-001</td><td class="px-4 py-1.5 text-gray-400">discontinued SKU</td><td class="px-4 py-1.5 text-right"><button class="text-gray-300 hover:text-red-500">Undo</button></td></tr>
<tr class="border-t border-gray-50"><td class="px-4 py-1.5 text-gray-400 font-mono">10</td><td class="px-4 py-1.5"><span class="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">scale</span></td><td class="px-4 py-1.5 text-gray-500">paul</td><td class="px-4 py-1.5 font-mono text-gray-600">channel=DIR</td><td class="px-4 py-1.5 text-gray-400"></td><td class="px-4 py-1.5 text-right"><button class="text-gray-300 hover:text-red-500">Undo</button></td></tr>
<tr class="border-t border-gray-50"><td class="px-4 py-1.5 text-gray-400 font-mono">9</td><td class="px-4 py-1.5"><span class="bg-green-50 text-green-700 px-1.5 py-0.5 rounded">clone</span></td><td class="px-4 py-1.5 text-gray-500">paul</td><td class="px-4 py-1.5 font-mono text-gray-600">customer=ACME</td><td class="px-4 py-1.5 text-gray-400">new account win</td><td class="px-4 py-1.5 text-right"><button class="text-gray-300 hover:text-red-500">Undo</button></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- flex-1 content area -->
</div><!-- app shell -->
<!-- Table peek modal -->
<div id="peek-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" onclick="closePeek()"></div>
<div class="relative bg-white rounded-lg shadow-2xl flex flex-col z-10" style="width:720px;max-width:90vw;max-height:80vh;">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
<div>
<span id="peek-title" class="text-sm font-semibold text-gray-800"></span>
<span id="peek-rowcount" class="ml-2 text-xs text-gray-400"></span>
</div>
<button onclick="closePeek()" class="text-gray-300 hover:text-gray-600 text-lg leading-none ml-4"></button>
</div>
<div id="peek-body" class="overflow-y-auto flex-1 text-xs"></div>
</div>
</div>
<script>
// ── Table peek ─────────────────────────────────────────────────
const peekData = {
sales_orders: {
schema: 'public', rows: 48291,
cols: [
{name:'customer', type:'text'},
{name:'channel', type:'text'},
{name:'part', type:'text'},
{name:'geography', type:'text'},
{name:'order_date', type:'date'},
{name:'ship_date', type:'date'},
{name:'status', type:'text'},
{name:'units', type:'numeric'},
{name:'revenue', type:'numeric'},
{name:'internal_id',type:'integer'},
],
sample: [
{customer:'ACME CORP', channel:'WHS', part:'SKU-001', geography:'WEST', order_date:'2025-03-14', ship_date:'2025-03-18', status:'SHIPPED', units:120, revenue:4800.00, internal_id:10041},
{customer:'GLOBEX INC', channel:'DIR', part:'SKU-004', geography:'EAST', order_date:'2025-03-15', ship_date:null, status:'OPEN', units:50, revenue:2250.00, internal_id:10042},
{customer:'INITECH', channel:'WHS', part:'SKU-002', geography:'CENT', order_date:'2025-03-15', ship_date:'2025-03-20', status:'SHIPPED', units:200, revenue:7600.00, internal_id:10043},
{customer:'UMBRELLA CO', channel:'ECOM',part:'SKU-007', geography:'WEST', order_date:'2025-03-16', ship_date:null, status:'PENDING', units:30, revenue:1350.00, internal_id:10044},
{customer:'ACME CORP', channel:'DIR', part:'SKU-001', geography:'EAST', order_date:'2025-03-16', ship_date:'2025-03-19', status:'SHIPPED', units:80, revenue:3200.00, internal_id:10045},
]
},
invoices: {
schema: 'public', rows: 12004,
cols: [
{name:'invoice_id', type:'integer'},
{name:'customer', type:'text'},
{name:'invoice_date', type:'date'},
{name:'due_date', type:'date'},
{name:'amount', type:'numeric'},
{name:'status', type:'text'},
],
sample: [
{invoice_id:5001, customer:'ACME CORP', invoice_date:'2025-02-01', due_date:'2025-03-01', amount:12400.00, status:'PAID'},
{invoice_id:5002, customer:'GLOBEX INC', invoice_date:'2025-02-03', due_date:'2025-03-03', amount:8750.00, status:'OPEN'},
{invoice_id:5003, customer:'INITECH', invoice_date:'2025-02-05', due_date:'2025-03-05', amount:31200.00, status:'PAID'},
{invoice_id:5004, customer:'UMBRELLA CO', invoice_date:'2025-02-08', due_date:'2025-03-08', amount:4100.00, status:'OVERDUE'},
{invoice_id:5005, customer:'ACME CORP', invoice_date:'2025-02-10', due_date:'2025-03-10', amount:9600.00, status:'OPEN'},
]
},
products: {
schema: 'public', rows: 891,
cols: [
{name:'sku', type:'text'},
{name:'name', type:'text'},
{name:'category', type:'text'},
{name:'price', type:'numeric'},
{name:'active', type:'boolean'},
],
sample: [
{sku:'SKU-001', name:'Widget A', category:'Widgets', price:40.00, active:true},
{sku:'SKU-002', name:'Widget B', category:'Widgets', price:38.00, active:true},
{sku:'SKU-004', name:'Gadget Pro', category:'Gadgets', price:45.00, active:true},
{sku:'SKU-007', name:'Doohickey X', category:'Other', price:45.00, active:true},
{sku:'SKU-009', name:'Old Widget', category:'Widgets', price:22.00, active:false},
]
},
summary_mv: {
schema: 'rpt', rows: 3442,
cols: [
{name:'period', type:'text'},
{name:'channel', type:'text'},
{name:'revenue', type:'numeric'},
{name:'units', type:'numeric'},
],
sample: [
{period:'2025-01', channel:'WHS', revenue:1240000, units:32100},
{period:'2025-01', channel:'DIR', revenue:980000, units:24400},
{period:'2025-01', channel:'ECOM', revenue:410000, units:10200},
{period:'2025-02', channel:'WHS', revenue:1190000, units:30800},
{period:'2025-02', channel:'DIR', revenue:1020000, units:25500},
]
}
}
function peekTable(name) {
const d = peekData[name]
if (!d) return
const cols = d.cols
const sample = d.sample
let html = '<div class="px-3 pt-3 pb-1 text-gray-400 uppercase tracking-wide font-medium text-xs">Columns</div>'
html += '<table class="w-full mb-3"><thead><tr class="text-left text-gray-400 border-b border-gray-100 bg-gray-50"><th class="px-3 py-1">name</th><th class="px-3 py-1">type</th></tr></thead><tbody>'
cols.forEach(c => {
html += `<tr class="border-t border-gray-50"><td class="px-3 py-1 font-mono text-gray-700">${c.name}</td><td class="px-3 py-1 text-gray-400">${c.type}</td></tr>`
})
html += '</tbody></table>'
html += '<div class="px-3 py-1 text-gray-400 uppercase tracking-wide font-medium text-xs border-t border-gray-100">Sample rows</div>'
html += '<div class="overflow-x-auto"><table class="text-xs" style="min-width:100%"><thead><tr class="text-left text-gray-400 bg-gray-50 border-b border-gray-100">'
cols.forEach(c => { html += `<th class="px-3 py-1 font-medium whitespace-nowrap">${c.name}</th>` })
html += '</tr></thead><tbody>'
sample.forEach(row => {
html += '<tr class="border-t border-gray-50">'
cols.forEach(c => {
const v = row[c.name]
html += `<td class="px-3 py-1 font-mono whitespace-nowrap ${v == null ? 'text-gray-300' : 'text-gray-600'}">${v == null ? 'null' : v}</td>`
})
html += '</tr>'
})
html += '</tbody></table></div>'
document.getElementById('peek-title').textContent = d.schema + '.' + name
document.getElementById('peek-rowcount').textContent = d.rows.toLocaleString() + ' rows'
document.getElementById('peek-body').innerHTML = html
document.getElementById('peek-modal').classList.remove('hidden')
}
function closePeek() {
document.getElementById('peek-modal').classList.add('hidden')
}
// ── Sidebar toggle ─────────────────────────────────────────────
function toggleSidebar() {
const sb = document.getElementById('sidebar')
const expanded = sb.classList.contains('expanded')
sb.classList.toggle('expanded', !expanded)
sb.classList.toggle('collapsed', expanded)
localStorage.setItem('sb', expanded ? 'collapsed' : 'expanded')
}
// ── Tab switching ──────────────────────────────────────────────
function show(view) {
['setup','baseline','forecast'].forEach(v => {
document.getElementById('view-' + v).classList.add('hidden')
const btn = document.getElementById('tab-' + v)
btn.classList.remove('bg-blue-50','text-blue-700')
btn.classList.add('text-gray-500')
})
document.getElementById('view-' + view).classList.remove('hidden')
const active = document.getElementById('tab-' + view)
active.classList.add('bg-blue-50','text-blue-700')
active.classList.remove('text-gray-500')
if (view === 'baseline') setTimeout(drawTimeline, 50)
}
// ── Log drawer ─────────────────────────────────────────────────
function toggleLog() {
const panel = document.getElementById('log-panel')
const arrow = document.getElementById('log-arrow')
panel.classList.toggle('hidden')
arrow.textContent = panel.classList.contains('hidden') ? '▶' : '▼'
}
// ── Timeline canvas ────────────────────────────────────────────
function parseDate(s) {
const [y,m,d] = s.split('-').map(Number)
return new Date(y, (m||1)-1, (d||1))
}
function addMonths(date, months) {
const d = new Date(date)
d.setMonth(d.getMonth() + months)
return d
}
function drawTimeline() {
const from = document.getElementById('date-from').value
const to = document.getElementById('date-to').value
const yr = parseInt(document.getElementById('offset-yr').value) || 0
const mo = parseInt(document.getElementById('offset-mo').value) || 0
drawTimelineOn('timeline-canvas', from, to, yr, mo)
}
function drawTimelineOn(canvasId, fromStr, toStr, yr, mo) {
const canvas = document.getElementById(canvasId)
if (!canvas) return
const W = canvas.offsetWidth || 500
canvas.width = W * devicePixelRatio
canvas.height = 90 * devicePixelRatio
const ctx = canvas.getContext('2d')
ctx.scale(devicePixelRatio, devicePixelRatio)
const H = 90
const PAD = { l: 8, r: 8, top: 20 }
const trackH = 22
const gap = 10
const srcY = PAD.top
const projY = srcY + trackH + gap
const srcStart = parseDate(fromStr)
const srcEnd = parseDate(toStr)
if (isNaN(srcStart) || isNaN(srcEnd)) return
const offsetMo = yr * 12 + mo
const projStart = addMonths(srcStart, offsetMo)
const projEnd = addMonths(srcEnd, offsetMo)
// window: from 1 month before srcStart to 1 month after projEnd
const winStart = addMonths(srcStart, -1)
const winEnd = addMonths(projEnd, 1)
const winMs = winEnd - winStart
const drawW = W - PAD.l - PAD.r
function xOf(date) {
return PAD.l + ((date - winStart) / winMs) * drawW
}
ctx.clearRect(0, 0, W, H)
// ── axis line ──
ctx.strokeStyle = '#e5e7eb'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(PAD.l, srcY - 8)
ctx.lineTo(PAD.l + drawW, srcY - 8)
ctx.stroke()
// ── month ticks ──
ctx.fillStyle = '#9ca3af'
ctx.font = '9px system-ui'
ctx.textAlign = 'center'
const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1)
for (let d = new Date(tickStart); d <= winEnd; d = addMonths(d, 1)) {
const x = xOf(d)
if (x < PAD.l || x > PAD.l + drawW) continue
ctx.strokeStyle = '#f3f4f6'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(x, srcY - 8)
ctx.lineTo(x, projY + trackH)
ctx.stroke()
// year label on Jan
if (d.getMonth() === 0) {
ctx.fillStyle = '#6b7280'
ctx.font = 'bold 9px system-ui'
ctx.fillText(d.getFullYear(), x, srcY - 10)
ctx.font = '9px system-ui'
}
}
// ── source band ──
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
ctx.fillStyle = '#dbeafe'
ctx.strokeStyle = '#93c5fd'
ctx.lineWidth = 1
roundRect(ctx, sx1, srcY, sx2 - sx1, trackH, 4, true, true)
ctx.fillStyle = '#1d4ed8'
ctx.font = '10px system-ui'
ctx.textAlign = 'left'
ctx.fillText('Source ' + fromStr + ' → ' + toStr, sx1 + 6, srcY + 14)
if (offsetMo > 0) {
// ── projected band ──
const px1 = xOf(projStart), px2 = xOf(projEnd)
ctx.fillStyle = '#dcfce7'
ctx.strokeStyle = '#86efac'
ctx.lineWidth = 1
roundRect(ctx, px1, projY, px2 - px1, trackH, 4, true, true)
ctx.fillStyle = '#15803d'
ctx.font = '10px system-ui'
ctx.textAlign = 'left'
ctx.fillText('Projected ' + fmtDate(projStart) + ' → ' + fmtDate(projEnd), px1 + 6, projY + 14)
// ── offset arrow ──
const arrowY = srcY + trackH / 2
ctx.strokeStyle = '#94a3b8'
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.beginPath()
ctx.moveTo(sx1, arrowY)
ctx.lineTo(px1 - 2, arrowY)
ctx.stroke()
ctx.setLineDash([])
// arrowhead
ctx.fillStyle = '#94a3b8'
ctx.beginPath()
ctx.moveTo(px1 + 4, arrowY)
ctx.lineTo(px1 - 4, arrowY - 4)
ctx.lineTo(px1 - 4, arrowY + 4)
ctx.closePath()
ctx.fill()
// label
const midX = (sx1 + px1) / 2
ctx.fillStyle = '#64748b'
ctx.font = '9px system-ui'
ctx.textAlign = 'center'
ctx.fillText('+' + (yr ? yr + 'yr ' : '') + (mo ? mo + 'mo' : ''), midX, arrowY - 5)
}
}
function roundRect(ctx, x, y, w, h, r, fill, stroke) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath()
if (fill) ctx.fill()
if (stroke) ctx.stroke()
}
function fmtDate(d) {
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0')
}
// ── Help popovers ──────────────────────────────────────────────
const helpText = {
segment: {
title: 'What is a segment?',
body: 'A segment is one query against the source table. Each segment appends rows independently — you can layer multiple segments to build up the baseline (e.g. core actuals, open orders, special items). Each is independently undoable.'
},
filters: {
title: 'Filters',
body: 'Define what rows to pull from the source table. You can use any date or filter-role column. At least one filter is required. Multiple filters are ANDed together.\n\nFor date ranges use BETWEEN. For lists use IN. For exact matches use =.'
},
offset: {
title: 'Date offset',
body: 'Shifts the primary date column forward by this amount when rows are inserted. For example, with offset = 1 yr, a row with order_date 2025-03-15 is stored as 2026-03-15.\n\nLeave at 0 to keep dates as-is (useful for open orders or non-date segments).'
},
timeline: {
title: 'Timeline preview',
body: 'The blue band shows the source period — the date range of rows being pulled from the source table.\n\nThe green band shows where those dates will land after the offset is applied. The arrow shows the shift.'
},
reference: {
title: 'Reference rows',
body: 'Reference rows are prior-period actuals loaded for comparison only. They appear in the pivot alongside your forecast rows but are never touched by scale, recode, or clone operations.'
},
operations: {
title: 'Operations',
body: 'Click a cell in the pivot to set the slice, then choose an operation:\n\n• Scale — add or subtract value/units across the slice\n• Recode — reassign dimension values (e.g. rename a customer)\n• Clone — copy a slice to a new set of dimension values\n\nAll operations are incremental and undoable from the log.'
}
}
function showHelp(btn, key) {
const data = helpText[key]
if (!data) return
const box = document.getElementById('help-box')
const over = document.getElementById('help-overlay')
box.innerHTML = `<div class="font-semibold text-white mb-1.5">${data.title}</div><div class="text-gray-300 whitespace-pre-line">${data.body}</div>`
const rect = btn.getBoundingClientRect()
box.style.top = (rect.bottom + 6 + window.scrollY) + 'px'
box.style.left = Math.min(rect.left, window.innerWidth - 300) + 'px'
box.classList.add('open')
over.classList.remove('hidden')
}
function closeHelp() {
document.getElementById('help-box').classList.remove('open')
document.getElementById('help-overlay').classList.add('hidden')
}
window.addEventListener('resize', drawTimeline)
// restore sidebar state
const sbState = localStorage.getItem('sb') || 'expanded'
const sb = document.getElementById('sidebar')
sb.classList.toggle('expanded', sbState === 'expanded')
sb.classList.toggle('collapsed', sbState === 'collapsed')
show('forecast')
</script>
</body>
</html>