Add hourly day timeline and red now indicators to the calendar
Days with 4+ entries open with an hour-grid timeline in the day editor: untimed entries in an all-day strip, timed ones positioned by start/end with side-by-side columns for overlaps, click-to-edit. Layout math lives in a pure, unit-tested module (timelineLayout.js). Today's month-grid cell gets a vertical red slider positioned by the fraction of the day elapsed; the timeline gets the classic horizontal red current-time line. Both tick every minute with cleanup on unmount, and the initial positioning runs unconditionally while the node is still detached (the isConnected self-heal gates only interval ticks). Also splits entryRow rendering out of dayEditor.js to stay under the 500-line cap.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// Pure, DOM-free layout helpers for the hourly day timeline (dayTimeline.js).
|
||||
// No imports from DOM-touching modules — unit-tested directly under node:test.
|
||||
// See docs/API.md "Hourly day timeline & 'now' indicator" for the binding spec.
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
// "HH:MM" -> minutes since midnight, or null if missing/malformed.
|
||||
function parseHHMM(str) {
|
||||
if (typeof str !== 'string') return null;
|
||||
const m = TIME_RE.exec(str);
|
||||
if (!m) return null;
|
||||
return Number(m[1]) * 60 + Number(m[2]);
|
||||
}
|
||||
|
||||
// entries with a valid start_time -> [{entry, startMin, endMin}] (minutes
|
||||
// since midnight). Missing/invalid end_time -> endMin = startMin + 60.
|
||||
// end_time <= start_time, or the entry spanning into end_date, clamps
|
||||
// endMin to 24*60 (overnight / multi-day entries run to the end of the day).
|
||||
// An invalid start_time (not HH:MM) counts as untimed and is skipped.
|
||||
export function timedSlots(entries) {
|
||||
const slots = [];
|
||||
for (const entry of entries || []) {
|
||||
const startMin = parseHHMM(entry.start_time);
|
||||
if (startMin == null) continue;
|
||||
const spansPastToday = !!(entry.end_date && entry.end_date > entry.date);
|
||||
let endMin = parseHHMM(entry.end_time);
|
||||
if (endMin == null) endMin = startMin + 60;
|
||||
else if (endMin <= startMin || spansPastToday) endMin = 24 * 60;
|
||||
slots.push({ entry, startMin, endMin });
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
// slots -> same slots + {col, cols}: overlapping slots split their shared row
|
||||
// into side-by-side columns. Greedy: sort by startMin then endMin; place each
|
||||
// slot in the first column whose last slot ends <= its start. `cols` is the
|
||||
// column count of the slot's overlap cluster (a maximal run of mutually-
|
||||
// reachable overlapping slots), not a single global maximum.
|
||||
export function layoutColumns(slots) {
|
||||
const sorted = [...slots].sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin);
|
||||
const out = sorted.map((s) => ({ ...s, col: 0, cols: 1 }));
|
||||
|
||||
let cluster = [];
|
||||
let columnEnds = []; // columnEnds[i] = endMin of the last slot placed in column i
|
||||
let clusterEnd = -Infinity;
|
||||
|
||||
const finalizeCluster = () => {
|
||||
for (const item of cluster) item.cols = columnEnds.length;
|
||||
cluster = [];
|
||||
columnEnds = [];
|
||||
};
|
||||
|
||||
for (const item of out) {
|
||||
if (item.startMin >= clusterEnd) {
|
||||
finalizeCluster();
|
||||
clusterEnd = -Infinity;
|
||||
}
|
||||
let col = columnEnds.findIndex((end) => end <= item.startMin);
|
||||
if (col === -1) {
|
||||
col = columnEnds.length;
|
||||
columnEnds.push(item.endMin);
|
||||
} else {
|
||||
columnEnds[col] = item.endMin;
|
||||
}
|
||||
item.col = col;
|
||||
cluster.push(item);
|
||||
clusterEnd = Math.max(clusterEnd, item.endMin);
|
||||
}
|
||||
finalizeCluster();
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// slots -> {startHour, endHour}: one full hour before the earliest startMin to
|
||||
// one full hour after the latest endMin, clamped to [0, 24].
|
||||
// {startHour: 8, endHour: 21} when slots is empty.
|
||||
export function gridWindow(slots) {
|
||||
if (!slots || !slots.length) return { startHour: 8, endHour: 21 };
|
||||
let minStart = Infinity;
|
||||
let maxEnd = -Infinity;
|
||||
for (const s of slots) {
|
||||
if (s.startMin < minStart) minStart = s.startMin;
|
||||
if (s.endMin > maxEnd) maxEnd = s.endMin;
|
||||
}
|
||||
const startHour = Math.max(0, Math.floor(minStart / 60) - 1);
|
||||
const endHour = Math.min(24, Math.ceil(maxEnd / 60) + 1);
|
||||
return { startHour, endHour };
|
||||
}
|
||||
Reference in New Issue
Block a user