openvibers/openvibe.shared Commits
4.47.2: the updates log reads as changes, not commits (#191)
Changes
@@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.47.2 — 2026-10-30++++- **The updates log reads as changes, not commits.** `shipped.js`'s log folds the entries of one day that are the same++ change into one row: a pin bump by package and version ("Shared 4.45.0 reached 31 sites", with a link to what's in++ it), anything else by its subject without the PR number or a leading name of the entry's own site ("Touch targets on++ phones (fleet audit)" on fourteen sites). The row shows how many sites, the time span and the sites by name; every++ commit is one tap away in its fold. Groups continue across "Load more". openvibe.network/updates went from 50 rows++ to 9 with the same 50 commits.++- The OpenVibers account every commit shares is no longer named on each row; any other author still is.++- `groupEntries(entries, dayKey)`, `groupTitle(g)` and `groupNotes(g)` are exported, so a server-rendered page groups++ the same way.++## 4.47.1 — 2026-10-30- **No page is wider than a phone.** A sideways row (`.sc-row`, `.ovk-tabs`, `.ovk-stats`) is `position: relative`, so a@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.47.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.47.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.47.1",++ "version": "4.47.2","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.47.1",++ "version": "4.47.2","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -81,6 +81,12 @@`.ov-shipped-posts{border:1px solid ${BORDER};border-radius:12px;padding:12px 16px}.ov-shipped-posts h3{font-size:.95rem;margin:0 0 8px}.ov-shipped-posts ul{list-style:none;margin:0;padding:0;display:grid;gap:6px}`,`.ov-shipped-posts a{color:${ACCENT};text-decoration:none;font-weight:600}.ov-shipped-posts a:hover{text-decoration:underline}.ov-shipped-posts small{color:${MUTED};margin-left:6px}`,`.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`,++ // 4.47.2: the same change on many sites is one row (a pin round was 31 rows of the same line).++ '.ov-shipped-group{flex-wrap:wrap}',++ `.ov-shipped-count{flex:none;min-width:2em;text-align:center;font-size:.72rem;font-weight:800;padding:.12rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);color:${TEXT}}`,++ `.ov-shipped-notes{color:${ACCENT};font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap}.ov-shipped-notes:hover{text-decoration:underline}`,++ `.ov-shipped-fold{flex-basis:100%;min-width:0;font-size:.82rem;color:${MUTED}}.ov-shipped-fold summary{cursor:pointer;padding:3px 0;overflow-wrap:anywhere}`,++ `.ov-shipped-fold .ov-shipped-entries{margin:4px 0 2px;padding-left:10px;border-left:2px solid ${BORDER}}.ov-shipped-fold .ov-shipped-entry{padding:5px 8px}`,'@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}',].join('');const ROCKET = 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5';@@@ -253,6 +259,50 @@return render(node, await feed({ service: o.service, limit: Math.min(50, clamp(o.limit, 1, 20, 5) * 3), api: o.api }), o);}++ // ── grouping (4.47.2): one row for the same change on many sites ──++ // A pin round lands the same commit line on thirty sites within a minute, and a fleet fix ("Touch targets on++ // phones") on a dozen: shown one by one they bury everything else. Entries of one day with the same change fold++ // into one row: a pin bump by package and version, anything else by its subject without the PR number.++ const PIN_KEY = /^(openvibe-[a-z][a-z0-9-]*) v(\d+\.\d+\.\d+)(?: \(was v\d+\.\d+\.\d+\))?/;++ const PKG = { 'openvibe-shared': ['Shared', 'OpenVibe.Shared'], 'openvibe-sdk': ['SDK', 'OpenVibe.SDK'], 'openvibe-contracts': ['Contracts', 'OpenVibe.Contracts'] };++ // The subject without its PR number, and without a leading "Play: " naming the entry's own site, so "Play: sign-in++ // on …" and "Help: sign-in on …" are the same change.++ const squash = (x) => String(x || '').replace(/[\s.-]/g, '').toLowerCase();++ function plainSubject(e) {++ let s = String((e && e.subject) || '').replace(/\s*\(#\d+\)\s*$/, '').trim();++ const m = /^([A-Za-z][\w .-]{0,24}):\s+(.+)$/.exec(s);++ if (m && e) {++ const p = squash(m[1]).replace(/^openvibe/, '');++ if (p && (p === squash(e.service) || p === squash(siteName(e.service)))) s = m[2].charAt(0).toUpperCase() + m[2].slice(1);++ }++ return s;++ }++ function pinOf(e) {++ const m = PIN_KEY.exec(plainSubject(e));++ if (!m) return null;++ const k = PKG[m[1]] || [m[1].replace(/^openvibe-/, ''), null];++ return { pkg: m[1], version: m[2], name: k[0], repo: k[1] };++ }++ function groupKey(e) { const p = pinOf(e); return p ? `pin:${p.pkg}@${p.version}` : `s:${plainSubject(e).toLowerCase()}`; }++ /** Entries in their order, the same change on many sites folded into one group (exported: the server page uses it). */++ function groupEntries(entries, dayKey) {++ const out = [], at = new Map();++ for (const e of entries || []) {++ const k = `${dayKey ? dayKey(e) : ''}|${groupKey(e)}`;++ const g = at.get(k);++ if (g) g.entries.push(e);++ else { const ng = { key: k, entries: [e], pin: pinOf(e) }; at.set(k, ng); out.push(ng); }++ }++ return out;++ }++ /** What a group's row says: "Shared 4.45.0 reached 31 sites", or the shared subject. */++ function groupTitle(g) {++ const sites = new Set(g.entries.map((e) => e.service)).size;++ if (g.pin) return `${g.pin.name} ${g.pin.version} reached ${sites} ${sites === 1 ? 'site' : 'sites'}`;++ return plainSubject(g.entries[0]);++ }++ function groupNotes(g) { return g.pin && g.pin.repo ? `https://github.com/OpenVibers/${g.pin.repo}/blob/v${g.pin.version}/CHANGELOG.md` : null; }++// ── log: the updates page ───────────────────────────────────function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); }function entryRow(e, showSite) {@@@ -263,10 +313,32 @@row.appendChild(el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}` }, e.subject));const meta = el('span', { class: 'ov-shipped-meta' });const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });-- meta.textContent = `${e.author ? `${e.author} · ` : ''}${time}`;++ // Every commit is the OpenVibers account's: naming it on each row says nothing.++ meta.textContent = `${e.author && e.author !== 'OpenVibers' ? `${e.author} · ` : ''}${time}`;row.appendChild(meta);return row;}++ const hhmm = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });++ function groupRow(g, open) {++ const row = el('div', { class: 'ov-shipped-entry ov-shipped-group' });++ const sites = [...new Set(g.entries.map((e) => e.service))];++ row.appendChild(el('span', { class: 'ov-shipped-count', title: `${g.entries.length} changes` }, String(sites.length)));++ const text = el('span', { class: 'ov-shipped-text' }, groupTitle(g));++ const notes = groupNotes(g);++ if (notes) { text.appendChild(document.createTextNode(' ')); text.appendChild(el('a', { class: 'ov-shipped-notes', href: notes, target: '_blank', rel: 'noopener' }, "What's in it")); }++ row.appendChild(text);++ const first = hhmm(g.entries[g.entries.length - 1].deployed_at), last = hhmm(g.entries[0].deployed_at);++ row.appendChild(el('span', { class: 'ov-shipped-meta' }, first === last ? last : `${first}–${last}`));++ const fold = el('details', { class: 'ov-shipped-fold' });++ if (open) fold.open = true;++ const names = sites.map(siteName);++ fold.appendChild(el('summary', {}, names.length > 7 ? `${names.slice(0, 6).join(', ')} and ${names.length - 6} more` : names.join(', ')));++ const list = el('div', { class: 'ov-shipped-entries' });++ for (const e of g.entries) list.appendChild(entryRow(e, true));++ fold.appendChild(list);++ row.appendChild(fold);++ return row;++ }/** Append entries under day headings, continuing the last day already shown (exported for tests). */function appendDays(box, entries, showSite) {let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null;@@@ -278,7 +350,20 @@day.appendChild(el('div', { class: 'ov-shipped-entries' }));box.appendChild(day);}-- day.lastElementChild.appendChild(entryRow(e, showSite));++ // The same change again on this day: it joins that row (across "Load more" pages too).++ if (!day._groups) day._groups = new Map();++ const k = groupKey(e), hit = day._groups.get(k);++ if (hit) {++ hit.g.entries.push(e);++ const det = hit.node.querySelector && hit.node.querySelector('details');++ const fresh = groupRow(hit.g, Boolean(det && det.open));++ hit.node.replaceWith(fresh);++ hit.node = fresh;++ } else {++ const node = entryRow(e, showSite);++ day._groups.set(k, { g: { key: k, entries: [e], pin: pinOf(e) }, node });++ day.lastElementChild.appendChild(node);++ }}}async function log(target, opts) {@@@ -402,5 +487,5 @@return out;}-- return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, serviceFor, ago, API, SITE_NAMES, siteName, isHousekeeping };++ return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, groupEntries, groupTitle, groupNotes, serviceFor, ago, API, SITE_NAMES, siteName, isHousekeeping };}));@@@ -79,9 +79,29 @@ const data = {assert.strictEqual(box.querySelectorAll('.ov-shipped-day').length, 1, 'the same day continues');assert.strictEqual(box.querySelectorAll('.ov-shipped-entry').length, 2);assert.strictEqual(box.querySelectorAll('.ov-shipped-site').length, 2);-- assert.ok(box.querySelector('.ov-shipped-meta').textContent.startsWith('OpenVibers · '));++ assert.ok(!box.querySelector('.ov-shipped-meta').textContent.includes('OpenVibers'), 'the account every commit shares is not named on each row (4.47.2)');assert.strictEqual(box.querySelectorAll('.ov-shipped-hash')[1].textContent, 'bbbbbbb', 'short sha from the full sha');++ // 4.47.2: the same change on many sites is one row, across "Load more" pages too.++ const g = document.createElement('div');++ const pin = (svc, n, h) => ({ service: svc, sha: String(n).repeat(40).slice(0, 40), subject: `openvibe-shared v4.45.0 (was v4.43.0) (#${n})`, author: 'OpenVibers', deployed_at: at(h), url: `https://github.com/x/${svc}/commit/${n}` });++ shipped.appendDays(g, [pin('fun', 9, 17), { service: 'trade', sha: 'c'.repeat(40), subject: 'Touch targets on phones (fleet audit) (#141)', author: 'Someone', deployed_at: at(17), url: 'https://github.com/x/t/commit/c' }, pin('chat', 252, 17)], true);++ shipped.appendDays(g, [pin('cam', 7, 16), { service: 'search', sha: 'd'.repeat(40), subject: 'Touch targets on phones (fleet audit) (#139)', deployed_at: at(16), url: 'https://github.com/x/s/commit/d' }], true);++ const rows = g.querySelectorAll('.ov-shipped-entries > .ov-shipped-entry');++ assert.strictEqual(g.querySelectorAll('.ov-shipped-day > .ov-shipped-entries > .ov-shipped-entry').length, 2, 'five commits, two changes');++ assert.strictEqual(g.querySelector('.ov-shipped-group .ov-shipped-text').firstChild.textContent, 'Shared 4.45.0 reached 3 sites');++ assert.strictEqual(g.querySelector('.ov-shipped-group .ov-shipped-count').textContent, '3');++ assert.strictEqual(g.querySelector('.ov-shipped-notes').getAttribute('href'), 'https://github.com/OpenVibers/OpenVibe.Shared/blob/v4.45.0/CHANGELOG.md');++ assert.strictEqual(g.querySelectorAll('.ov-shipped-group .ov-shipped-fold .ov-shipped-entry').length, 5, 'every commit is still one tap away');++ assert.ok(rows.length >= 2 && [...g.querySelectorAll('.ov-shipped-group .ov-shipped-text')].some((t) => t.textContent === 'Touch targets on phones (fleet audit)'), 'the same subject on two sites is one row, without its PR number');++ assert.ok([...g.querySelectorAll('.ov-shipped-fold .ov-shipped-meta')].some((m) => m.textContent.startsWith('Someone · ')), 'another author is still named');++ const ge = shipped.groupEntries([pin('a', 1, 10), pin('b', 2, 10), { service: 'c', subject: 'Other' }], () => 'd');++ assert.deepStrictEqual(ge.map((x) => x.entries.length), [2, 1]);++ assert.strictEqual(shipped.groupTitle(ge[0]), 'Shared 4.45.0 reached 2 sites');++ const pre = shipped.groupEntries([{ service: 'play', subject: 'Play: sign-in on openvibe-sdk/sso 0.73.0 (#86)' }, { service: 'media-hub', subject: 'MediaHub: sign-in on openvibe-sdk/sso 0.73.0 (#197)' }, { service: 'help', subject: 'Fix: sign-in on openvibe-sdk/sso 0.73.0' }]);++ assert.deepStrictEqual(pre.map((x) => x.entries.length), [2, 1], "a leading name of the entry's own site is not part of the change; any other prefix is");++ assert.strictEqual(shipped.groupTitle(pre[0]), 'Sign-in on openvibe-sdk/sso 0.73.0');++// serviceFor: hosts to registry ids.const sf = shipped.serviceFor;assert.strictEqual(sf('openvibe.wiki'), 'wiki');
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.47.2 — 2026-10-30
+
+- **The updates log reads as changes, not commits.** `shipped.js`'s log folds the entries of one day that are the same
+ change into one row: a pin bump by package and version ("Shared 4.45.0 reached 31 sites", with a link to what's in
+ it), anything else by its subject without the PR number or a leading name of the entry's own site ("Touch targets on
+ phones (fleet audit)" on fourteen sites). The row shows how many sites, the time span and the sites by name; every
+ commit is one tap away in its fold. Groups continue across "Load more". openvibe.network/updates went from 50 rows
+ to 9 with the same 50 commits.
+- The OpenVibers account every commit shares is no longer named on each row; any other author still is.
+- `groupEntries(entries, dayKey)`, `groupTitle(g)` and `groupNotes(g)` are exported, so a server-rendered page groups
+ the same way.
+
## 4.47.1 — 2026-10-30
## 4.47.1 — 2026-10-30
- **No page is wider than a phone.** A sideways row (`.sc-row`, `.ovk-tabs`, `.ovk-stats`) is `position: relative`, so a
- **No page is wider than a phone.** A sideways row (`.sc-row`, `.ovk-tabs`, `.ovk-stats`) is `position: relative`, so a
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.47.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.47.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.47.1",
+ "version": "4.47.2",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.47.1",
+ "version": "4.47.2",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -81,6 +81,12 @@
@@ -81,6 +81,12 @@
`.ov-shipped-posts{border:1px solid ${BORDER};border-radius:12px;padding:12px 16px}.ov-shipped-posts h3{font-size:.95rem;margin:0 0 8px}.ov-shipped-posts ul{list-style:none;margin:0;padding:0;display:grid;gap:6px}`,
`.ov-shipped-posts{border:1px solid ${BORDER};border-radius:12px;padding:12px 16px}.ov-shipped-posts h3{font-size:.95rem;margin:0 0 8px}.ov-shipped-posts ul{list-style:none;margin:0;padding:0;display:grid;gap:6px}`,
`.ov-shipped-posts a{color:${ACCENT};text-decoration:none;font-weight:600}.ov-shipped-posts a:hover{text-decoration:underline}.ov-shipped-posts small{color:${MUTED};margin-left:6px}`,
`.ov-shipped-posts a{color:${ACCENT};text-decoration:none;font-weight:600}.ov-shipped-posts a:hover{text-decoration:underline}.ov-shipped-posts small{color:${MUTED};margin-left:6px}`,
`.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`,
`.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`,
+ // 4.47.2: the same change on many sites is one row (a pin round was 31 rows of the same line).
+ '.ov-shipped-group{flex-wrap:wrap}',
+ `.ov-shipped-count{flex:none;min-width:2em;text-align:center;font-size:.72rem;font-weight:800;padding:.12rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);color:${TEXT}}`,
+ `.ov-shipped-notes{color:${ACCENT};font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap}.ov-shipped-notes:hover{text-decoration:underline}`,
+ `.ov-shipped-fold{flex-basis:100%;min-width:0;font-size:.82rem;color:${MUTED}}.ov-shipped-fold summary{cursor:pointer;padding:3px 0;overflow-wrap:anywhere}`,
+ `.ov-shipped-fold .ov-shipped-entries{margin:4px 0 2px;padding-left:10px;border-left:2px solid ${BORDER}}.ov-shipped-fold .ov-shipped-entry{padding:5px 8px}`,
'@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}',
'@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}',
].join('');
].join('');
const ROCKET = 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5';
const ROCKET = 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5';
@@ -253,6 +259,50 @@
@@ -253,6 +259,50 @@
return render(node, await feed({ service: o.service, limit: Math.min(50, clamp(o.limit, 1, 20, 5) * 3), api: o.api }), o);
return render(node, await feed({ service: o.service, limit: Math.min(50, clamp(o.limit, 1, 20, 5) * 3), api: o.api }), o);
}
}
+ // ── grouping (4.47.2): one row for the same change on many sites ──
+ // A pin round lands the same commit line on thirty sites within a minute, and a fleet fix ("Touch targets on
+ // phones") on a dozen: shown one by one they bury everything else. Entries of one day with the same change fold
+ // into one row: a pin bump by package and version, anything else by its subject without the PR number.
+ const PIN_KEY = /^(openvibe-[a-z][a-z0-9-]*) v(\d+\.\d+\.\d+)(?: \(was v\d+\.\d+\.\d+\))?/;
+ const PKG = { 'openvibe-shared': ['Shared', 'OpenVibe.Shared'], 'openvibe-sdk': ['SDK', 'OpenVibe.SDK'], 'openvibe-contracts': ['Contracts', 'OpenVibe.Contracts'] };
+ // The subject without its PR number, and without a leading "Play: " naming the entry's own site, so "Play: sign-in
+ // on …" and "Help: sign-in on …" are the same change.
+ const squash = (x) => String(x || '').replace(/[\s.-]/g, '').toLowerCase();
+ function plainSubject(e) {
+ let s = String((e && e.subject) || '').replace(/\s*\(#\d+\)\s*$/, '').trim();
+ const m = /^([A-Za-z][\w .-]{0,24}):\s+(.+)$/.exec(s);
+ if (m && e) {
+ const p = squash(m[1]).replace(/^openvibe/, '');
+ if (p && (p === squash(e.service) || p === squash(siteName(e.service)))) s = m[2].charAt(0).toUpperCase() + m[2].slice(1);
+ }
+ return s;
+ }
+ function pinOf(e) {
+ const m = PIN_KEY.exec(plainSubject(e));
+ if (!m) return null;
+ const k = PKG[m[1]] || [m[1].replace(/^openvibe-/, ''), null];
+ return { pkg: m[1], version: m[2], name: k[0], repo: k[1] };
+ }
+ function groupKey(e) { const p = pinOf(e); return p ? `pin:${p.pkg}@${p.version}` : `s:${plainSubject(e).toLowerCase()}`; }
+ /** Entries in their order, the same change on many sites folded into one group (exported: the server page uses it). */
+ function groupEntries(entries, dayKey) {
+ const out = [], at = new Map();
+ for (const e of entries || []) {
+ const k = `${dayKey ? dayKey(e) : ''}|${groupKey(e)}`;
+ const g = at.get(k);
+ if (g) g.entries.push(e);
+ else { const ng = { key: k, entries: [e], pin: pinOf(e) }; at.set(k, ng); out.push(ng); }
+ }
+ return out;
+ }
+ /** What a group's row says: "Shared 4.45.0 reached 31 sites", or the shared subject. */
+ function groupTitle(g) {
+ const sites = new Set(g.entries.map((e) => e.service)).size;
+ if (g.pin) return `${g.pin.name} ${g.pin.version} reached ${sites} ${sites === 1 ? 'site' : 'sites'}`;
+ return plainSubject(g.entries[0]);
+ }
+ function groupNotes(g) { return g.pin && g.pin.repo ? `https://github.com/OpenVibers/${g.pin.repo}/blob/v${g.pin.version}/CHANGELOG.md` : null; }
+
// ── log: the updates page ───────────────────────────────────
// ── log: the updates page ───────────────────────────────────
function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); }
function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); }
function entryRow(e, showSite) {
function entryRow(e, showSite) {
@@ -263,10 +313,32 @@
@@ -263,10 +313,32 @@
row.appendChild(el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}` }, e.subject));
row.appendChild(el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}` }, e.subject));
const meta = el('span', { class: 'ov-shipped-meta' });
const meta = el('span', { class: 'ov-shipped-meta' });
const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
- meta.textContent = `${e.author ? `${e.author} · ` : ''}${time}`;
+ // Every commit is the OpenVibers account's: naming it on each row says nothing.
+ meta.textContent = `${e.author && e.author !== 'OpenVibers' ? `${e.author} · ` : ''}${time}`;
row.appendChild(meta);
row.appendChild(meta);
return row;
return row;
}
}
+ const hhmm = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+ function groupRow(g, open) {
+ const row = el('div', { class: 'ov-shipped-entry ov-shipped-group' });
+ const sites = [...new Set(g.entries.map((e) => e.service))];
+ row.appendChild(el('span', { class: 'ov-shipped-count', title: `${g.entries.length} changes` }, String(sites.length)));
+ const text = el('span', { class: 'ov-shipped-text' }, groupTitle(g));
+ const notes = groupNotes(g);
+ if (notes) { text.appendChild(document.createTextNode(' ')); text.appendChild(el('a', { class: 'ov-shipped-notes', href: notes, target: '_blank', rel: 'noopener' }, "What's in it")); }
+ row.appendChild(text);
+ const first = hhmm(g.entries[g.entries.length - 1].deployed_at), last = hhmm(g.entries[0].deployed_at);
+ row.appendChild(el('span', { class: 'ov-shipped-meta' }, first === last ? last : `${first}–${last}`));
+ const fold = el('details', { class: 'ov-shipped-fold' });
+ if (open) fold.open = true;
+ const names = sites.map(siteName);
+ fold.appendChild(el('summary', {}, names.length > 7 ? `${names.slice(0, 6).join(', ')} and ${names.length - 6} more` : names.join(', ')));
+ const list = el('div', { class: 'ov-shipped-entries' });
+ for (const e of g.entries) list.appendChild(entryRow(e, true));
+ fold.appendChild(list);
+ row.appendChild(fold);
+ return row;
+ }
/** Append entries under day headings, continuing the last day already shown (exported for tests). */
/** Append entries under day headings, continuing the last day already shown (exported for tests). */
function appendDays(box, entries, showSite) {
function appendDays(box, entries, showSite) {
let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null;
let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null;
@@ -278,7 +350,20 @@
@@ -278,7 +350,20 @@
day.appendChild(el('div', { class: 'ov-shipped-entries' }));
day.appendChild(el('div', { class: 'ov-shipped-entries' }));
box.appendChild(day);
box.appendChild(day);
}
}
- day.lastElementChild.appendChild(entryRow(e, showSite));
+ // The same change again on this day: it joins that row (across "Load more" pages too).
+ if (!day._groups) day._groups = new Map();
+ const k = groupKey(e), hit = day._groups.get(k);
+ if (hit) {
+ hit.g.entries.push(e);
+ const det = hit.node.querySelector && hit.node.querySelector('details');
+ const fresh = groupRow(hit.g, Boolean(det && det.open));
+ hit.node.replaceWith(fresh);
+ hit.node = fresh;
+ } else {
+ const node = entryRow(e, showSite);
+ day._groups.set(k, { g: { key: k, entries: [e], pin: pinOf(e) }, node });
+ day.lastElementChild.appendChild(node);
+ }
}
}
}
}
async function log(target, opts) {
async function log(target, opts) {
@@ -402,5 +487,5 @@
@@ -402,5 +487,5 @@
return out;
return out;
}
}
- return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, serviceFor, ago, API, SITE_NAMES, siteName, isHousekeeping };
+ return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, groupEntries, groupTitle, groupNotes, serviceFor, ago, API, SITE_NAMES, siteName, isHousekeeping };
}));
}));
@@ -79,9 +79,29 @@ const data = {
@@ -79,9 +79,29 @@ const data = {
assert.strictEqual(box.querySelectorAll('.ov-shipped-day').length, 1, 'the same day continues');
assert.strictEqual(box.querySelectorAll('.ov-shipped-day').length, 1, 'the same day continues');
assert.strictEqual(box.querySelectorAll('.ov-shipped-entry').length, 2);
assert.strictEqual(box.querySelectorAll('.ov-shipped-entry').length, 2);
assert.strictEqual(box.querySelectorAll('.ov-shipped-site').length, 2);
assert.strictEqual(box.querySelectorAll('.ov-shipped-site').length, 2);
- assert.ok(box.querySelector('.ov-shipped-meta').textContent.startsWith('OpenVibers · '));
+ assert.ok(!box.querySelector('.ov-shipped-meta').textContent.includes('OpenVibers'), 'the account every commit shares is not named on each row (4.47.2)');
assert.strictEqual(box.querySelectorAll('.ov-shipped-hash')[1].textContent, 'bbbbbbb', 'short sha from the full sha');
assert.strictEqual(box.querySelectorAll('.ov-shipped-hash')[1].textContent, 'bbbbbbb', 'short sha from the full sha');
+ // 4.47.2: the same change on many sites is one row, across "Load more" pages too.
+ const g = document.createElement('div');
+ const pin = (svc, n, h) => ({ service: svc, sha: String(n).repeat(40).slice(0, 40), subject: `openvibe-shared v4.45.0 (was v4.43.0) (#${n})`, author: 'OpenVibers', deployed_at: at(h), url: `https://github.com/x/${svc}/commit/${n}` });
+ shipped.appendDays(g, [pin('fun', 9, 17), { service: 'trade', sha: 'c'.repeat(40), subject: 'Touch targets on phones (fleet audit) (#141)', author: 'Someone', deployed_at: at(17), url: 'https://github.com/x/t/commit/c' }, pin('chat', 252, 17)], true);
+ shipped.appendDays(g, [pin('cam', 7, 16), { service: 'search', sha: 'd'.repeat(40), subject: 'Touch targets on phones (fleet audit) (#139)', deployed_at: at(16), url: 'https://github.com/x/s/commit/d' }], true);
+ const rows = g.querySelectorAll('.ov-shipped-entries > .ov-shipped-entry');
+ assert.strictEqual(g.querySelectorAll('.ov-shipped-day > .ov-shipped-entries > .ov-shipped-entry').length, 2, 'five commits, two changes');
+ assert.strictEqual(g.querySelector('.ov-shipped-group .ov-shipped-text').firstChild.textContent, 'Shared 4.45.0 reached 3 sites');
+ assert.strictEqual(g.querySelector('.ov-shipped-group .ov-shipped-count').textContent, '3');
+ assert.strictEqual(g.querySelector('.ov-shipped-notes').getAttribute('href'), 'https://github.com/OpenVibers/OpenVibe.Shared/blob/v4.45.0/CHANGELOG.md');
+ assert.strictEqual(g.querySelectorAll('.ov-shipped-group .ov-shipped-fold .ov-shipped-entry').length, 5, 'every commit is still one tap away');
+ assert.ok(rows.length >= 2 && [...g.querySelectorAll('.ov-shipped-group .ov-shipped-text')].some((t) => t.textContent === 'Touch targets on phones (fleet audit)'), 'the same subject on two sites is one row, without its PR number');
+ assert.ok([...g.querySelectorAll('.ov-shipped-fold .ov-shipped-meta')].some((m) => m.textContent.startsWith('Someone · ')), 'another author is still named');
+ const ge = shipped.groupEntries([pin('a', 1, 10), pin('b', 2, 10), { service: 'c', subject: 'Other' }], () => 'd');
+ assert.deepStrictEqual(ge.map((x) => x.entries.length), [2, 1]);
+ assert.strictEqual(shipped.groupTitle(ge[0]), 'Shared 4.45.0 reached 2 sites');
+ const pre = shipped.groupEntries([{ service: 'play', subject: 'Play: sign-in on openvibe-sdk/sso 0.73.0 (#86)' }, { service: 'media-hub', subject: 'MediaHub: sign-in on openvibe-sdk/sso 0.73.0 (#197)' }, { service: 'help', subject: 'Fix: sign-in on openvibe-sdk/sso 0.73.0' }]);
+ assert.deepStrictEqual(pre.map((x) => x.entries.length), [2, 1], "a leading name of the entry's own site is not part of the change; any other prefix is");
+ assert.strictEqual(shipped.groupTitle(pre[0]), 'Sign-in on openvibe-sdk/sso 0.73.0');
+
// serviceFor: hosts to registry ids.
// serviceFor: hosts to registry ids.
const sf = shipped.serviceFor;
const sf = shipped.serviceFor;
assert.strictEqual(sf('openvibe.wiki'), 'wiki');
assert.strictEqual(sf('openvibe.wiki'), 'wiki');