pf_app/ui/src/components/Timeline.jsx
Paul Trowbridge dc090fe394 Scaffold React/Vite/Tailwind UI with 3-step Setup → Baseline → Forecast flow
- ui/: React + Vite + Tailwind app (Setup, Baseline, Forecast views, collapsible sidebar, status bar, canvas timeline)
- server.js: serve built UI from public/app/
- package.json: add build script (cd ui && npm run build)
- routes/sources.js: default new col_meta role to 'dimension' instead of 'ignore'
- .gitignore: exclude public/app/ build output
- pf_spec.md: update tech stack, nav, frontend section, and project status to reflect current implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:28:45 -04:00

148 lines
4.4 KiB
JavaScript

import { useEffect, useRef } from 'react'
function parseDate(s) {
if (!s) return null
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 fmtDate(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
}
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()
}
export default function Timeline({ dateFrom, dateTo, offsetYr, offsetMo }) {
const canvasRef = useRef(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const dpr = window.devicePixelRatio || 1
const W = canvas.offsetWidth || 500
canvas.width = W * dpr
canvas.height = 90 * dpr
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
const H = 90
const PAD = { l: 8, r: 8 }
const trackH = 22
const srcY = 20
const projY = srcY + trackH + 10
const drawW = W - PAD.l - PAD.r
const srcStart = parseDate(dateFrom)
const srcEnd = parseDate(dateTo)
if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return
const offsetMoTotal = (offsetYr || 0) * 12 + (offsetMo || 0)
const projStart = addMonths(srcStart, offsetMoTotal)
const projEnd = addMonths(srcEnd, offsetMoTotal)
const winStart = addMonths(srcStart, -1)
const winEnd = addMonths(projEnd, 1)
const winMs = winEnd - winStart
function xOf(date) {
return PAD.l + ((date - winStart) / winMs) * drawW
}
ctx.clearRect(0, 0, W, H)
// axis
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 + year labels
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()
if (d.getMonth() === 0) {
ctx.fillStyle = '#6b7280'
ctx.font = 'bold 9px system-ui'
ctx.textAlign = 'center'
ctx.fillText(d.getFullYear(), x, srcY - 10)
}
}
// source band
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
ctx.fillStyle = '#dbeafe'
ctx.strokeStyle = '#93c5fd'
ctx.lineWidth = 1
roundRect(ctx, sx1, srcY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
ctx.fillStyle = '#1d4ed8'
ctx.font = '10px system-ui'
ctx.textAlign = 'left'
ctx.fillText('Source ' + dateFrom + ' → ' + dateTo, sx1 + 6, srcY + 14)
if (offsetMoTotal > 0) {
const px1 = xOf(projStart), px2 = xOf(projEnd)
ctx.fillStyle = '#dcfce7'
ctx.strokeStyle = '#86efac'
roundRect(ctx, px1, projY, Math.max(px2 - px1, 4), 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)
// 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([])
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()
const label = '+' + (offsetYr ? offsetYr + 'yr ' : '') + (offsetMo ? offsetMo + 'mo' : '')
ctx.fillStyle = '#64748b'
ctx.font = '9px system-ui'
ctx.textAlign = 'center'
ctx.fillText(label.trim(), (sx1 + px1) / 2, arrowY - 5)
}
}, [dateFrom, dateTo, offsetYr, offsetMo])
return <canvas ref={canvasRef} height={90} style={{ width: '100%', display: 'block' }} />
}