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:
@@ -16,4 +16,5 @@ import './expenses-export.test.js';
|
||||
import './flights.test.js';
|
||||
import './rental.test.js';
|
||||
import './stays.test.js';
|
||||
import './timeline-layout.test.js';
|
||||
import './transport.test.js';
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { timedSlots, layoutColumns, gridWindow } from '../public/js/timelineLayout.js';
|
||||
|
||||
// Unit tests for the pure, DOM-free timeline layout helpers backing the hourly
|
||||
// day timeline (docs/API.md "Hourly day timeline"). The DOM rendering in
|
||||
// dayTimeline.js is not covered here — these pin the contract of the maths.
|
||||
|
||||
let nextId = 1;
|
||||
function entry(over = {}) {
|
||||
return {
|
||||
id: nextId++, type: 'activity', title: 'Thing', date: '2026-08-01',
|
||||
start_time: '09:00', end_time: '10:00', ...over,
|
||||
};
|
||||
}
|
||||
|
||||
// A raw slot as layoutColumns consumes it (what timedSlots produces).
|
||||
function slot(title, startMin, endMin) {
|
||||
return { entry: entry({ title, start_time: null }), startMin, endMin };
|
||||
}
|
||||
|
||||
// Column results keyed by entry title, so assertions are independent of the
|
||||
// order layoutColumns returns the slots in.
|
||||
function colsByTitle(slots) {
|
||||
const out = {};
|
||||
for (const s of layoutColumns(slots)) out[s.entry.title] = { col: s.col, cols: s.cols };
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- timedSlots ---
|
||||
|
||||
test('timedSlots: parses valid HH:MM times into minutes since midnight', () => {
|
||||
const e = entry({ start_time: '09:30', end_time: '11:45' });
|
||||
const slots = timedSlots([e]);
|
||||
assert.equal(slots.length, 1);
|
||||
assert.equal(slots[0].startMin, 9 * 60 + 30);
|
||||
assert.equal(slots[0].endMin, 11 * 60 + 45);
|
||||
assert.equal(slots[0].entry, e, 'the slot carries the original entry');
|
||||
});
|
||||
|
||||
test('timedSlots: missing end_time defaults to one hour after the start', () => {
|
||||
const slots = timedSlots([
|
||||
entry({ start_time: '14:15', end_time: null }),
|
||||
entry({ start_time: '14:15', end_time: undefined }),
|
||||
entry({ start_time: '14:15', end_time: '' }),
|
||||
]);
|
||||
assert.equal(slots.length, 3);
|
||||
for (const s of slots) {
|
||||
assert.equal(s.startMin, 14 * 60 + 15);
|
||||
assert.equal(s.endMin, 15 * 60 + 15);
|
||||
}
|
||||
});
|
||||
|
||||
test('timedSlots: an invalid end_time format is treated as missing (+60)', () => {
|
||||
const slots = timedSlots([
|
||||
entry({ start_time: '10:00', end_time: 'later' }),
|
||||
entry({ start_time: '10:00', end_time: '10.30' }),
|
||||
]);
|
||||
assert.equal(slots.length, 2);
|
||||
for (const s of slots) assert.equal(s.endMin, 11 * 60);
|
||||
});
|
||||
|
||||
test('timedSlots: end_time at or before start_time clamps endMin to midnight (1440)', () => {
|
||||
const slots = timedSlots([
|
||||
entry({ start_time: '22:00', end_time: '01:30' }), // crosses midnight
|
||||
entry({ start_time: '12:00', end_time: '12:00' }), // equal counts too
|
||||
]);
|
||||
assert.equal(slots.length, 2);
|
||||
assert.equal(slots[0].startMin, 22 * 60);
|
||||
assert.equal(slots[0].endMin, 1440);
|
||||
assert.equal(slots[1].endMin, 1440);
|
||||
});
|
||||
|
||||
test('timedSlots: an entry spanning into end_date clamps endMin to 1440', () => {
|
||||
const slots = timedSlots([
|
||||
entry({ date: '2026-08-01', end_date: '2026-08-02', start_time: '10:00', end_time: '11:00' }),
|
||||
]);
|
||||
assert.equal(slots.length, 1);
|
||||
assert.equal(slots[0].startMin, 10 * 60);
|
||||
assert.equal(slots[0].endMin, 1440);
|
||||
});
|
||||
|
||||
test('timedSlots: entries with an invalid or absent start_time are excluded', () => {
|
||||
const timed = entry({ title: 'timed', start_time: '08:00', end_time: '09:00' });
|
||||
const slots = timedSlots([
|
||||
entry({ start_time: null }),
|
||||
entry({ start_time: undefined }),
|
||||
entry({ start_time: '' }),
|
||||
entry({ start_time: 'noon' }),
|
||||
timed,
|
||||
]);
|
||||
assert.equal(slots.length, 1);
|
||||
assert.equal(slots[0].entry, timed);
|
||||
});
|
||||
|
||||
// --- layoutColumns ---
|
||||
|
||||
test('layoutColumns: non-overlapping slots all sit in column 0 of a 1-column row', () => {
|
||||
const out = colsByTitle([
|
||||
slot('a', 9 * 60, 10 * 60),
|
||||
slot('b', 11 * 60, 12 * 60),
|
||||
slot('c', 13 * 60, 14 * 60),
|
||||
]);
|
||||
assert.deepEqual(out, {
|
||||
a: { col: 0, cols: 1 },
|
||||
b: { col: 0, cols: 1 },
|
||||
c: { col: 0, cols: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('layoutColumns: two overlapping slots get distinct columns in a 2-column row', () => {
|
||||
const out = colsByTitle([
|
||||
slot('a', 9 * 60, 11 * 60),
|
||||
slot('b', 10 * 60, 12 * 60),
|
||||
]);
|
||||
assert.equal(out.a.cols, 2);
|
||||
assert.equal(out.b.cols, 2);
|
||||
assert.notEqual(out.a.col, out.b.col);
|
||||
assert.deepEqual([out.a.col, out.b.col].sort(), [0, 1]);
|
||||
});
|
||||
|
||||
test('layoutColumns: an overlap chain shares one cluster, with column reuse', () => {
|
||||
// a overlaps b, b overlaps c, but a and c do not touch: one connected
|
||||
// cluster of width 2, and c reuses column 0 because a has ended by then.
|
||||
const out = colsByTitle([
|
||||
slot('a', 0, 100),
|
||||
slot('b', 50, 150),
|
||||
slot('c', 120, 200),
|
||||
]);
|
||||
assert.deepEqual(out, {
|
||||
a: { col: 0, cols: 2 },
|
||||
b: { col: 1, cols: 2 },
|
||||
c: { col: 0, cols: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
test('layoutColumns: back-to-back slots (end == next start) do not overlap', () => {
|
||||
const out = colsByTitle([
|
||||
slot('a', 60, 120),
|
||||
slot('b', 120, 180),
|
||||
]);
|
||||
assert.deepEqual(out, {
|
||||
a: { col: 0, cols: 1 },
|
||||
b: { col: 0, cols: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('layoutColumns: cols is per overlap cluster, not a global maximum', () => {
|
||||
// Three separate clusters in one call: a 2-wide pair, an isolated slot, and
|
||||
// a 3-wide pile-up. Each slot's cols must reflect only its own cluster.
|
||||
const out = colsByTitle([
|
||||
slot('a1', 9 * 60, 10 * 60),
|
||||
slot('a2', 9 * 60 + 30, 10 * 60 + 30),
|
||||
slot('lone', 11 * 60, 12 * 60),
|
||||
slot('b1', 13 * 60, 16 * 60),
|
||||
slot('b2', 13 * 60 + 30, 15 * 60),
|
||||
slot('b3', 14 * 60, 14 * 60 + 30),
|
||||
]);
|
||||
assert.equal(out.a1.cols, 2);
|
||||
assert.equal(out.a2.cols, 2);
|
||||
assert.deepEqual(out.lone, { col: 0, cols: 1 },
|
||||
'an isolated slot is full-width even when other clusters split');
|
||||
assert.deepEqual([out.b1.cols, out.b2.cols, out.b3.cols], [3, 3, 3]);
|
||||
});
|
||||
|
||||
test('layoutColumns: three-way overlap widens the whole cluster to 3 columns', () => {
|
||||
const out = colsByTitle([
|
||||
slot('a', 9 * 60, 12 * 60),
|
||||
slot('b', 10 * 60, 11 * 60),
|
||||
slot('c', 10 * 60 + 30, 11 * 60 + 30),
|
||||
]);
|
||||
assert.deepEqual([out.a.cols, out.b.cols, out.c.cols], [3, 3, 3]);
|
||||
assert.deepEqual([out.a.col, out.b.col, out.c.col].sort(), [0, 1, 2]);
|
||||
});
|
||||
|
||||
// --- gridWindow ---
|
||||
|
||||
test('gridWindow: empty slots fall back to the 08:00–21:00 default window', () => {
|
||||
assert.deepEqual(gridWindow([]), { startHour: 8, endHour: 21 });
|
||||
});
|
||||
|
||||
test('gridWindow: pads one full hour either side of the slots', () => {
|
||||
const win = gridWindow([slot('a', 9 * 60 + 30, 17 * 60 + 45)]);
|
||||
assert.deepEqual(win, { startHour: 8, endHour: 19 });
|
||||
});
|
||||
|
||||
test('gridWindow: exact-hour boundaries still get a full hour of padding', () => {
|
||||
const win = gridWindow([slot('a', 9 * 60, 17 * 60)]);
|
||||
assert.deepEqual(win, { startHour: 8, endHour: 18 });
|
||||
});
|
||||
|
||||
test('gridWindow: clamps to 0 and 24 at the edges of the day', () => {
|
||||
assert.deepEqual(gridWindow([slot('a', 15, 23 * 60 + 40)]), { startHour: 0, endHour: 24 });
|
||||
assert.deepEqual(gridWindow([slot('a', 0, 1440)]), { startHour: 0, endHour: 24 });
|
||||
});
|
||||
|
||||
test('gridWindow: spans from the earliest start to the latest end across slots', () => {
|
||||
const win = gridWindow([
|
||||
slot('a', 11 * 60, 12 * 60),
|
||||
slot('b', 7 * 60 + 10, 8 * 60),
|
||||
slot('c', 13 * 60, 20 * 60 + 5),
|
||||
]);
|
||||
assert.deepEqual(win, { startHour: 6, endHour: 22 });
|
||||
});
|
||||
|
||||
// --- determinism & purity ---
|
||||
|
||||
test('timedSlots: repeat calls agree and the input entries are untouched', () => {
|
||||
const make = () => [
|
||||
entry({ id: 1, title: 'x', start_time: '10:00', end_time: 'bogus' }),
|
||||
entry({ id: 2, title: 'y', start_time: null }),
|
||||
entry({ id: 3, title: 'z', start_time: '08:00', end_time: '07:00' }),
|
||||
];
|
||||
const input = make();
|
||||
const snapshot = JSON.stringify(input);
|
||||
const a = timedSlots(input);
|
||||
const b = timedSlots(input);
|
||||
assert.deepEqual(
|
||||
a.map((s) => [s.entry.id, s.startMin, s.endMin]),
|
||||
b.map((s) => [s.entry.id, s.startMin, s.endMin])
|
||||
);
|
||||
assert.equal(JSON.stringify(input), snapshot, 'entries are not mutated');
|
||||
});
|
||||
|
||||
test('layoutColumns: deliberately unsorted input yields the sorted-input layout', () => {
|
||||
// Same chain as the cluster test, fed out of order: the greedy pass sorts
|
||||
// by startMin then endMin internally, so the assignments must not change.
|
||||
const unsorted = [
|
||||
slot('c', 120, 200),
|
||||
slot('a', 0, 100),
|
||||
slot('b', 50, 150),
|
||||
];
|
||||
const before = [...unsorted];
|
||||
const out = colsByTitle(unsorted);
|
||||
assert.deepEqual(out, {
|
||||
a: { col: 0, cols: 2 },
|
||||
b: { col: 1, cols: 2 },
|
||||
c: { col: 0, cols: 2 },
|
||||
});
|
||||
assert.deepEqual(unsorted, before, 'the input array order is not disturbed');
|
||||
|
||||
// And a second identical call agrees exactly.
|
||||
const again = colsByTitle([
|
||||
slot('c', 120, 200),
|
||||
slot('a', 0, 100),
|
||||
slot('b', 50, 150),
|
||||
]);
|
||||
assert.deepEqual(again, out);
|
||||
});
|
||||
Reference in New Issue
Block a user