File size: 3,300 Bytes
c6253b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/** 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>
  );
}