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>
176 lines
6.0 KiB
JavaScript
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' }} />
|
|
}
|