Spaces:
Sleeping
Sleeping
| /** iOS-like day timeline: hour grid, now line, colored blocks. */ | |
| import type { ScheduledBlock } from "../api"; | |
| const DAY_START = 6; | |
| const DAY_END = 23; | |
| const HOUR_PX = 64; | |
| export function parseMinutes(hhmm: string): number { | |
| const [h, m] = hhmm.split(":").map(Number); | |
| return h * 60 + m; | |
| } | |
| export function timelineMetrics() { | |
| const startMin = DAY_START * 60; | |
| const endMin = DAY_END * 60; | |
| return { | |
| startMin, | |
| endMin, | |
| hourPx: HOUR_PX, | |
| totalPx: ((endMin - startMin) / 60) * HOUR_PX, | |
| }; | |
| } | |
| function blockClass(block: ScheduledBlock): string { | |
| const parts = ["timeline-block"]; | |
| if (block.priority === "P0" || block.locked) parts.push("p0"); | |
| else if (block.intent === "explore") parts.push("explore"); | |
| else if (block.intent === "restore_fun") parts.push("restore"); | |
| else if (block.kind === "stabilize") parts.push("stabilize"); | |
| else if (block.priority === "P1") parts.push("duty"); | |
| else parts.push("flex"); | |
| if (block.status === "done" || block.status === "partial") parts.push("done"); | |
| if (block.status === "skipped" || block.status === "cancelled") parts.push("skipped"); | |
| return parts.join(" "); | |
| } | |
| type Props = { | |
| blocks: ScheduledBlock[]; | |
| onSelect: (block: ScheduledBlock) => void; | |
| now?: Date; | |
| }; | |
| export function Timeline({ blocks, onSelect, now = new Date() }: Props) { | |
| const { startMin, endMin, hourPx, totalPx } = timelineMetrics(); | |
| const hours: number[] = []; | |
| for (let h = DAY_START; h <= DAY_END; h += 1) hours.push(h); | |
| const nowMin = now.getHours() * 60 + now.getMinutes(); | |
| const showNow = nowMin >= startMin && nowMin <= endMin; | |
| const nowTop = ((nowMin - startMin) / 60) * hourPx; | |
| return ( | |
| <div class="timeline" style={{ height: `${totalPx}px` }}> | |
| <div class="timeline-gutter" aria-hidden="true"> | |
| {hours.map((h) => ( | |
| <div | |
| key={h} | |
| class="timeline-hour-label" | |
| style={{ top: `${((h * 60 - startMin) / 60) * hourPx}px` }} | |
| > | |
| {`${String(h).padStart(2, "0")}:00`} | |
| </div> | |
| ))} | |
| </div> | |
| <div class="timeline-canvas"> | |
| {hours.map((h) => ( | |
| <div | |
| key={h} | |
| class="timeline-hour-line" | |
| style={{ top: `${((h * 60 - startMin) / 60) * hourPx}px` }} | |
| /> | |
| ))} | |
| {showNow && ( | |
| <div class="timeline-now" style={{ top: `${nowTop}px` }}> | |
| <span class="timeline-now-dot" /> | |
| </div> | |
| )} | |
| {blocks.map((block) => { | |
| const start = Math.max(parseMinutes(block.start), startMin); | |
| const end = Math.min(parseMinutes(block.end), endMin); | |
| if (end <= start) return null; | |
| const top = ((start - startMin) / 60) * hourPx; | |
| const height = Math.max(28, ((end - start) / 60) * hourPx - 4); | |
| return ( | |
| <button | |
| key={block.id} | |
| type="button" | |
| class={blockClass(block)} | |
| style={{ top: `${top}px`, height: `${height}px` }} | |
| onClick={() => onSelect(block)} | |
| > | |
| <strong>{block.title}</strong> | |
| <span> | |
| {block.start}–{block.end} · {block.planned_min}m | |
| </span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |