| 1 | <!-- 🤖 The music ledger: what was playing on contributor machines while |
| 2 | Eleven was built — the soundtrack companion to the carbon and prompt |
| 3 | ledgers. Data is COMMITTED like the carbon tally and SHARDED BY MONTH: |
| 4 | stream-first: the page prefers the live ledger stream's derived views |
| 5 | (./ledgers/music/…, hash-chained appends — issue #1224) and falls back |
| 6 | to the committed files. Either way ./music.json is the index (months + |
| 7 | totals), ./music-YYYY-MM.json |
| 8 | the shards, accrued by tools/music-ledger.mjs (skips filtered, album |
| 9 | art deduped per album and inlined as data URIs — the pages allow no |
| 10 | external hosts). The newest month renders on load; earlier months fetch |
| 11 | on demand. Titles link to song.link, albums to album.link. --> |
| 12 | <!doctype html> |
| 13 | <html lang="en"> |
| 14 | <head> |
| 15 | <meta charset="utf-8" /> |
| 16 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 17 | <meta name="color-scheme" content="light dark" /> |
| 18 | <title>Eleven — the music ledger</title> |
| 19 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 20 | <link rel="stylesheet" href="./status.css" /> |
| 21 | <style> |
| 22 | /* Music-only styles — track cards echo the access ledger's session |
| 23 | rows plus a small art tile. Everything shared is in status.css. */ |
| 24 | .tracks { list-style: none; padding: 0; } |
| 25 | .tracks li { |
| 26 | display: flex; gap: 14px; align-items: flex-start; |
| 27 | background: var(--surface); border: 1px solid var(--border); |
| 28 | border-radius: 12px; padding: 14px 16px; margin: 10px 0; |
| 29 | } |
| 30 | .tracks .art { width: 56px; height: 56px; border-radius: 8px; flex: none; } |
| 31 | .tracks .art.placeholder { |
| 32 | display: flex; align-items: center; justify-content: center; |
| 33 | background: var(--grid); color: var(--ink-2); font-size: 22px; |
| 34 | } |
| 35 | .tracks .body { min-width: 0; } |
| 36 | .tracks .top { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; } |
| 37 | .tracks .who { font-weight: 650; } |
| 38 | .tracks .when { color: var(--ink-2); font-size: 14px; font-variant-numeric: tabular-nums; } |
| 39 | .tracks .facts { color: var(--ink-2); font-size: 14px; margin-top: 4px; } |
| 40 | .badge { |
| 41 | display: inline-block; font-size: 12px; font-weight: 650; border-radius: 999px; |
| 42 | padding: 1px 10px; border: 1px solid var(--border); color: var(--ink-2); |
| 43 | } |
| 44 | .month-h { font-size: 15px; font-weight: 650; margin: 18px 0 4px; } |
| 45 | #earlier[hidden] { display: none; } /* display:block below would defeat [hidden] */ |
| 46 | #earlier { |
| 47 | display: block; width: 100%; font: inherit; font-weight: 650; cursor: pointer; |
| 48 | background: var(--surface); color: var(--link); border: 1px solid var(--border); |
| 49 | border-radius: 12px; padding: 12px 16px; margin: 10px 0; |
| 50 | } |
| 51 | #earlier:hover { background: var(--grid); } |
| 52 | #earlier:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; } |
| 53 | .empty { color: var(--ink-2); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| 54 | </style> |
| 55 | </head> |
| 56 | <body> |
| 57 | <div class="wrap"> |
| 58 | <header data-title="The music ledger"></header> |
| 59 | <nav class="status-nav" aria-label="Status"></nav> |
| 60 | <script src="./status-nav.js"></script> |
| 61 | |
| 62 | <p class="lede">What was playing on contributor machines while Eleven was |
| 63 | built — the soundtrack to the prompt ledger, attributed the same way. |
| 64 | Sessions sample Apple Music every 20 seconds; a track that held for under a |
| 65 | minute counts as a skip and never publishes. Rows carry the signals Music |
| 66 | exposes — <em>queued</em>, <em>radio</em>, <em>shuffle</em> — and a bare |
| 67 | catalog stream carries none, because Music can't tell a direct pick from |
| 68 | autoplay. Titles link to the song's <a href="https://song.link">song.link</a> |
| 69 | page, albums to album.link. One month loads at a time.</p> |
| 70 | |
| 71 | <p class="asof">Last played <time id="asof" datetime="">—</time></p> |
| 72 | |
| 73 | <p id="error" role="alert">Couldn’t load the music ledger. It may be mid-publish — this page retries every minute.</p> |
| 74 | |
| 75 | <div class="tiles" id="totals"></div> |
| 76 | |
| 77 | <section aria-labelledby="h-tracks"> |
| 78 | <h2 id="h-tracks">Tracks, newest first</h2> |
| 79 | <div id="months"></div> |
| 80 | <button id="earlier" hidden></button> |
| 81 | <p class="empty" id="empty" hidden>Nothing in the ledger yet — the needle |
| 82 | hasn’t dropped.</p> |
| 83 | </section> |
| 84 | |
| 85 | <footer> |
| 86 | <p>Accrued per session by <code>tools/music-ledger.mjs</code> from a |
| 87 | machine-local Apple Music log (song ids and art resolved once, at accrual — |
| 88 | this page makes no external requests). Convention: <a |
| 89 | href="https://github.com/elevenmessenger/messenger/blob/main/docs/status.md">docs/status.md</a>.</p> |
| 90 | </footer> |
| 91 | </div> |
| 92 | |
| 93 | <script> |
| 94 | (async function () { |
| 95 | "use strict"; |
| 96 | const $ = (id) => document.getElementById(id); |
| 97 | const FIELDS = ["updated_at", "months[]", "totals.tracks", "totals.artists", |
| 98 | "totals.albums", "totals.listeners"]; |
| 99 | |
| 100 | const fmtInt = (n) => Number(n).toLocaleString(); |
| 101 | const PLURAL = new Intl.PluralRules(); |
| 102 | const plural = (n, one, many) => PLURAL.select(n) === "one" ? one : (many || one + "s"); |
| 103 | const fmtDay = (iso) => new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); |
| 104 | const fmtTime = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); |
| 105 | const fmtMonth = (m) => new Date(m + "-15T12:00:00Z").toLocaleDateString(undefined, { month: "long", year: "numeric" }); |
| 106 | const artKey = (t) => t.album_id != null ? String(t.album_id) : t.track + " — " + t.artist; |
| 107 | |
| 108 | let months = []; // index order, newest first |
| 109 | // Where the data lives: the live ledger stream's derived views when the |
| 110 | // bucket has them (issue #1224 — updated in seconds, no deploy), else the |
| 111 | // committed files that ride the release train. |
| 112 | let base = "./"; |
| 113 | let shown = 0; // how many months are rendered |
| 114 | |
| 115 | function trackRow(t, albums) { |
| 116 | const li = document.createElement("li"); |
| 117 | let art; |
| 118 | const uri = albums[artKey(t)]; |
| 119 | if (uri) { |
| 120 | art = document.createElement("img"); |
| 121 | art.className = "art"; |
| 122 | art.src = uri; |
| 123 | art.alt = ""; |
| 124 | art.width = 56; art.height = 56; |
| 125 | } else { |
| 126 | art = document.createElement("span"); |
| 127 | art.className = "art placeholder"; |
| 128 | art.setAttribute("aria-hidden", "true"); |
| 129 | art.textContent = "♪"; |
| 130 | } |
| 131 | li.appendChild(art); |
| 132 | |
| 133 | const body = document.createElement("div"); |
| 134 | body.className = "body"; |
| 135 | const top = document.createElement("div"); |
| 136 | top.className = "top"; |
| 137 | const who = document.createElement("span"); |
| 138 | who.className = "who"; |
| 139 | if (t.song_id) { |
| 140 | const a = document.createElement("a"); |
| 141 | a.href = "https://song.link/i/" + t.song_id; |
| 142 | a.textContent = t.track; |
| 143 | who.appendChild(a); |
| 144 | } else { |
| 145 | who.textContent = t.track; |
| 146 | } |
| 147 | const when = document.createElement("span"); |
| 148 | when.className = "when"; |
| 149 | when.textContent = fmtDay(t.ts) + " · " + fmtTime(t.ts); |
| 150 | top.append(who, when); |
| 151 | for (const text of [t.listener, t.source].concat(t.signals || [])) { |
| 152 | const b = document.createElement("span"); |
| 153 | b.className = "badge"; |
| 154 | b.textContent = text; |
| 155 | top.appendChild(b); |
| 156 | } |
| 157 | body.appendChild(top); |
| 158 | |
| 159 | const facts = document.createElement("div"); |
| 160 | facts.className = "facts"; |
| 161 | facts.append(t.artist + " · "); |
| 162 | if (t.album_id) { |
| 163 | const a = document.createElement("a"); |
| 164 | a.href = "https://album.link/i/" + t.album_id; |
| 165 | a.textContent = t.album; |
| 166 | facts.appendChild(a); |
| 167 | } else { |
| 168 | facts.append(t.album); |
| 169 | } |
| 170 | body.appendChild(facts); |
| 171 | li.appendChild(body); |
| 172 | return li; |
| 173 | } |
| 174 | |
| 175 | async function showMonth(m) { |
| 176 | const r = await fetch(base + "music-" + m + ".json"); |
| 177 | if (!r.ok) throw 0; |
| 178 | const shard = await r.json(); |
| 179 | const sec = document.createElement("section"); |
| 180 | const h = document.createElement("h3"); |
| 181 | h.className = "month-h"; |
| 182 | h.textContent = fmtMonth(m); |
| 183 | const ol = document.createElement("ol"); |
| 184 | ol.className = "tracks"; |
| 185 | ol.setAttribute("reversed", ""); |
| 186 | for (const t of shard.tracks.slice().sort((a, b) => b.ts.localeCompare(a.ts))) { |
| 187 | ol.appendChild(trackRow(t, shard.albums || {})); |
| 188 | } |
| 189 | sec.append(h, ol); |
| 190 | $("months").appendChild(sec); |
| 191 | shown++; |
| 192 | const btn = $("earlier"); |
| 193 | if (shown < months.length) { |
| 194 | btn.hidden = false; |
| 195 | btn.textContent = "Show " + fmtMonth(months[shown]); |
| 196 | } else { |
| 197 | btn.hidden = true; |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | $("earlier").addEventListener("click", () => { |
| 202 | showMonth(months[shown]).catch(() => $("error").classList.add("show")); |
| 203 | }); |
| 204 | |
| 205 | function renderTiles(totals) { |
| 206 | const tiles = [ |
| 207 | [plural(totals.tracks, "track"), fmtInt(totals.tracks)], |
| 208 | [plural(totals.artists, "artist"), fmtInt(totals.artists)], |
| 209 | [plural(totals.albums, "album"), fmtInt(totals.albums)], |
| 210 | [plural(totals.listeners, "listener"), fmtInt(totals.listeners)], |
| 211 | ]; |
| 212 | const totalsEl = $("totals"); |
| 213 | totalsEl.textContent = ""; |
| 214 | for (const [label, val] of tiles) { |
| 215 | const tile = document.createElement("div"); |
| 216 | tile.className = "tile"; |
| 217 | const l = document.createElement("div"); l.className = "label"; l.textContent = label; |
| 218 | const v = document.createElement("div"); v.className = "value"; v.textContent = val; |
| 219 | tile.append(l, v); |
| 220 | totalsEl.appendChild(tile); |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | async function load() { |
| 225 | try { |
| 226 | let r = await fetch("./ledgers/music/music.json"); |
| 227 | base = r.ok ? "./ledgers/music/" : "./"; |
| 228 | if (!r.ok) r = await fetch("./music.json"); |
| 229 | if (!r.ok) throw 0; |
| 230 | const doc = await r.json(); |
| 231 | $("error").classList.remove("show"); |
| 232 | const asof = $("asof"); |
| 233 | asof.dateTime = doc.updated_at || ""; |
| 234 | asof.textContent = doc.updated_at ? fmtDay(doc.updated_at) + " " + fmtTime(doc.updated_at) : "—"; |
| 235 | renderTiles(doc.totals); |
| 236 | months = doc.months; |
| 237 | $("empty").hidden = months.length > 0; |
| 238 | // Re-render however many months the reader had open (at least one). |
| 239 | const keep = Math.max(1, shown); |
| 240 | $("months").textContent = ""; |
| 241 | shown = 0; |
| 242 | for (let i = 0; i < Math.min(keep, months.length); i++) await showMonth(months[i]); |
| 243 | } catch { |
| 244 | $("error").classList.add("show"); |
| 245 | } |
| 246 | } |
| 247 | await load(); |
| 248 | setInterval(load, 60000); // same retry cadence as the other dashboards |
| 249 | })(); |
| 250 | </script> |
| 251 | </body> |
| 252 | </html> |
| 253 | |