openvibers/openvibe.today Commits
Your week: openvibe.today/me/week, day by day (#42)
Changes
@@@ -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; }@@@ -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'));@@@ -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) => {@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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);++ }++})();
@@ -223,3 +223,49 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
@@ -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-row { grid-template-columns: auto minmax(0, 1fr); }
.td-mine-acts { grid-column: 1 / -1; justify-content: flex-start; }
.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; }
@@ -8,7 +8,21 @@
@@ -8,7 +8,21 @@
var zone = (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })();
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(); } }
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() {
function run() {
+ document.querySelectorAll('input[data-tz]').forEach(function (el) { el.value = zone; });
// "Your time" on an event page.
// "Your time" on an event page.
document.querySelectorAll('[data-local="range"]').forEach(function (el) {
document.querySelectorAll('[data-local="range"]').forEach(function (el) {
var a = new Date(el.getAttribute('data-start')), b = new Date(el.getAttribute('data-end'));
var a = new Date(el.getAttribute('data-start')), b = new Date(el.getAttribute('data-end'));
@@ -211,6 +211,29 @@ function createPageRoutes(ctx) {
@@ -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 ──
// ── A person's own reminders (plan L48, R1): plain forms, so the page works without its script ──
const mineGuard = (req, res, next) => {
const mineGuard = (req, res, next) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23reminders');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23reminders');
@@ -222,6 +245,8 @@ function createPageRoutes(ctx) {
@@ -222,6 +245,8 @@ function createPageRoutes(ctx) {
const b = req.body || {};
const b = req.body || {};
const out = await mine.create(req.viewer.subject, { text: b.text, tz: b.tz, source: 'today' });
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))}`);
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);
return toMine(res);
});
});
r.post('/me/reminders/:id/:act(done|snooze|remove)', express.urlencoded({ extended: false, limit: '2kb' }), mineGuard, async (req, res) => {
r.post('/me/reminders/:id/:act(done|snooze|remove)', express.urlencoded({ extended: false, limit: '2kb' }), mineGuard, async (req, res) => {
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* channel({ username, rows, now }) a channel's schedule and its calendar
* 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)
* 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
* 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
* brief({ out, name, tz }) the day's brief
* schedule({ values, problem, zones }) the schedule form
* schedule({ values, problem, zones }) the schedule form
* signIn({ title, text, next }) a page that needs an account
* signIn({ title, text, next }) a page that needs an account
@@ -252,7 +253,7 @@ function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now =
@@ -252,7 +253,7 @@ function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now =
return `<div class="td-wrap td-page-pad" id="td-me">
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>
<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>
<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 })}
${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>' : ''}
${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>
<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="/
@@ -269,6 +270,46 @@ ${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/
</div>`;
</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' } = {}) {
function brief({ out, name, tz = 'UTC' } = {}) {
const ACT = { remind: 'Remind', watch: 'Watch', buy: 'Buy from', approve: 'Approve', open: 'Open' };
const ACT = { remind: 'Remind', watch: 'Watch', buy: 'Buy from', approve: 'Approve', open: 'Open' };
const sections = (out && out.sections) || [];
const sections = (out && out.sections) || [];
@@ -338,4 +379,4 @@ function notFound() {
@@ -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>`;
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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);
+ }
+})();