openvibers/openvibe.today Commits

Your week: openvibe.today/me/week, day by day (#42)

OP OpenVibers committed 13 days ago · 2fcde87e25 · 6 files, +298 −2

Plan L48 R3 (the owner: "view it through openvibe.today… calendar type shit"). Seven days from Monday in the
person's own zone (?tz=, else the td_tz cookie today.js keeps, else the newest reminder's, else UTC): their
reminders at each time they fire (a repeating-every-few-hours one as one line a day with its span), their own
events and their followed channels' streams, in time order. Seven columns on a computer; on a phone the days one
under another, empty ones said in one line. Last/next week, back to this week, As a list; a quick box adds a
reminder in plain words and comes back to the week. The agenda leads to it. server/week.js is pure and tested,
across a clock change.

Changes

ddiff --git a/public/css/today.css b/public/css/today.css
iindex 3875124..99804e1 100644
---- a/public/css/today.css
++++ b/public/css/today.css
@@@ -223,3 +223,49 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
.td-mine-row { grid-template-columns: auto minmax(0, 1fr); }
.td-mine-acts { grid-column: 1 / -1; justify-content: flex-start; }
}
++
++/* ── Your week (plan L48 R3): seven columns on a computer, the days one under another on a phone ── */
++.td-weeknav { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 0; }
++.td-weeknav .ovk-btn { gap: 6px; }
++.td-weeknav .ovk-i { width: 1em; height: 1em; }
++.td-quick { display: flex; gap: 8px; margin: 14px 0 0; max-width: 720px; }
++.td-quick input[name="text"] { flex: 1; min-width: 0; min-height: 40px; box-sizing: border-box; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0e1626); color: var(--text-primary, #e8eef8); font: inherit; font-size: 14px; }
++.td-quick input[name="text"]:focus { outline: none; border-color: var(--room-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--room-accent) 22%, transparent); }
++.td-week { list-style: none; margin: 22px 0 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px; }
++.td-wd { min-width: 0; display: grid; align-content: start; gap: 8px; padding: 10px 8px 12px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0e1626); min-height: 220px; }
++.td-wd.is-today { border-color: color-mix(in srgb, var(--room-accent) 60%, var(--border, #1f2d47)); box-shadow: inset 0 3px 0 var(--room-accent); }
++.td-wd.is-empty { background: transparent; }
++.td-wd-h { margin: 0 2px; display: flex; align-items: baseline; gap: 6px; font-size: 13px; font-weight: 700; color: var(--text-secondary, #96a7c2); }
++.td-wd-h b { font-size: 22px; font-weight: 800; color: var(--text-primary, #e8eef8); letter-spacing: -.02em; }
++.td-wd-h i, .td-pd-h i { margin-left: auto; font-style: normal; font-size: 11px; font-weight: 750; color: var(--room-strong, var(--room-accent)); }
++.td-wd-none { margin: 2px; color: var(--text-muted, #6f819d); font-size: 12.5px; }
++.td-wis { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
++.td-wi a { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 1px 7px; padding: 7px 8px; border-radius: 10px; color: var(--text-primary, #e8eef8); text-decoration: none; background: color-mix(in srgb, var(--text-primary, #e8eef8) 4%, transparent); border-left: 3px solid var(--k, var(--border, #1f2d47)); }
++.td-wi a:hover { background: color-mix(in srgb, var(--text-primary, #e8eef8) 8%, transparent); }
++.td-wi a:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }
++.td-wi-k { grid-row: span 2; display: grid; place-items: center; width: 18px; height: 18px; margin-top: 1px; color: var(--k, var(--text-secondary, #96a7c2)); }
++.td-wi-k .ovk-i, .td-wi-k svg { width: 15px; height: 15px; }
++.td-wi time { font-size: 12px; font-weight: 750; font-variant-numeric: tabular-nums; color: var(--text-secondary, #96a7c2); }
++.td-wi-t { grid-column: 2; font-size: 13px; font-weight: 650; line-height: 1.3; overflow-wrap: anywhere; }
++.td-wi-m { grid-column: 2; font-size: 11.5px; color: var(--text-muted, #6f819d); line-height: 1.3; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
++.td-wi.is-reminder { --k: var(--room-accent); }
++.td-wi.is-band { --k: color-mix(in srgb, var(--room-accent) 55%, #22c55e); }
++.td-wi.is-event { --k: #a78bfa; }
++.td-wi.is-stream { --k: #f43f5e; }
++.td-pweek { display: none; list-style: none; margin: 18px 0 0; padding: 0; }
++.td-pd { padding: 12px 0; border-top: 1px solid var(--border, #1f2d47); }
++.td-pd-h { margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; font-size: 15px; font-weight: 750; }
++.td-pd.is-today .td-pd-h { color: var(--room-strong, var(--room-accent)); }
++.td-pd.is-empty { padding: 10px 0; color: var(--text-muted, #6f819d); font-size: 13.5px; }
++@media (max-width: 960px) {
++ .td-week { display: none; }
++ .td-pweek { display: block; }
++ .td-wi a { grid-template-columns: auto auto minmax(0, 1fr); align-items: baseline; }
++ .td-wi-k { grid-row: auto; }
++ .td-wi-t { grid-column: 3; }
++ .td-wi-m { grid-column: 3; }
++}
++@media (max-width: 520px) { .td-quick { flex-direction: column; } .td-quick .ovk-btn { align-self: flex-start; } }
++/* The week's lines read as text (the room's link colour belongs to links in prose), and their times line up. */
++.td-wrap .td-wi a, .td-wrap .td-wi-t { color: var(--text-primary, #e8eef8); }
++.td-wi time { min-width: 2.9em; }
ddiff --git a/public/js/today.js b/public/js/today.js
iindex 7478a4e..8c43a92 100644
---- a/public/js/today.js
++++ b/public/js/today.js
@@@ -8,7 +8,21 @@
var zone = (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })();
function fmt(d, opts) { try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e) { return d.toISOString(); } }
++ // Your week (plan L48 R3) is drawn by the server in your zone: keep it in a cookie, and if this week page was drawn
++ // in another one, ask again once in yours.
++ try {
++ if (!/(?:^|; )td_tz=/.test(document.cookie) || decodeURIComponent((document.cookie.match(/(?:^|; )td_tz=([^;]*)/) || [])[1] || '') !== zone) {
++ document.cookie = 'td_tz=' + encodeURIComponent(zone) + '; path=/; max-age=31536000; samesite=lax' + (location.protocol === 'https:' ? '; secure' : '');
++ var wk = document.getElementById('td-week');
++ if (wk && wk.getAttribute('data-tz') !== zone && !/[?&]tz=/.test(location.search) && !sessionStorage.getItem('td-week-tz')) {
++ sessionStorage.setItem('td-week-tz', '1');
++ location.replace(location.pathname + location.search + (location.search ? '&' : '?') + 'tz=' + encodeURIComponent(zone));
++ }
++ }
++ } catch (e) { /* no cookies: the week is drawn in the zone of the newest reminder */ }
++
function run() {
++ document.querySelectorAll('input[data-tz]').forEach(function (el) { el.value = zone; });
// "Your time" on an event page.
document.querySelectorAll('[data-local="range"]').forEach(function (el) {
var a = new Date(el.getAttribute('data-start')), b = new Date(el.getAttribute('data-end'));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a36f6f1..d52c3dc 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -211,6 +211,29 @@ function createPageRoutes(ctx) {
});
});
++ // ── Your week (plan L48 R3): seven days in your own time zone ──
++ // The zone: ?tz=, else the td_tz cookie today.js keeps from the browser, else the zone of the person's newest
++ // reminder, else UTC.
++ r.get('/me/week', async (req, res) => {
++ if (!signedIn(req)) return page(req, res, { title: 'Your week', body: raw(views.signIn({ title: 'Your week', text: 'Your reminders, your events and the streams of the channels you follow, day by day.', next: '/me/week' })) }, 401);
++ const p = person(req);
++ const own = mine ? await mine.list(p.subject) : [];
++ const asked = typeof req.query.tz === 'string' ? req.query.tz : (req.cookies && req.cookies.td_tz) || '';
++ const tz = require('../when').validTz(asked) ? asked : ((own.find((x) => x.tz) || {}).tz || 'UTC');
++ const start = typeof req.query.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.start) ? req.query.start : null;
++ const first = require('../week').build({ tz, start, now: s.now(), reminders: [], events: [] });
++ const rows = await agenda.windowFor({ subject: p.subject, token: p.token, fromMs: Date.parse(first.from), toMs: Date.parse(first.to), now: s.now() });
++ const { wire: wireEvent } = require('../events/store');
++ const reminded = new Set((await s.db.many('SELECT event_id FROM today_reminders WHERE subject = $1', [p.subject])).map((x) => x.event_id));
++ const events = rows.map((x) => ({ ...wireEvent(x), own: x.owner_subject === p.subject, reminded: reminded.has(x.id) }));
++ const w = require('../week').build({ tz, start, now: s.now(), reminders: own, events });
++ return page(req, res, {
++ title: 'Your week',
++ description: 'Your reminders, your events and the streams of the channels you follow, day by day.',
++ body: raw(views.week({ name: req.viewer.displayName || req.viewer.username, week: w })),
++ });
++ });
++
// ── A person's own reminders (plan L48, R1): plain forms, so the page works without its script ──
const mineGuard = (req, res, next) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23reminders');
@@@ -222,6 +245,8 @@ function createPageRoutes(ctx) {
const b = req.body || {};
const out = await mine.create(req.viewer.subject, { text: b.text, tz: b.tz, source: 'today' });
if (out.error) return toMine(res, `?problem=${encodeURIComponent(out.error)}&said=${encodeURIComponent(String(b.text || '').slice(0, 500))}`);
++ // Added from the week: back to the week, the one the reminder lands in.
++ if (b.back === 'week') return res.redirect(303, '/me/week');
return toMine(res);
});
r.post('/me/reminders/:id/:act(done|snooze|remove)', express.urlencoded({ extended: false, limit: '2kb' }), mineGuard, async (req, res) => {
ddiff --git a/server/views/index.js b/server/views/index.js
iindex d37f2a9..68ec817 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -8,6 +8,7 @@
* channel({ username, rows, now }) a channel's schedule and its calendar
* event({ e, end, reminder, own, signedIn, path }) one event: when, Remind, Add to calendar, Watch, Cancel (owner)
* me({ name, agenda, reminders, feedUrl, now }) the person's agenda, reminders and private feed
++ * week({ name, week }) the person's week, day by day (server/week.js, plan L48 R3)
* brief({ out, name, tz }) the day's brief
* schedule({ values, problem, zones }) the schedule form
* signIn({ title, text, next }) a page that needs an account
@@@ -252,7 +253,7 @@ function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now =
return `<div class="td-wrap td-page-pad" id="td-me">
<header class="td-page-h"><p class="td-kicker">Your agenda</p><h1>The next two weeks</h1>
<p class="td-lede">Your own events, the ones you set reminders on, and the channels you follow, ${esc(name || 'you')}.</p>
--<div class="td-acts">${kit.button({ href: '/schedule', label: 'Schedule a stream', kind: 'primary' })}${kit.button({ href: '/brief', label: 'Your brief', kind: 'quiet' })}</div></header>
++<div class="td-acts">${kit.button({ href: '/me/week', label: 'Your week', kind: 'primary' })}${kit.button({ href: '/schedule', label: 'Schedule a stream', kind: 'quiet' })}${kit.button({ href: '/brief', label: 'Your brief', kind: 'quiet' })}</div></header>
${myReminders({ mine, problem, said })}
${agenda.follows_unavailable ? '<p class="td-note" role="status">OpenVibe.Network did not answer, so the channels you follow are missing for a moment. Your own and reminded events are all here.</p>' : ''}
<div class="td-me-grid"><div>${days(agenda.events || [], { now, empty: 'Nothing in the next two weeks. Follow a channel on Live, or set a reminder on something coming up.' })}</div>
@@@ -269,6 +270,46 @@ ${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/
</div>`;
}
++/**
++ * The person's week (plan L48 R3): seven days from Monday in their own time zone. On a computer, seven columns; on a
++ * phone, the days one under another, with the empty ones said in one line. Each line is a time, what it is, and a
++ * mark for its kind: a reminder (once or repeating), a repeating reminder's span for the day, their own event, or a
++ * stream of a channel they follow.
++ */
++function week({ name, week: w }) {
++ const KIND = { reminder: ['bell', 'Reminder'], band: ['retry', 'Repeats'], event: ['calendar', 'Your event'], stream: ['live', 'Stream'] };
++ const item = (it) => {
++ const [icon, word] = KIND[it.kind] || KIND.reminder;
++ const meta = it.kind === 'band' ? it.summary : it.kind === 'stream' ? (it.channel ? `@${it.channel}` : word) : it.kind === 'reminder' && it.repeats ? 'Repeats' : word;
++ return `<li class="td-wi is-${esc(it.kind)}${it.live ? ' is-live' : ''}"><a href="${esc(it.href)}"><span class="td-wi-k" aria-hidden="true">${kit.icon(icon)}</span><time datetime="${esc(it.at)}">${esc(it.time)}</time><span class="td-wi-t">${esc(it.title)}</span><span class="td-wi-m">${it.live ? '<span class="td-pill is-live">Live now</span>' : ''}<span class="ovk-sr">${esc(word)}: </span>${esc(meta)}</span></a></li>`;
++ };
++ // On a phone, a run of empty days is one line ("Nothing on Sat and Sun").
++ const runs = [];
++ for (const d of w.days) {
++ const last = runs[runs.length - 1];
++ if (!d.items.length && !d.today && last && last.empty) last.days.push(d); else runs.push({ empty: !d.items.length && !d.today, days: [d] });
++ }
++ const list = (arr) => (arr.length < 2 ? arr.join('') : `${arr.slice(0, -1).join(', ')} and ${arr[arr.length - 1]}`);
++ const cols = w.days.map((d) => `<li class="td-wd${d.today ? ' is-today' : ''}${d.items.length ? '' : ' is-empty'}" aria-label="${esc(d.long)}${d.today ? ', today' : ''}">
++<h2 class="td-wd-h"><span>${esc(d.label.split(' ')[0])}</span><b>${esc(String(d.date.d))}</b>${d.today ? '<i>Today</i>' : ''}</h2>
++${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p class="td-wd-none">Nothing</p>'}</li>`).join('');
++ const phone = runs.map((r) => (r.empty
++ ? `<li class="td-pd is-empty">Nothing on ${esc(list(r.days.map((d) => d.label)))}</li>`
++ : r.days.map((d) => `<li class="td-pd${d.today ? ' is-today' : ''}"><h2 class="td-pd-h">${esc(d.long)}${d.today ? ' <i>Today</i>' : ''}</h2>${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p class="td-wd-none">Nothing yet</p>'}</li>`).join(''))).join('');
++ const count = w.days.reduce((a, d) => a + d.items.length, 0);
++ return `<div class="td-wrap td-page-pad" id="td-week" data-tz="${esc(w.tz)}">
++<header class="td-page-h"><p class="td-kicker">Your week</p><h1>${esc(w.title)}</h1>
++<p class="td-lede">${count ? `${count} thing${count === 1 ? '' : 's'} this week` : 'A clear week so far'}: your reminders, your events and the streams of the channels you follow${name ? `, ${esc(name)}` : ''}. Times in ${esc(w.tz.replace(/_/g, ' '))}.</p>
++<nav class="td-weeknav" aria-label="Weeks"><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week?start=${esc(w.prev)}" rel="prev">${kit.icon('back')}<span>Last week</span></a>${w.thisWeek ? '' : '<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week">This week</a>'}<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me/week?start=${esc(w.next)}" rel="next"><span>Next week</span>${kit.icon('chevron')}</a><a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/me">As a list</a></nav>
++<form class="td-quick" method="post" action="/me/reminders"><input type="hidden" name="back" value="week"><input type="hidden" name="tz" value="${esc(w.tz)}" data-tz>
++<label class="ovk-sr" for="td-quick-text">Add a reminder in plain words</label><input id="td-quick-text" name="text" maxlength="500" required placeholder="Remind me… every weekday at 9, tomorrow at 3pm, every 2 hours from 9 to 5" autocomplete="off">
++<button class="ovk-btn ovk-btn--primary ovk-btn--small" type="submit">${kit.icon('plus')}Add</button></form>
++</header>
++<ol class="td-week" aria-label="The week">${cols}</ol>
++<ol class="td-pweek" aria-label="The week, day by day">${phone}</ol>
++</div>`;
++}
++
function brief({ out, name, tz = 'UTC' } = {}) {
const ACT = { remind: 'Remind', watch: 'Watch', buy: 'Buy from', approve: 'Approve', open: 'Open' };
const sections = (out && out.sections) || [];
@@@ -338,4 +379,4 @@ function notFound() {
return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty">${kit.icon('calendar')}<h1>Not on the calendar</h1><p>There is nothing at this address. It may have been cancelled, or it is for followers only.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div></div>`;
}
--module.exports = { home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };
++module.exports = { week, home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };
ddiff --git a/server/week.js b/server/week.js
nnew file mode 100644
iindex 0000000..7e518fc
---- /dev/null
++++ b/server/week.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * A person's week (plan L48 R3; the owner, 11-01: "view it through openvibe.today… calendar type shit"): seven days
++ * from a Monday, in the person's own time zone, each with what happens in it, in time order:
++ * - their own reminders (server/my-reminders.js): each time a rule fires that week, except a rule that fires every
++ * few minutes or hours (interval), which is one line a day with its span ("Every 2 hours, 9:00–17:00");
++ * - their events, the events they set a reminder on, and the events of the channels they follow (server/agenda.js).
++ *
++ * build({ tz, start, now, reminders, events }) → { tz, start, prev, next, thisWeek, days: [{ key, label, date, today,
++ * items: [{ kind, at, time, title, href, … }] }] }
++ * Pure: the callers fetch, this arranges. tz is an IANA zone (UTC when unknown); start is a YYYY-MM-DD (any day of the
++ * week it is in) or null for this week.
++ */
++const when = require('./when');
++
++const DAY_NAMES = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
++const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
++const PER_RULE = 60; // at most this many times one reminder shows in a week (a daily rule shows 7)
++
++const pad = (n) => String(n).padStart(2, '0');
++const keyOf = (p) => `${p.y}-${pad(p.m)}-${pad(p.d)}`;
++function addDays(y, m, d, n) {
++ const t = new Date(Date.UTC(y, m - 1, d + n));
++ return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate(), dow: ((t.getUTCDay() + 6) % 7) + 1 };
++}
++const clock = (p) => `${p.h}:${pad(p.min)}`;
++
++function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], events = [] } = {}) {
++ const zone = when.validTz(tz) ? tz : 'UTC';
++ const today = when.partsIn(new Date(now), zone);
++ // The Monday of the week asked for (or this one).
++ let base = today;
++ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(start || ''));
++ if (m) base = { ...addDays(+m[1], +m[2], +m[3], 0) };
++ const mon = addDays(base.y, base.m, base.d, -((base.dow || 1) - 1));
++ const dates = Array.from({ length: 7 }, (_, i) => addDays(mon.y, mon.m, mon.d, i));
++ const from = when.atLocal(dates[0].y, dates[0].m, dates[0].d, 0, 0, zone).getTime();
++ const end = addDays(dates[6].y, dates[6].m, dates[6].d, 1);
++ const to = when.atLocal(end.y, end.m, end.d, 0, 0, zone).getTime();
++ const todayKey = keyOf(today);
++ const days = dates.map((d) => ({
++ key: keyOf(d), date: d, today: keyOf(d) === todayKey,
++ label: `${DAY_NAMES[d.dow]} ${d.d}`, long: `${DAY_NAMES[d.dow]}, ${MONTHS[d.m]} ${d.d}`,
++ items: [],
++ }));
++ const dayOf = (ms) => days.find((x) => x.key === keyOf(when.partsIn(new Date(ms), zone))) || null;
++
++ // Reminders: each time it fires this week; an interval rule once a day, with its span.
++ for (const r of reminders) {
++ if (!r || r.status !== 'active' || !r.rule) continue;
++ const rz = when.validTz(r.tz) ? r.tz : zone;
++ if (r.rule.kind === 'interval') {
++ const seen = new Set();
++ let t = when.next(r.rule, { after: new Date(from - 1), tz: rz });
++ for (let i = 0; t && t.getTime() < to && i < 2000; i++) {
++ const day = dayOf(t.getTime());
++ if (day && !seen.has(day.key)) {
++ seen.add(day.key);
++ day.items.push({ kind: 'band', at: t.toISOString(), time: clock(when.partsIn(t, zone)), title: r.title, summary: when.describe(r.rule, { tz: rz }), href: '/me#reminders', id: r.id });
++ }
++ t = when.next(r.rule, { after: t, tz: rz });
++ }
++ continue;
++ }
++ let t = when.next(r.rule, { after: new Date(from - 1), tz: rz });
++ for (let i = 0; t && t.getTime() < to && i < PER_RULE; i++) {
++ const day = dayOf(t.getTime());
++ if (day) day.items.push({ kind: 'reminder', at: t.toISOString(), time: clock(when.partsIn(t, zone)), title: r.title, repeats: r.rule.kind !== 'once', href: '/me#reminders', id: r.id });
++ t = when.next(r.rule, { after: t, tz: rz });
++ }
++ }
++ // Events: theirs, reminded, and the channels they follow.
++ for (const e of events) {
++ if (!e || e.status === 'cancelled') continue;
++ const at = Date.parse(e.starts_at);
++ if (!(at >= from && at < to)) continue;
++ const day = dayOf(at);
++ if (!day) continue;
++ day.items.push({ kind: e.own ? 'event' : 'stream', at: new Date(at).toISOString(), time: clock(when.partsIn(new Date(at), zone)), title: e.title, channel: e.channel || null, live: e.status === 'live', reminded: !!e.reminded, href: `/e/${e.id}`, id: e.id });
++ }
++ days.forEach((d) => d.items.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)));
++ const prev = addDays(mon.y, mon.m, mon.d, -7), next = addDays(mon.y, mon.m, mon.d, 7);
++ const thisMon = addDays(today.y, today.m, today.d, -((today.dow || 1) - 1));
++ return {
++ tz: zone, from: new Date(from).toISOString(), to: new Date(to).toISOString(), start: keyOf(mon),
++ prev: keyOf(prev), next: keyOf(next), thisWeek: keyOf(mon) === keyOf(thisMon),
++ title: `${MONTHS[dates[0].m]} ${dates[0].d} – ${dates[0].m === dates[6].m ? '' : `${MONTHS[dates[6].m]} `}${dates[6].d}, ${dates[6].y}`,
++ days,
++ };
++}
++
++module.exports = { build };
ddiff --git a/test/week.test.js b/test/week.test.js
nnew file mode 100644
iindex 0000000..afe955b
---- /dev/null
++++ b/test/week.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * Your week (plan L48 R3): seven days from Monday in the person's own zone, each with its reminders' times (an
++ * interval rule once a day, with its span), their own events and their followed channels' streams, in time order;
++ * across a clock change; and the page: the zone from ?tz=, the cookie or the newest reminder; the week's navigation;
++ * the quick box that adds a reminder and comes back to the week; signed out, sign in.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const week = require('../server/week');
++
++(async () => {
++ await check('the week: Monday to Sunday in the zone, reminders at their times, an interval once a day, events in order', () => {
++ const now = Date.parse('2026-11-04T15:00:00Z'); // Wednesday afternoon in Paris
++ const w = week.build({
++ tz: 'Europe/Paris', now,
++ reminders: [
++ { status: 'active', title: 'Stretch', tz: 'Europe/Paris', rule: { kind: 'interval', minutes: 120, from: '09:00', to: '17:00', days: [1, 2, 3, 4, 5] } },
++ { status: 'active', title: 'Standup', tz: 'Europe/Paris', rule: { kind: 'daily', times: ['09:30'], days: [1, 2, 3, 4, 5] } },
++ { status: 'active', title: 'Call Sam', tz: 'Europe/Paris', rule: { kind: 'once', at: '2026-11-06T08:00:00Z' } },
++ { status: 'done', title: 'Old one', tz: 'Europe/Paris', rule: { kind: 'daily', times: ['10:00'] } },
++ ],
++ events: [
++ { id: 'ev1', title: 'Speedrun night', starts_at: '2026-11-07T19:00:00Z', status: 'scheduled', channel: 'kai', own: false },
++ { id: 'ev2', title: 'My stream', starts_at: '2026-11-03T18:00:00Z', status: 'live', channel: 'ana', own: true },
++ { id: 'ev3', title: 'Cancelled', starts_at: '2026-11-05T18:00:00Z', status: 'cancelled' },
++ { id: 'ev4', title: 'Next week', starts_at: '2026-11-10T18:00:00Z', status: 'scheduled' },
++ ],
++ });
++ assert.deepStrictEqual([w.title, w.start, w.prev, w.next, w.thisWeek, w.tz], ['Nov 2 – 8, 2026', '2026-11-02', '2026-10-26', '2026-11-09', true, 'Europe/Paris']);
++ assert.deepStrictEqual(w.days.map((d) => d.label), ['Mon 2', 'Tue 3', 'Wed 4', 'Thu 5', 'Fri 6', 'Sat 7', 'Sun 8']);
++ assert.deepStrictEqual(w.days.map((d) => d.today), [false, false, true, false, false, false, false]);
++ const line = (d) => d.items.map((i) => `${i.kind} ${i.time} ${i.title}`);
++ assert.deepStrictEqual(line(w.days[0]), ['band 9:00 Stretch', 'reminder 9:30 Standup'], 'an interval rule is one line a day');
++ assert.match(w.days[0].items[0].summary, /Every 2 hours/);
++ assert.deepStrictEqual(line(w.days[1]), ['band 9:00 Stretch', 'reminder 9:30 Standup', 'event 19:00 My stream'], 'their own event, at its local time');
++ assert.ok(w.days[1].items[2].live, 'live now');
++ assert.deepStrictEqual(line(w.days[4]), ['band 9:00 Stretch', 'reminder 9:00 Call Sam', 'reminder 9:30 Standup']);
++ assert.deepStrictEqual(line(w.days[5]), ['stream 20:00 Speedrun night'], 'a followed channel\'s stream; nothing cancelled, nothing done, nothing from next week');
++ assert.deepStrictEqual(line(w.days[6]), []);
++ const other = week.build({ tz: 'Europe/Paris', now, start: '2026-11-11' });
++ assert.deepStrictEqual([other.start, other.thisWeek], ['2026-11-09', false], 'any day picks its Monday');
++ assert.strictEqual(week.build({ tz: 'Mars/Olympus', now }).tz, 'UTC', 'an unknown zone is UTC');
++ });
++
++ await check('across a clock change a daily reminder keeps its hour, and the week still has seven days', () => {
++ const w = week.build({ tz: 'America/New_York', now: Date.parse('2026-10-29T15:00:00Z'), reminders: [{ status: 'active', title: 'Walk', tz: 'America/New_York', rule: { kind: 'daily', times: ['09:00'] } }] });
++ assert.strictEqual(w.days.length, 7);
++ assert.deepStrictEqual(w.days.map((d) => d.items.map((i) => i.time).join()), ['9:00', '9:00', '9:00', '9:00', '9:00', '9:00', '9:00'], 'clocks went back on Sunday 1 November');
++ });
++
++ const t = await boot();
++ const ana = t.network.addUser('ana');
++ try {
++ await check('the page: the zone, the days, the navigation, the quick box; signed out, sign in', async () => {
++ assert.strictEqual((await t.get('/me/week')).status, 401);
++ const made = await t.get('/me/reminders', { as: ana, form: { text: 'standup weekdays at 9:30', tz: 'Asia/Tokyo' } });
++ assert.strictEqual(made.status, 303);
++ const page = await t.get('/me/week', { as: ana });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<div class="td-wrap td-page-pad" id="td-week" data-tz="Asia\/Tokyo">/, 'the zone of the newest reminder, when the browser has not said');
++ assert.strictEqual((page.text.match(/<li class="td-wd/g) || []).length, 7, 'seven days');
++ assert.ok(page.text.includes('Standup'), 'the reminder shows');
++ assert.match(page.text, /href="\/me\/week\?start=\d{4}-\d{2}-\d{2}" rel="prev"/);
++ const paris = await t.get('/me/week', { as: ana, headers: { cookie: 'td_tz=Europe%2FParis' } });
++ assert.match(paris.text, /data-tz="Europe\/Paris"/, 'the browser\'s zone, from its cookie');
++ const asked = await t.get('/me/week?tz=America%2FChicago&start=2026-11-11', { as: ana });
++ assert.match(asked.text, /data-tz="America\/Chicago"/);
++ assert.match(asked.text, /<h1>Nov 9 – 15, 2026<\/h1>/);
++ assert.match(asked.text, /<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="\/me\/week">This week<\/a>/, 'another week offers the way back');
++ const quick = await t.get('/me/reminders', { as: ana, form: { text: 'water the plants every Sunday at 10am', tz: 'Asia/Tokyo', back: 'week' } });
++ assert.strictEqual(quick.headers.get('location'), '/me/week', 'added from the week: back to the week');
++ assert.ok((await t.get('/me', { as: ana })).text.includes('href="/me/week"'), 'the agenda leads to the week');
++ });
++ } finally {
++ await done(t);
++ }
++})();