[pending-judge] feat(DC-168): calendar widget UX + theming — "No upcoming appointments" state, month grid with highlighted appointment days, theme-correct styling
Owner directives (2026-10-10/11, verbatim in brief): show "no upcoming
appointments" when empty; show a month calendar with days and times
highlighted when there are events; stylize to fit every theme; keep the
widget weather-widget-sized.
Changes (status/ frontend + tests):
- status/js/calendar-widget.js: rewritten render path — chip states ("No
upcoming appointments" / next appointment with highlighted when-span),
month grid modal (cal-grid + prev/next nav, appointment days highlighted,
per-event time dots), upcoming list with day/time emphasis, month-following
fetch with window-authoritative event merge (via calendar-cache.js),
forced refresh on periodic tick + source mutations, local escapeHtml (was
an undefined global).
- status/js/calendar-cache.js (new): uid+start-keyed event cache; fetched
window is authoritative for entries starting inside it (deletions and
reschedules propagate), entries outside survive. Dual export (module +
window) for tests and browser bundle.
- status/build.js: calendar-cache.js bundled before calendar-widget.js.
- status/css/dashboard.css: DC-168 block — chip states, month grid, day
highlight states, dots, list/source rows, universal light-bg contrast
rules (color-mix; WCAG >= 4.5:1 verified for all 10 builtin themes, worst
4.60:1 ocean) + light-surface rules.
Tests: 20 total in __tests__/calendar — 9 in caldav-default-ns (extraction
incl. mixed docs, empty multistatus, namespace-aware fixture validation,
undeclared-prefix rejection both quote styles, positive declared-attr),
5 in dc168-cache-contract (merge, duplicate suppression, recurring uid+start,
window-authoritative removal on refetch, cross-window survival, ended-
exclusion), 6 in dc168-widget-contract (navigation fetch windows, forced-
refresh semantics, cache+widget integration, interval coverage). Existing
suites pass unmodified; eslint 0 errors on touched files.
This commit is contained in:
9 files changed
+1099
-304
No files matched your search
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* DC-168 r4: direct contract tests for calendar-cache.js (the extracted
|
||||
* cache semantics module) — merge, duplicate suppression, recurring
|
||||
* instances (uid+start), stale-entry removal on refetch of the same window,
|
||||
* and ended-appointment exclusion in chip selection semantics.
|
||||
*/
|
||||
'use strict';
|
||||
const { createCache } = require('../../../status/js/calendar-cache.js');
|
||||
|
||||
const RANGE_OCT = { start: new Date('2026-10-01T00:00:00Z'), end: new Date('2026-10-31T23:59:59Z') };
|
||||
const RANGE_NOV = { start: new Date('2026-11-01T00:00:00Z'), end: new Date('2026-11-30T23:59:59Z') };
|
||||
|
||||
const ev = (title, start, extra) => Object.assign({ title, start }, extra || {});
|
||||
|
||||
describe('DC-168 r4: calendar-cache contract', () => {
|
||||
test('merge + duplicate suppression (same uid+start counted once)', () => {
|
||||
const c = createCache();
|
||||
const a = { events: [ev('A', '2026-10-05T10:00:00Z', { uid: 'u1' })] };
|
||||
const merged1 = c.merge(a, RANGE_OCT);
|
||||
expect(merged1.events).toHaveLength(1);
|
||||
// duplicate fetch of the same window: still one entry
|
||||
const merged2 = c.merge(a, RANGE_OCT);
|
||||
expect(merged2.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('recurring instances share uid but differ by start — both kept', () => {
|
||||
const c = createCache();
|
||||
const merged = c.merge({ events: [
|
||||
ev('Weekly', '2026-10-05T10:00:00Z', { uid: 'recur' }),
|
||||
ev('Weekly', '2026-10-12T10:00:00Z', { uid: 'recur' }),
|
||||
] }, RANGE_OCT);
|
||||
expect(merged.events).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('refetching a window is authoritative: deletions and reschedules propagate', () => {
|
||||
const c = createCache();
|
||||
c.merge({ events: [
|
||||
ev('Keep', '2026-10-05T10:00:00Z'),
|
||||
ev('Gone', '2026-10-06T10:00:00Z'),
|
||||
ev('Moved', '2026-10-07T10:00:00Z'),
|
||||
] }, RANGE_OCT);
|
||||
expect(c.merge({ events: [] }, RANGE_OCT).events).toHaveLength(0); // all inside window → server says none
|
||||
// reschedule: refetching the OLD window removes the stale occurrence
|
||||
// (server says it's no longer there)...
|
||||
const afterOld = c.merge({ events: [] }, RANGE_OCT);
|
||||
expect(afterOld.events).toHaveLength(0);
|
||||
// ...then fetching the NEW window adds the new occurrence: exactly 1
|
||||
const merged = c.merge({ events: [ev('Moved', '2026-11-07T10:00:00Z')] }, RANGE_NOV);
|
||||
expect(merged.events).toHaveLength(1);
|
||||
expect(merged.events[0].start).toBe('2026-11-07T10:00:00Z');
|
||||
});
|
||||
|
||||
test('entries OUTSIDE the refetched window survive (other-month data kept)', () => {
|
||||
const c = createCache();
|
||||
c.merge({ events: [ev('Nov thing', '2026-11-03T09:00:00Z')] }, RANGE_NOV);
|
||||
c.merge({ events: [ev('Oct thing', '2026-10-14T09:00:00Z')] }, RANGE_OCT);
|
||||
const merged = c.merge({ events: [ev('Oct thing', '2026-10-14T09:00:00Z')] }, RANGE_OCT);
|
||||
expect(merged.events).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('chip semantics: ended appointments excluded, ongoing included (mirrors renderWidget filter)', () => {
|
||||
const now = Date.now();
|
||||
const mk = (mins) => new Date(now + mins * 60000).toISOString();
|
||||
const events = [
|
||||
{ title: 'past', start: mk(-90), end: mk(-30) }, // ended → out
|
||||
{ title: 'ongoing', start: mk(-30), end: mk(30) }, // in progress → in
|
||||
{ title: 'future', start: mk(120), end: mk(180) }, // in
|
||||
];
|
||||
const upcoming = events
|
||||
.filter(e => {
|
||||
const endT = e.end ? new Date(e.end).getTime() : new Date(e.start).getTime();
|
||||
return endT >= now;
|
||||
})
|
||||
.sort((a, b) => new Date(a.start) - new Date(b.start));
|
||||
expect(upcoming.map(e => e.title)).toEqual(['ongoing', 'future']);
|
||||
// no end field: start is the qualifier
|
||||
const noEnd = [{ title: 'started-long-ago', start: mk(-600) }];
|
||||
expect(noEnd.filter(e => new Date(e.start).getTime() >= now)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user