oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1// views.mjs — switching views holds still (R2, Paul: "views jumping").
2//
3// Driven by TestViewSwitchingHoldsStillInARealBrowser, which seeds the
4// week of 21 September 2026 with an all-day event and timed events.
5//
6// At 1440 and 390 wide, over every hop Day → Week → Month → Agenda → Day
7// and every view's Previous and Next:
8// 1. CLS inside the new document is 0.
9// 2. The page heading, the view switcher, Quick add, New event, and the
10// Previous and Next buttons are at the same x/y as on the page
11// before (within 0.05px: the bold current tab once moved the switcher by 0.1px).
12// 3. Every view's <main> has the same box, so there is one layout.
13// And, at 1440:
14// 4. Week and Month are not squeezed: a day column is at least 160px.
15// 5. The week's sticky day headings sit at the top of the grid's own
16// scroll box once it has scrolled to working hours, painted over
17// the hours scrolled beneath them, with the all-day row still shown.
18import { chromium } from 'playwright';
19
20const [origin, cookieName, cookieValue] = process.argv.slice(2);
21if (!origin || !cookieName || !cookieValue) {
22 console.error('usage: views.mjs <origin> <cookieName> <cookieValue>');
23 process.exit(2);
24}
25
26const failures = [];
27function check(ok, what) {
28 if (ok) { console.log('ok: ' + what); return; }
29 failures.push(what);
30 console.error('FAIL: ' + what);
31}
32
33const views = {
34 Day: '/day/2026-09-24',
35 Week: '/week/2026-09-24',
36 Month: '/month/2026-09',
37 Agenda: '/agenda?from=2026-09-24',
38};
39const fields = ['heading', 'switcher', 'quickAdd', 'newEvent', 'prev', 'next', 'main'];
40
41const browser = await chromium.launch();
42for (const [width, height] of [[1440, 900], [390, 844]]) {
43 const ctx = await browser.newContext({ viewport: { width, height } });
44 await ctx.addCookies([{ name: cookieName, value: cookieValue, url: origin, httpOnly: true }]);
45 await ctx.addInitScript(() => {
46 window.__cls = 0;
47 new PerformanceObserver((list) => {
48 for (const e of list.getEntries()) if (!e.hadRecentInput) window.__cls += e.value;
49 }).observe({ type: 'layout-shift', buffered: true });
50 });
51 const page = await ctx.newPage();
52
53 const facts = async () => {
54 await page.waitForLoadState('networkidle');
55 await page.waitForTimeout(300);
56 return page.evaluate(() => {
57 const box = (e) => {
58 if (!e) return null;
59 const r = e.getBoundingClientRect();
60 return r.width > 0 && r.height > 0 ? { x: r.x, y: r.y, w: r.width } : null;
61 };
62 const bar = [...document.querySelectorAll('[cal-bar] a')];
63 return {
64 cls: window.__cls || 0,
65 heading: box(document.querySelector('main h1')),
66 switcher: box(document.querySelector('[rst-seg-tabs][aria-label="Calendar view"]')),
67 quickAdd: box(document.querySelector('[cal-quick-summary]')),
68 newEvent: box(document.querySelector('[cal-header-actions] a[rst-btn="primary"]')),
69 prev: box(bar.find((a) => /^Previous/.test(a.textContent.trim()))),
70 next: box(bar.find((a) => /^Next/.test(a.textContent.trim()))),
71 main: box(document.querySelector('main')),
72 };
73 });
74 };
75 const hop = async (name, path) => {
76 const before = await facts();
77 await page.goto(origin + path);
78 const after = await facts();
79 check(after.cls === 0, `${width}: ${name}: CLS is 0 (${after.cls})`);
80 for (const f of fields) {
81 const a = before[f], b = after[f];
82 const still = a && b && Math.abs(a.x - b.x) < 0.05 && Math.abs(a.y - b.y) < 0.05 && (f !== 'main' || Math.abs(a.w - b.w) < 0.05);
83 check(still, `${width}: ${name}: ${f} holds still (${a ? `${a.x.toFixed(1)},${a.y.toFixed(1)}` : 'missing'} → ${b ? `${b.x.toFixed(1)},${b.y.toFixed(1)}` : 'missing'})`);
84 }
85 };
86
87 await page.goto(origin + views.Day);
88 await hop('Day → Week', views.Week);
89 await hop('Week → Month', views.Month);
90 await hop('Month → Agenda', views.Agenda);
91 await hop('Agenda → Day', views.Day);
92 for (const [view, path] of Object.entries(views)) {
93 for (const dir of ['Previous', 'Next']) {
94 await page.goto(origin + path);
95 await facts();
96 const href = await page.locator('[cal-bar] a').filter({ hasText: new RegExp('^' + dir) }).first().getAttribute('href');
97 await hop(`${view} ${dir.toLowerCase()}`, href);
98 }
99 }
100
101 if (width === 1440) {
102 await page.goto(origin + views.Week);
103 await facts();
104 const week = await page.evaluate(() => {
105 const heads = [...document.querySelectorAll('[cal-column-head]')].slice(1).map((h) => h.getBoundingClientRect());
106 const scroller = document.querySelector('[cal-week-scroll]');
107 const top = scroller.getBoundingClientRect().top;
108 // What paints at each heading's centre: the heading, not an hour
109 // row scrolled over it.
110 const covered = heads.filter((r) => !document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.closest('[cal-column-head]')).length;
111 // The all-day row stays on the first screen once the grid has
112 // scrolled to working hours: the offsite's chip paints at its centre.
113 const chip = [...document.querySelectorAll('[cal-allday] [cal-chip]')].find((c) => c.textContent.includes('Team offsite'));
114 const cr = chip && chip.getBoundingClientRect();
115 const allDay = !!(cr && document.elementFromPoint(cr.x + cr.width / 2, cr.y + cr.height / 2)?.closest('[cal-chip]') === chip);
116 return { col: Math.min(...heads.map((r) => r.width)), headTop: heads[0].top, top, scrolled: scroller.scrollTop, covered, allDay };
117 });
118 check(week.col >= 160, `1440: a week column is at least 160px (${week.col.toFixed(1)})`);
119 check(week.scrolled > 0, `1440: the week opened scrolled to working hours (scrollTop ${week.scrolled})`);
120 check(Math.abs(week.headTop - week.top) < 1, `1440: the day headings stick to the grid's top (${week.headTop.toFixed(1)} vs ${week.top.toFixed(1)})`);
121 check(week.allDay, '1440: the all-day row is still on screen after the scroll to working hours');
122 check(week.covered === 0, `1440: every day heading paints over the scrolled grid (${week.covered} covered)`);
123
124 await page.goto(origin + views.Month);
125 await facts();
126 const cell = await page.evaluate(() => Math.min(...[...document.querySelectorAll('[cal-month] tbody tr:first-child td')].map((td) => td.getBoundingClientRect().width)));
127 check(cell >= 160, `1440: a month column is at least 160px (${cell.toFixed(1)})`);
128 }
129 await ctx.close();
130}
131await browser.close();
132if (failures.length) {
133 console.error(`\n${failures.length} check(s) failed`);
134 process.exit(1);
135}
136