Fastwhisper / frontend /src /components /Timeline.tsx
Mbonea's picture
Ship Plan timeline with thick feedback, priors, and P1 Seligman scope lock.
c6253b2
Raw
History Blame Contribute Delete
3.3 kB
/** 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>
);
}