import React from 'react'; import { License } from '../types'; import { ChevronLeftIcon, ChevronRightIcon } from './Icons'; interface TimelineProps { licenses: License[]; timelineDate: Date; onNavigate: (direction: 'prev' | 'next') => void; fiscalStartMonth: number; } const COLORS = ['#22d3ee', '#f472b6', '#a78bfa', '#4ade80', '#facc15', '#fb923c', '#f87171', '#60a5fa']; const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime()); const getColorForString = (str: string): string => { let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); hash = hash & hash; } return COLORS[Math.abs(hash) % COLORS.length]; }; const getFiscalYearBounds = (refDate: Date, startMonth: number): [Date, Date] => { try { const year = isValidDate(refDate) ? refDate.getFullYear() : new Date().getFullYear(); const safeMonth = (!startMonth || isNaN(startMonth) || startMonth < 1 || startMonth > 12) ? 4 : Number(startMonth); let start = new Date(year, safeMonth - 1, 1); if (refDate < start) { start.setFullYear(year - 1); } const end = new Date(start.getFullYear() + 1, start.getMonth(), 0); end.setHours(23, 59, 59, 999); return [start, end]; } catch (e) { const now = new Date(); const s = new Date(now.getFullYear(), 0, 1); const e_ = new Date(now.getFullYear(), 11, 31, 23, 59, 59); return [s, e_]; } }; const percentThroughDateRange = (date: Date, start: Date, end: Date): number => { if (!isValidDate(date) || !isValidDate(start) || !isValidDate(end)) return 0; if (date < start) return 0; if (date > end) return 100; const total = end.getTime() - start.getTime(); return total > 0 ? ((date.getTime() - start.getTime()) / total) * 100 : 0; }; interface TimelineEvent { type: string; date: Date; label: string; color: string; isEndDate: boolean; percent: number; stackLevel: number; } const Timeline: React.FC = ({ licenses = [], timelineDate, onNavigate, fiscalStartMonth }) => { const safeDate = isValidDate(timelineDate) ? timelineDate : new Date(); const [fyStart, fyEnd] = getFiscalYearBounds(safeDate, fiscalStartMonth); const safeLicenses = Array.isArray(licenses) ? licenses : []; const events: TimelineEvent[] = safeLicenses.flatMap(lic => { if (!lic) return []; const color = getColorForString(lic.licenseName || 'U'); const list: TimelineEvent[] = []; const add = (dStr: string | undefined, type: string, isEnd: boolean) => { if (!dStr) return; const d = new Date(dStr); if (isValidDate(d)) { list.push({ type, date: d, label: lic.licenseName, color, isEndDate: isEnd, percent: 0, stackLevel: 0 }); } }; // ONLY show the current active contract term add(lic.purchaseDate, 'Current Term Start', false); add(lic.endDate, 'Current Term Expiry', true); // NOTE: Historical renewals are intentionally excluded from timeline visualization // to focus on current contract status and upcoming expirations. return list; }); const visibleEvents = events .filter(e => e.date >= fyStart && e.date <= fyEnd) .map(e => ({ ...e, percent: percentThroughDateRange(e.date, fyStart, fyEnd) })) .sort((a, b) => a.percent - b.percent); const today = new Date(); const todayPercent = percentThroughDateRange(today, fyStart, fyEnd); const isTodayVisible = today >= fyStart && today <= fyEnd; const levelLastPositions: number[] = []; visibleEvents.forEach(e => { let level = 0, placed = false; while (!placed && level < 10) { if (e.percent > (levelLastPositions[level] || -100) + 4.0) { e.stackLevel = level; levelLastPositions[level] = e.percent; placed = true; } else level++; } if (!placed) e.stackLevel = level; }); const containerHeight = 60 + (Math.max(0, ...visibleEvents.map(e => e.stackLevel)) * 22); const monthLabels = Array.from({length: 12}, (_, i) => { const d = new Date(fyStart.getFullYear(), fyStart.getMonth() + i, 1); return d.toLocaleString('default', { month: 'short' }); }); return (

Fiscal Timeline

Period: {fyStart.toLocaleDateString()} - {fyEnd.toLocaleDateString()}

{monthLabels.map((m, i) =>
{m}
)}
{isTodayVisible && (
Today
)}
{visibleEvents.map((event, i) => { const isTop = event.stackLevel % 2 === 0; const offset = Math.ceil(event.stackLevel / 2) * 20 + 8; return (
{event.label}
{event.type} • {event.date.toLocaleDateString()}
); })}
); }; export default Timeline;