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 }