experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip(changelog): scaffold per-day bump script + per-bullet RSS generator (untested)

+435 -72
+361
scripts/changelog-bump.js
··· 1 + #!/usr/bin/env node 2 + /** 3 + * changelog-bump.js 4 + * 5 + * Reads jj log between the last-bumped marker and main, generates terse 6 + * one-line bullets clustered by (type, area, day), and merges them into 7 + * CHANGELOG.md under `## Week of YYYY-MM-DD (Wnn)` / `### YYYY-MM-DD`. 8 + * 9 + * Usage: 10 + * node scripts/changelog-bump.js [--dry-run] [--since <commit>] 11 + * 12 + * Marker: 13 + * `<!-- last-bumped: <commit-short-id> -->` — first match in CHANGELOG.md. 14 + * On success, the marker is rewritten to the current main HEAD. 15 + * 16 + * Skip rules (commit titles): 17 + * - chore(*) 18 + * - test(*) 19 + * - docs(changelog) 20 + * - "(working copy)" / "(no description set)" 21 + * 22 + * Clustering: 23 + * - Same (area, type, day) → one bullet. 24 + * - Phase rollup: ≥2 commits whose subjects match `Phase \d+` → one bullet 25 + * with the trailing "(Phases X–Y)" suffix. 26 + * 27 + * Output bullet form: 28 + * - `{area}: {subject}` (type prefix dropped) 29 + * - For phase rollups, the leading "Phase N — " is stripped from subject. 30 + * 31 + * After mutation, runs `yarn rss` to regenerate the feed. 32 + */ 33 + 34 + import { readFileSync, writeFileSync } from 'node:fs'; 35 + import { execSync } from 'node:child_process'; 36 + import { resolve } from 'node:path'; 37 + 38 + const CHANGELOG_PATH = resolve('CHANGELOG.md'); 39 + const MARKER_RE = /<!--\s*last-bumped:\s*([a-f0-9]+)\s*-->/; 40 + 41 + const args = process.argv.slice(2); 42 + const DRY_RUN = args.includes('--dry-run'); 43 + const sinceArgIdx = args.indexOf('--since'); 44 + const SINCE_OVERRIDE = sinceArgIdx >= 0 ? args[sinceArgIdx + 1] : null; 45 + 46 + const SKIP_TYPES = new Set(['chore', 'test']); 47 + 48 + function readChangelog() { 49 + return readFileSync(CHANGELOG_PATH, 'utf-8'); 50 + } 51 + 52 + function findMarker(content) { 53 + const m = content.match(MARKER_RE); 54 + return m ? m[1] : null; 55 + } 56 + 57 + function getMainHead() { 58 + const out = execSync(`jj log -r main -T 'commit_id.short()' --no-graph`, { 59 + encoding: 'utf-8', 60 + }); 61 + return out.trim(); 62 + } 63 + 64 + function getCommits(sinceCommit) { 65 + // Tab-separated: short_id \t ISO timestamp \t description first line 66 + const tmpl = String.raw`commit_id.short() ++ "\t" ++ committer.timestamp().format("%Y-%m-%dT%H:%M:%S%z") ++ "\t" ++ description.first_line() ++ "\n"`; 67 + const range = `${sinceCommit}..main`; 68 + const cmd = `jj log -r '${range}' --no-graph -T '${tmpl}' --reversed`; 69 + let out; 70 + try { 71 + out = execSync(cmd, { encoding: 'utf-8' }); 72 + } catch (err) { 73 + throw new Error(`jj log failed for range ${range}: ${err.message}`); 74 + } 75 + return out 76 + .split('\n') 77 + .filter(Boolean) 78 + .map((line) => { 79 + const idx1 = line.indexOf('\t'); 80 + const idx2 = line.indexOf('\t', idx1 + 1); 81 + return { 82 + commit: line.slice(0, idx1), 83 + ts: line.slice(idx1 + 1, idx2), 84 + subject: line.slice(idx2 + 1), 85 + }; 86 + }); 87 + } 88 + 89 + function parseSubject(line) { 90 + // Match: type(area)?: subject OR type!: subject OR type: subject 91 + const m = line.match(/^(\w+)(?:\(([^)]+)\))?!?\s*:\s*(.+)$/); 92 + if (!m) return { type: '', area: '', subject: line }; 93 + return { type: m[1], area: m[2] || '', subject: m[3] }; 94 + } 95 + 96 + function shouldSkip(parsed, raw) { 97 + if (!raw || raw.startsWith('(working copy)') || raw === '(no description set)') return true; 98 + if (raw.startsWith('(empty)')) return true; 99 + if (SKIP_TYPES.has(parsed.type)) return true; 100 + if (parsed.type === 'docs' && parsed.area === 'changelog') return true; 101 + return false; 102 + } 103 + 104 + function getDayFromTs(ts) { 105 + return ts.slice(0, 10); 106 + } 107 + 108 + function getISOWeek(dateStr) { 109 + const d = new Date(dateStr + 'T12:00:00Z'); 110 + const dayNum = (d.getUTCDay() + 6) % 7; 111 + d.setUTCDate(d.getUTCDate() - dayNum + 3); 112 + const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4)); 113 + const week = 1 + Math.round( 114 + ((d.getTime() - firstThursday.getTime()) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7, 115 + ); 116 + return week; 117 + } 118 + 119 + function getMondayOfWeek(dateStr) { 120 + const d = new Date(dateStr + 'T12:00:00Z'); 121 + const dayNum = (d.getUTCDay() + 6) % 7; 122 + d.setUTCDate(d.getUTCDate() - dayNum); 123 + return d.toISOString().slice(0, 10); 124 + } 125 + 126 + function clusterCommits(commits) { 127 + const enriched = commits 128 + .map((c) => { 129 + const p = parseSubject(c.subject); 130 + return { ...c, ...p, day: getDayFromTs(c.ts) }; 131 + }) 132 + .filter((c) => !shouldSkip(c, c.subject)); 133 + 134 + const clusters = []; 135 + for (const c of enriched) { 136 + const last = clusters[clusters.length - 1]; 137 + if (last && last.area === c.area && last.type === c.type && last.day === c.day) { 138 + last.commits.push(c); 139 + } else { 140 + clusters.push({ area: c.area, type: c.type, day: c.day, commits: [c] }); 141 + } 142 + } 143 + return clusters; 144 + } 145 + 146 + function renderBullet(cluster) { 147 + const last = cluster.commits[cluster.commits.length - 1]; 148 + let subject = last.subject; 149 + 150 + const phaseNums = cluster.commits 151 + .map((c) => { 152 + const m = c.subject.match(/Phase\s+(\d+)/i); 153 + return m ? parseInt(m[1], 10) : null; 154 + }) 155 + .filter((n) => n !== null); 156 + 157 + if (phaseNums.length >= 2) { 158 + const min = Math.min(...phaseNums); 159 + const max = Math.max(...phaseNums); 160 + subject = subject.replace(/Phase\s+\d+\s*[—–\-:]\s*/i, ''); 161 + subject = subject.charAt(0).toLowerCase() + subject.slice(1); 162 + subject = `${subject} (Phases ${min}–${max})`; 163 + } 164 + 165 + const label = cluster.area || cluster.type || 'misc'; 166 + return `- ${label}: ${subject}`; 167 + } 168 + 169 + function groupClustersByDay(clusters) { 170 + const days = new Map(); 171 + for (const cl of clusters) { 172 + if (!days.has(cl.day)) days.set(cl.day, []); 173 + days.get(cl.day).push(cl); 174 + } 175 + return [...days.entries()].sort((a, b) => b[0].localeCompare(a[0])); 176 + } 177 + 178 + function findHeadingPosition(content, headingRegex) { 179 + const m = content.match(headingRegex); 180 + if (!m) return -1; 181 + return m.index; 182 + } 183 + 184 + function findEndOfSection(content, startIdx, sectionLevel) { 185 + // Find the next heading at the same or higher level (fewer #). 186 + // For ## section: stop at next ## or # (or EOF). 187 + // For ### section: stop at next ### or ## or # (or EOF). 188 + const lines = content.slice(startIdx).split('\n'); 189 + let offset = 0; 190 + // Skip the heading line itself 191 + if (lines.length > 0) offset = lines[0].length + 1; 192 + for (let i = 1; i < lines.length; i++) { 193 + const line = lines[i]; 194 + if (line.startsWith('#')) { 195 + const headingHashes = line.match(/^#+/)[0].length; 196 + if (headingHashes <= sectionLevel) { 197 + return startIdx + offset; 198 + } 199 + } 200 + offset += line.length + 1; 201 + } 202 + return content.length; 203 + } 204 + 205 + function mergeIntoChangelog(content, daysData) { 206 + // daysData: [[day, [bullets]], ...] in descending day order 207 + let result = content; 208 + 209 + // Process oldest day first so newer days end up at the top after each prepend. 210 + const ordered = [...daysData].reverse(); 211 + 212 + for (const [day, bullets] of ordered) { 213 + const monday = getMondayOfWeek(day); 214 + const week = getISOWeek(day); 215 + const weekHeading = `## Week of ${monday} (W${String(week).padStart(2, '0')})`; 216 + const dayHeading = `### ${day}`; 217 + 218 + const weekRe = new RegExp(`^${escapeRegex(weekHeading)}\\s*$`, 'm'); 219 + const dayRe = new RegExp(`^${escapeRegex(dayHeading)}\\s*$`, 'm'); 220 + 221 + const weekMatch = result.match(weekRe); 222 + 223 + if (weekMatch) { 224 + // Week section exists. Find day sub-section under it. 225 + const weekStart = weekMatch.index; 226 + const weekEnd = findEndOfSection(result, weekStart, 2); 227 + const weekBody = result.slice(weekStart, weekEnd); 228 + 229 + const dayMatch = weekBody.match(dayRe); 230 + if (dayMatch) { 231 + // Day section exists in week. Prepend bullets just below the day heading. 232 + const absoluteDayStart = weekStart + dayMatch.index; 233 + const dayHeadingEnd = absoluteDayStart + dayHeading.length; 234 + // Insert bullets right after heading + newline; keep existing bullets below. 235 + const before = result.slice(0, dayHeadingEnd); 236 + const after = result.slice(dayHeadingEnd); 237 + const insertion = '\n\n' + bullets.join('\n'); 238 + // after starts with \n, possibly \n\n... we want to drop the first \n\n if present 239 + // and just put a single \n\n separator before existing bullets. 240 + const trimmedAfter = after.replace(/^\n+/, ''); 241 + result = before + insertion + '\n\n' + trimmedAfter; 242 + } else { 243 + // Create new day section as the first day under this week. 244 + const weekHeadingEnd = weekStart + weekHeading.length; 245 + const before = result.slice(0, weekHeadingEnd); 246 + const after = result.slice(weekHeadingEnd); 247 + const trimmedAfter = after.replace(/^\n+/, ''); 248 + const insertion = '\n\n' + dayHeading + '\n\n' + bullets.join('\n'); 249 + result = before + insertion + '\n\n' + trimmedAfter; 250 + } 251 + } else { 252 + // Create new week section before the first existing ## heading. 253 + const firstHeadingIdx = findFirstSectionHeading(result); 254 + const block = `${weekHeading}\n\n${dayHeading}\n\n${bullets.join('\n')}\n\n`; 255 + if (firstHeadingIdx === -1) { 256 + // No existing sections; append. 257 + result = result.replace(/\s*$/, '\n\n' + block); 258 + } else { 259 + result = result.slice(0, firstHeadingIdx) + block + result.slice(firstHeadingIdx); 260 + } 261 + } 262 + } 263 + 264 + return result; 265 + } 266 + 267 + function escapeRegex(s) { 268 + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 269 + } 270 + 271 + function findFirstSectionHeading(content) { 272 + const m = content.match(/^## /m); 273 + return m ? m.index : -1; 274 + } 275 + 276 + function updateMarker(content, newCommit) { 277 + const marker = `<!-- last-bumped: ${newCommit} -->`; 278 + if (MARKER_RE.test(content)) { 279 + return content.replace(MARKER_RE, marker); 280 + } 281 + // Insert marker right after the @marss block (or at top after the H1). 282 + const marsEnd = content.indexOf('-->'); 283 + if (marsEnd !== -1) { 284 + const insertAt = marsEnd + 3; 285 + return content.slice(0, insertAt) + '\n' + marker + content.slice(insertAt); 286 + } 287 + return marker + '\n' + content; 288 + } 289 + 290 + function main() { 291 + const content = readChangelog(); 292 + const marker = SINCE_OVERRIDE || findMarker(content); 293 + if (!marker) { 294 + console.error( 295 + 'No <!-- last-bumped: <commit> --> marker found in CHANGELOG.md.\n' + 296 + 'First run: pass --since <commit-short-id> to bootstrap.', 297 + ); 298 + process.exit(2); 299 + } 300 + 301 + const head = getMainHead(); 302 + if (head === marker) { 303 + console.log(`No new commits since ${marker}; nothing to bump.`); 304 + return; 305 + } 306 + 307 + const commits = getCommits(marker); 308 + if (commits.length === 0) { 309 + console.log(`No commits in ${marker}..main; nothing to bump.`); 310 + return; 311 + } 312 + 313 + const clusters = clusterCommits(commits); 314 + if (clusters.length === 0) { 315 + console.log( 316 + `${commits.length} commits in range, but all filtered out (chore/test/docs(changelog)/etc).`, 317 + ); 318 + if (!DRY_RUN) { 319 + const updated = updateMarker(content, head); 320 + writeFileSync(CHANGELOG_PATH, updated, 'utf-8'); 321 + console.log(`Updated marker to ${head}.`); 322 + } 323 + return; 324 + } 325 + 326 + const daysData = groupClustersByDay(clusters).map(([day, cls]) => [ 327 + day, 328 + cls.map(renderBullet), 329 + ]); 330 + 331 + if (DRY_RUN) { 332 + console.log('--- DRY RUN ---'); 333 + for (const [day, bullets] of daysData) { 334 + const monday = getMondayOfWeek(day); 335 + const week = getISOWeek(day); 336 + console.log(`\n## Week of ${monday} (W${String(week).padStart(2, '0')})\n`); 337 + console.log(`### ${day}\n`); 338 + console.log(bullets.join('\n')); 339 + } 340 + console.log(`\n--- (would advance marker ${marker} → ${head}) ---`); 341 + return; 342 + } 343 + 344 + let updated = mergeIntoChangelog(content, daysData); 345 + updated = updateMarker(updated, head); 346 + writeFileSync(CHANGELOG_PATH, updated, 'utf-8'); 347 + 348 + const totalBullets = daysData.reduce((sum, [, bs]) => sum + bs.length, 0); 349 + console.log( 350 + `Bumped ${totalBullets} bullet(s) across ${daysData.length} day(s); marker now ${head}.`, 351 + ); 352 + 353 + // Regenerate RSS 354 + try { 355 + execSync('node scripts/changelog-to-rss.js', { stdio: 'inherit' }); 356 + } catch (err) { 357 + console.error('Warning: yarn rss / changelog-to-rss.js failed:', err.message); 358 + } 359 + } 360 + 361 + main();
+74 -72
scripts/changelog-to-rss.js
··· 2 2 /** 3 3 * changelog-to-rss.js 4 4 * 5 - * Parses CHANGELOG.md and generates an RSS 2.0 feed XML file. 5 + * Parses CHANGELOG.md and emits a rolling RSS 2.0 feed of the last N days 6 + * of changes (default: 7). One <item> per bullet under `### YYYY-MM-DD` 7 + * sub-headings (the post-2026-04-29 format). Older `## YYYY-MM-DD` weekly 8 + * sections are ignored — RSS is "what shipped recently", not the archive. 6 9 * 7 - * Expected CHANGELOG.md format: 8 - * - HTML comment block with @marss metadata (title, link, description) 9 - * - Level-2 headings (##) with ISO dates (YYYY-MM-DD) as feed items 10 - * - Content under each heading becomes the item description 11 - * 12 - * Usage: node scripts/changelog-to-rss.js [input] [output] 13 - * input - path to CHANGELOG.md (default: CHANGELOG.md) 14 - * output - path to RSS XML output (default: docs/feed.xml) 10 + * Usage: node scripts/changelog-to-rss.js [input] [output] [--days N] 11 + * input - path to CHANGELOG.md (default: CHANGELOG.md) 12 + * output - path to RSS XML out (default: docs/feed.xml) 13 + * --days N - rolling window in days (default: 7) 15 14 */ 16 15 17 16 import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 18 17 import { dirname, resolve } from 'node:path'; 18 + import { createHash } from 'node:crypto'; 19 19 20 - const inputPath = resolve(process.argv[2] || 'CHANGELOG.md'); 21 - const outputPath = resolve(process.argv[3] || 'docs/feed.xml'); 20 + const positional = process.argv.slice(2).filter((a) => !a.startsWith('--')); 21 + const flagDaysIdx = process.argv.indexOf('--days'); 22 + const ROLLING_DAYS = flagDaysIdx >= 0 ? parseInt(process.argv[flagDaysIdx + 1], 10) : 7; 23 + 24 + const inputPath = resolve(positional[0] || 'CHANGELOG.md'); 25 + const outputPath = resolve(positional[1] || 'docs/feed.xml'); 22 26 23 27 const content = readFileSync(inputPath, 'utf-8'); 24 28 25 - // Parse @marss metadata from HTML comment 26 29 function parseMetadata(text) { 27 30 const match = text.match(/<!--\s*\n\s*@marss\s*\n([\s\S]*?)-->/); 28 31 if (!match) { ··· 40 43 return meta; 41 44 } 42 45 43 - // Parse level-2 headings and their content as feed items 44 - function parseItems(text) { 46 + // Walk the file collecting per-bullet items under `### YYYY-MM-DD` sub-headings. 47 + function parseBulletItems(text) { 48 + const lines = text.split('\n'); 45 49 const items = []; 46 - // Split on ## headings (level 2 only, not ### or #) 47 - const parts = text.split(/^## /m); 50 + let currentDate = null; 48 51 49 - for (let i = 1; i < parts.length; i++) { 50 - const part = parts[i]; 51 - const newlineIdx = part.indexOf('\n'); 52 - if (newlineIdx === -1) continue; 53 - 54 - const heading = part.slice(0, newlineIdx).trim(); 55 - const body = part.slice(newlineIdx + 1).trim(); 56 - 57 - // Extract date from heading - expect YYYY-MM-DD 58 - const dateMatch = heading.match(/(\d{4}-\d{2}-\d{2})/); 59 - if (!dateMatch) continue; // Skip headings without dates 60 - 61 - const dateStr = dateMatch[1]; 62 - const date = new Date(dateStr + 'T12:00:00Z'); 63 - 64 - // Title: use the heading text (may include more than just the date) 65 - const title = heading; 66 - 67 - // Convert markdown body to plain text for RSS description 68 - const description = markdownToPlainText(body); 69 - 70 - items.push({ title, date, dateStr, description }); 52 + for (const line of lines) { 53 + const dayMatch = line.match(/^### (\d{4}-\d{2}-\d{2})\s*$/); 54 + if (dayMatch) { 55 + currentDate = dayMatch[1]; 56 + continue; 57 + } 58 + // Reset on a new ## heading (week boundary or older format) 59 + if (/^## /.test(line)) { 60 + currentDate = null; 61 + continue; 62 + } 63 + if (!currentDate) continue; 64 + const bulletMatch = line.match(/^- (.+)$/); 65 + if (!bulletMatch) continue; 66 + items.push({ date: currentDate, body: bulletMatch[1].trim() }); 71 67 } 72 68 73 69 return items; 74 70 } 75 71 76 - // Convert markdown to plain text for RSS descriptions 77 - function markdownToPlainText(md) { 78 - let text = md 79 - // Convert markdown links to "text (URL)" format 72 + function withinRollingWindow(items, days) { 73 + if (items.length === 0) return []; 74 + const newest = items 75 + .map((i) => i.date) 76 + .sort((a, b) => b.localeCompare(a))[0]; 77 + const cutoff = new Date(newest + 'T12:00:00Z'); 78 + cutoff.setUTCDate(cutoff.getUTCDate() - (days - 1)); 79 + const cutoffStr = cutoff.toISOString().slice(0, 10); 80 + return items.filter((i) => i.date >= cutoffStr); 81 + } 82 + 83 + function markdownInlineToPlain(s) { 84 + return s 80 85 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)') 81 - // Remove checkboxes 82 - .replace(/^- \[x\] /gm, '- ') 83 - .replace(/^- \[ \] /gm, '- ') 84 - // Remove bold markers 85 86 .replace(/\*\*(.+?)\*\*/g, '$1') 86 - // Remove italic markers 87 87 .replace(/\*(.+?)\*/g, '$1') 88 - // Remove inline code backticks 89 - .replace(/`([^`]+)`/g, '$1') 90 - // Collapse multiple blank lines 91 - .replace(/\n{3,}/g, '\n\n') 92 - .trim(); 93 - 94 - return text; 88 + .replace(/`([^`]+)`/g, '$1'); 95 89 } 96 90 97 - // Escape XML special characters 98 91 function escapeXml(str) { 99 92 return str 100 93 .replace(/&/g, '&amp;') ··· 104 97 .replace(/'/g, '&apos;'); 105 98 } 106 99 107 - // Generate RSS 2.0 XML 100 + function stableGuid(date, body) { 101 + const h = createHash('sha1').update(`${date}\n${body}`).digest('hex'); 102 + return `urn:peek:changelog:${date}:${h.slice(0, 12)}`; 103 + } 104 + 108 105 function generateRss(meta, items) { 109 106 const now = new Date().toUTCString(); 110 107 ··· 128 125 if (meta.copyright) { 129 126 xml += ` <copyright>${escapeXml(meta.copyright)}</copyright>\n`; 130 127 } 131 - if (meta.imageUrl) { 132 - xml += ` <image>\n <url>${escapeXml(meta.imageUrl)}</url>\n <title>${escapeXml(meta.title || 'Changelog')}</title>\n <link>${escapeXml(meta.link || '')}</link>\n </image>\n`; 133 - } 128 + 129 + // Newest first 130 + const sorted = [...items].sort((a, b) => 131 + a.date === b.date ? 0 : b.date.localeCompare(a.date), 132 + ); 134 133 135 - for (const item of items) { 136 - const pubDate = item.date.toUTCString(); 137 - const guid = (meta.link || 'urn:changelog') + '#' + item.dateStr; 134 + for (const item of sorted) { 135 + const date = new Date(item.date + 'T12:00:00Z'); 136 + const pubDate = date.toUTCString(); 137 + const plain = markdownInlineToPlain(item.body); 138 + const title = plain.length > 200 ? plain.slice(0, 197) + '...' : plain; 139 + const guid = stableGuid(item.date, item.body); 140 + const itemLink = (meta.link || '') + '/blob/main/CHANGELOG.md#' + item.date; 138 141 139 142 xml += ` <item> 140 - <title>${escapeXml(item.title)}</title> 141 - <link>${escapeXml((meta.link || '') + '/blob/main/CHANGELOG.md#' + item.dateStr)}</link> 143 + <title>${escapeXml(title)}</title> 144 + <link>${escapeXml(itemLink)}</link> 142 145 <guid isPermaLink="false">${escapeXml(guid)}</guid> 143 146 <pubDate>${pubDate}</pubDate> 144 - <description>${escapeXml(item.description)}</description> 147 + <description>${escapeXml(plain)}</description> 145 148 </item> 146 149 `; 147 150 } ··· 149 152 xml += ` </channel> 150 153 </rss> 151 154 `; 152 - 153 155 return xml; 154 156 } 155 157 156 - // Main 157 158 const meta = parseMetadata(content); 158 - const items = parseItems(content); 159 + const allItems = parseBulletItems(content); 160 + const items = withinRollingWindow(allItems, ROLLING_DAYS); 159 161 160 162 if (items.length === 0) { 161 - console.error('Warning: No feed items found (no ## headings with dates)'); 163 + console.error( 164 + `Warning: No bullets found within last ${ROLLING_DAYS} days under ### YYYY-MM-DD sub-headings.`, 165 + ); 162 166 } 163 167 164 168 const rss = generateRss(meta, items); 165 - 166 - // Ensure output directory exists 167 169 mkdirSync(dirname(outputPath), { recursive: true }); 168 170 writeFileSync(outputPath, rss, 'utf-8'); 169 171 170 - console.log(`Generated RSS feed: ${outputPath} (${items.length} items)`); 172 + console.log(`Generated RSS feed: ${outputPath} (${items.length} items, ${ROLLING_DAYS}-day window)`);