pf_app/ui/src/components/Timeline.jsx
Paul Trowbridge ee4a60475e Type the segment date offset as an interval, not year and month spinners
The Date offset on a baseline or reference segment was a pair of
type="number" inputs, years and months, both clamped at min 0. So no
characters could be typed, days could not be expressed at all, and a segment
could only ever be shifted forward in whole months -- while the stored value
is a Postgres interval that happily accepts "4 months", "1 year" or
"-90 days". parseOffset only read year and month, so anything else would not
have survived a round trip through the edit form either.

One text field now, with suggestions, matching what clone already offers.
parseInterval parses it far enough to draw the timeline preview; Postgres
stays the authority, and assertInterval -- now shared by the baseline,
reference and clone routes -- rejects what it will not accept, with a
message naming the field rather than a parse error from inside a CTE.

Timeline takes months and days separately rather than years and months,
because the two are not interchangeable: a month shift lands on the same day
of another month, a day shift can cross a month boundary. It adds months
then days, as Postgres does, and its label handles negatives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 02:36:50 -04:00

176 lines
6.0 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, offsetMonths = 0, offsetDays = 0, type = 'baseline' }) {
const canvasRef = useRef(null)
// Months and days are kept apart because they are not interchangeable: a month
// shift lands on the same day of a different month, a day shift can cross a
// month boundary. The preview adds months first, then days, as Postgres does.
const offsetMoTotal = offsetMonths || 0
const offsetDayTotal = offsetDays || 0
const shifted = offsetMoTotal !== 0 || offsetDayTotal !== 0
const twoBands = type === 'baseline' && shifted
const canvasH = twoBands ? 90 : 52
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
let raf
const draw = () => {
const dpr = window.devicePixelRatio || 1
const W = canvas.offsetWidth || 500
canvas.width = W * dpr
canvas.height = canvasH * dpr
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
const PAD = { l: 8, r: 8 }
const trackH = 22
const drawW = W - PAD.l - PAD.r
const bandY = twoBands ? 20 : (canvasH - trackH) / 2
const projY = bandY + trackH + 10
const srcStart = parseDate(dateFrom)
const srcEnd = parseDate(dateTo)
if (!srcStart || !srcEnd || isNaN(srcStart) || isNaN(srcEnd)) return
const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x }
const projStart = addDays(addMonths(srcStart, offsetMoTotal), offsetDayTotal)
const projEnd = addDays(addMonths(srcEnd, offsetMoTotal), offsetDayTotal)
const winStart = addMonths(srcStart, -1)
const winEnd = addMonths(twoBands ? projEnd : srcEnd, 1)
const winMs = winEnd - winStart
function xOf(date) {
return PAD.l + ((date - winStart) / winMs) * drawW
}
ctx.clearRect(0, 0, W, canvasH)
// axis
ctx.strokeStyle = '#e5e7eb'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(PAD.l, bandY - 8)
ctx.lineTo(PAD.l + drawW, bandY - 8)
ctx.stroke()
// month ticks + year labels
const tickStart = new Date(winStart.getFullYear(), winStart.getMonth(), 1)
const tickBottom = twoBands ? projY + trackH : bandY + trackH
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, bandY - 8)
ctx.lineTo(x, tickBottom)
ctx.stroke()
if (d.getMonth() === 0) {
ctx.fillStyle = '#6b7280'
ctx.font = 'bold 9px system-ui'
ctx.textAlign = 'center'
ctx.fillText(d.getFullYear(), x, bandY - 10)
}
}
// first band
const sx1 = xOf(srcStart), sx2 = xOf(srcEnd)
if (type === 'reference') {
ctx.fillStyle = '#f3e8ff'
ctx.strokeStyle = '#d8b4fe'
} else {
ctx.fillStyle = '#dbeafe'
ctx.strokeStyle = '#93c5fd'
}
ctx.lineWidth = 1
roundRect(ctx, sx1, bandY, Math.max(sx2 - sx1, 4), trackH, 4, true, true)
ctx.fillStyle = type === 'reference' ? '#7c3aed' : '#1d4ed8'
ctx.font = '10px system-ui'
ctx.textAlign = 'left'
const bandLabel = type === 'reference' ? 'Reference' : 'Source'
ctx.fillText(bandLabel + ' ' + dateFrom + ' → ' + dateTo, sx1 + 6, bandY + 14)
// projected band + arrow (baseline only, when offset > 0)
if (twoBands) {
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)
const arrowY = bandY + 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 yrs = Math.trunc(offsetMoTotal / 12)
const mos = offsetMoTotal % 12
const sign = (offsetMoTotal + offsetDayTotal) < 0 ? '' : '+'
const offsetLabel = sign + [
yrs ? `${yrs}yr` : '',
mos ? `${mos}mo` : '',
offsetDayTotal ? `${offsetDayTotal}d` : '',
].filter(Boolean).join(' ')
ctx.fillStyle = '#64748b'
ctx.font = '9px system-ui'
ctx.textAlign = 'center'
ctx.fillText(offsetLabel.trim(), (sx1 + px1) / 2, arrowY - 5)
}
}
raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
}, [dateFrom, dateTo, offsetMoTotal, offsetDayTotal, type, twoBands, canvasH])
return <canvas ref={canvasRef} height={canvasH} style={{ width: '100%', display: 'block' }} />
}