|
9 | 9 | const providerChartContainer = document.querySelector('[data-provider-chart]'); |
10 | 10 | const datasetTableBody = document.querySelector('[data-dataset-table]'); |
11 | 11 | const similarityGrid = document.querySelector('[data-similarity-grid]'); |
| 12 | + const freshnessGrid = document.querySelector('[data-freshness-grid]'); |
12 | 13 | const sourceList = document.querySelector('[data-source-list]'); |
13 | 14 | const generatedAtNode = document.querySelector('[data-generated-at]'); |
14 | 15 | const providerCountNode = document.querySelector('[data-provider-count]'); |
15 | 16 |
|
16 | | - if (!summaryRoot || !datasetChartContainer || !providerChartContainer || !datasetTableBody || !similarityGrid || !sourceList) { |
| 17 | + if (!summaryRoot || !datasetChartContainer || !providerChartContainer || !datasetTableBody || !similarityGrid || !freshnessGrid || !sourceList) { |
17 | 18 | return; |
18 | 19 | } |
19 | 20 |
|
|
58 | 59 | }); |
59 | 60 | }; |
60 | 61 |
|
| 62 | + const toDate = (value) => { |
| 63 | + if (!value) { |
| 64 | + return null; |
| 65 | + } |
| 66 | + if (value instanceof Date) { |
| 67 | + return Number.isNaN(value.getTime()) ? null : value; |
| 68 | + } |
| 69 | + if (typeof value === 'string' || typeof value === 'number') { |
| 70 | + const parsed = new Date(value); |
| 71 | + return Number.isNaN(parsed.getTime()) ? null : parsed; |
| 72 | + } |
| 73 | + return null; |
| 74 | + }; |
| 75 | + |
| 76 | + const msPerDay = 1000 * 60 * 60 * 24; |
| 77 | + |
| 78 | + const differenceInDays = (laterDate, earlierDate) => { |
| 79 | + const later = toDate(laterDate); |
| 80 | + const earlier = toDate(earlierDate); |
| 81 | + if (!later || !earlier) { |
| 82 | + return null; |
| 83 | + } |
| 84 | + const diff = later.getTime() - earlier.getTime(); |
| 85 | + if (!Number.isFinite(diff) || diff <= 0) { |
| 86 | + return 0; |
| 87 | + } |
| 88 | + return diff / msPerDay; |
| 89 | + }; |
| 90 | + |
| 91 | + const formatRelativeDays = (days) => { |
| 92 | + if (days === null || !Number.isFinite(days)) { |
| 93 | + return 'No signal'; |
| 94 | + } |
| 95 | + if (days < 0.5) { |
| 96 | + return 'today'; |
| 97 | + } |
| 98 | + if (days < 1.5) { |
| 99 | + return '1 day ago'; |
| 100 | + } |
| 101 | + if (days < 7) { |
| 102 | + return `${decimalFormatter.format(days)} days ago`; |
| 103 | + } |
| 104 | + return `${Math.round(days)} days ago`; |
| 105 | + }; |
| 106 | + |
| 107 | + const classifyFreshness = (ageDays) => { |
| 108 | + if (ageDays === null || !Number.isFinite(ageDays)) { |
| 109 | + return { bucket: 'missing', className: 'status-muted', toneLabel: 'Missing' }; |
| 110 | + } |
| 111 | + if (ageDays <= 2) { |
| 112 | + return { bucket: 'fresh', className: 'status-healthy', toneLabel: 'Fresh' }; |
| 113 | + } |
| 114 | + if (ageDays <= 7) { |
| 115 | + return { bucket: 'due', className: 'status-warning', toneLabel: 'Due soon' }; |
| 116 | + } |
| 117 | + return { bucket: 'stale', className: 'status-critical', toneLabel: 'Needs refresh' }; |
| 118 | + }; |
| 119 | + |
| 120 | + const referenceDate = toDate(data.generatedAt) || new Date(); |
| 121 | + |
| 122 | + const computeComponentStatus = (timestamps, count) => { |
| 123 | + const valid = timestamps |
| 124 | + .map((entry) => toDate(entry)) |
| 125 | + .filter((entry) => entry); |
| 126 | + |
| 127 | + if (valid.length === 0) { |
| 128 | + return { |
| 129 | + count, |
| 130 | + bucket: 'missing', |
| 131 | + className: 'status-muted', |
| 132 | + toneLabel: count > 0 ? 'Untracked' : 'Missing', |
| 133 | + newestDate: null, |
| 134 | + oldestDate: null, |
| 135 | + ageDays: null, |
| 136 | + spanDays: null, |
| 137 | + }; |
| 138 | + } |
| 139 | + |
| 140 | + const sorted = valid.slice().sort((a, b) => a.getTime() - b.getTime()); |
| 141 | + const oldestDate = sorted[0]; |
| 142 | + const newestDate = sorted[sorted.length - 1]; |
| 143 | + const ageDays = differenceInDays(referenceDate, newestDate); |
| 144 | + const spanDays = sorted.length > 1 ? differenceInDays(newestDate, oldestDate) : 0; |
| 145 | + const classification = classifyFreshness(ageDays); |
| 146 | + |
| 147 | + return { |
| 148 | + count, |
| 149 | + bucket: classification.bucket, |
| 150 | + className: classification.className, |
| 151 | + toneLabel: classification.toneLabel, |
| 152 | + newestDate, |
| 153 | + oldestDate, |
| 154 | + ageDays, |
| 155 | + spanDays, |
| 156 | + }; |
| 157 | + }; |
| 158 | + |
61 | 159 | if (generatedAtNode) { |
62 | 160 | generatedAtNode.textContent = formatTimestamp(data.generatedAt); |
63 | 161 | } |
|
69 | 167 | providerCountNode.textContent = formatNumber(providerCount); |
70 | 168 | } |
71 | 169 |
|
| 170 | + const datasets = [...data.datasets]; |
| 171 | + const providerTotals = Array.isArray(data.providers) ? [...data.providers] : []; |
| 172 | + |
| 173 | + const deckFreshness = datasets.map((deck) => { |
| 174 | + const manifestStatus = computeComponentStatus([deck.manifest?.generatedAt], deck.manifest ? 1 : 0); |
| 175 | + const embeddingRecords = Array.isArray(deck.embeddings) ? deck.embeddings : []; |
| 176 | + const similarityRecords = Array.isArray(deck.similarityReports) ? deck.similarityReports : []; |
| 177 | + const embeddingStatus = computeComponentStatus(embeddingRecords.map((record) => record.generatedAt), embeddingRecords.length); |
| 178 | + const similarityStatus = computeComponentStatus(similarityRecords.map((record) => record.generatedAt), similarityRecords.length); |
| 179 | + const statuses = [manifestStatus, embeddingStatus, similarityStatus]; |
| 180 | + |
| 181 | + const latestTimestamp = statuses.reduce((accumulator, status) => { |
| 182 | + if (!status.newestDate) { |
| 183 | + return accumulator; |
| 184 | + } |
| 185 | + if (!accumulator || status.newestDate.getTime() > accumulator.getTime()) { |
| 186 | + return status.newestDate; |
| 187 | + } |
| 188 | + return accumulator; |
| 189 | + }, null); |
| 190 | + |
| 191 | + const latestAgeDays = latestTimestamp ? differenceInDays(referenceDate, latestTimestamp) : null; |
| 192 | + |
| 193 | + const statusCounts = { |
| 194 | + dueSoon: statuses.filter((status) => status.bucket === 'due').length, |
| 195 | + stale: statuses.filter((status) => status.bucket === 'stale').length, |
| 196 | + missing: statuses.filter((status) => status.bucket === 'missing').length, |
| 197 | + }; |
| 198 | + |
| 199 | + return { |
| 200 | + id: deck.id, |
| 201 | + label: deck.label, |
| 202 | + statuses: { |
| 203 | + manifest: manifestStatus, |
| 204 | + embeddings: embeddingStatus, |
| 205 | + similarity: similarityStatus, |
| 206 | + }, |
| 207 | + counts: { |
| 208 | + embeddings: embeddingStatus.count, |
| 209 | + similarityReports: similarityStatus.count, |
| 210 | + }, |
| 211 | + latestTimestamp, |
| 212 | + latestAgeDays, |
| 213 | + statusCounts, |
| 214 | + }; |
| 215 | + }); |
| 216 | + |
| 217 | + const totalAttention = deckFreshness.reduce((accumulator, entry) => accumulator + entry.statusCounts.dueSoon + entry.statusCounts.stale, 0); |
| 218 | + const totalStale = deckFreshness.reduce((accumulator, entry) => accumulator + entry.statusCounts.stale, 0); |
| 219 | + const totalMissingSignals = deckFreshness.reduce((accumulator, entry) => accumulator + entry.statusCounts.missing, 0); |
| 220 | + const averageLagDays = (() => { |
| 221 | + const values = deckFreshness |
| 222 | + .map((entry) => entry.latestAgeDays) |
| 223 | + .filter((value) => value !== null && Number.isFinite(value)); |
| 224 | + if (values.length === 0) { |
| 225 | + return null; |
| 226 | + } |
| 227 | + const sum = values.reduce((accumulator, value) => accumulator + value, 0); |
| 228 | + return sum / values.length; |
| 229 | + })(); |
| 230 | + const averageLagLabel = averageLagDays === null ? '—' : formatRelativeDays(averageLagDays); |
| 231 | + |
72 | 232 | const summaryMetrics = [ |
73 | 233 | { |
74 | 234 | label: 'Curated entries', |
|
90 | 250 | value: formatBytes(data.totals?.totalBytes || 0), |
91 | 251 | detail: `${formatNumber(data.totals?.totalFiles || 0)} files across datasets & sources`, |
92 | 252 | }, |
| 253 | + { |
| 254 | + label: 'Refresh queue', |
| 255 | + value: formatNumber(totalAttention), |
| 256 | + detail: `${formatNumber(totalStale)} stale • ${formatNumber(totalMissingSignals)} missing signals • avg refresh ${averageLagLabel}`, |
| 257 | + }, |
93 | 258 | ]; |
94 | 259 |
|
95 | 260 | summaryRoot.innerHTML = ''; |
|
112 | 277 | summaryRoot.appendChild(card); |
113 | 278 | }); |
114 | 279 |
|
115 | | - const datasets = [...data.datasets]; |
116 | | - const providerTotals = Array.isArray(data.providers) ? [...data.providers] : []; |
117 | | - |
118 | 280 | const datasetById = new Map(datasets.map((deck) => [deck.id, deck])); |
119 | 281 |
|
| 282 | + const buildFreshnessCards = (assessments) => { |
| 283 | + freshnessGrid.innerHTML = ''; |
| 284 | + |
| 285 | + if (!Array.isArray(assessments) || assessments.length === 0) { |
| 286 | + const fallback = document.createElement('p'); |
| 287 | + fallback.textContent = 'No decks available to audit.'; |
| 288 | + fallback.style.color = 'var(--text-secondary)'; |
| 289 | + freshnessGrid.appendChild(fallback); |
| 290 | + return; |
| 291 | + } |
| 292 | + |
| 293 | + assessments.forEach((assessment) => { |
| 294 | + const card = document.createElement('article'); |
| 295 | + card.className = 'freshness-card'; |
| 296 | + |
| 297 | + if (assessment.statusCounts.stale > 0) { |
| 298 | + card.classList.add('needs-action'); |
| 299 | + } else if (assessment.statusCounts.dueSoon > 0) { |
| 300 | + card.classList.add('due-soon'); |
| 301 | + } |
| 302 | + |
| 303 | + const heading = document.createElement('h3'); |
| 304 | + heading.textContent = assessment.label; |
| 305 | + card.appendChild(heading); |
| 306 | + |
| 307 | + const lastRefresh = document.createElement('p'); |
| 308 | + if (assessment.latestAgeDays === null) { |
| 309 | + lastRefresh.textContent = 'No refresh timestamps captured.'; |
| 310 | + } else { |
| 311 | + const timestampLabel = assessment.latestTimestamp |
| 312 | + ? formatTimestamp(assessment.latestTimestamp.toISOString()) |
| 313 | + : '—'; |
| 314 | + lastRefresh.textContent = `Last refresh ${formatRelativeDays(assessment.latestAgeDays)} (${timestampLabel})`; |
| 315 | + } |
| 316 | + card.appendChild(lastRefresh); |
| 317 | + |
| 318 | + const meta = document.createElement('p'); |
| 319 | + meta.className = 'freshness-meta'; |
| 320 | + const embeddingLabel = `${formatNumber(assessment.counts.embeddings)} embedding store${assessment.counts.embeddings === 1 ? '' : 's'}`; |
| 321 | + const similarityLabel = `${formatNumber(assessment.counts.similarityReports)} similarity report${assessment.counts.similarityReports === 1 ? '' : 's'}`; |
| 322 | + meta.textContent = `${embeddingLabel} • ${similarityLabel}`; |
| 323 | + card.appendChild(meta); |
| 324 | + |
| 325 | + const statusList = document.createElement('ul'); |
| 326 | + statusList.className = 'freshness-status-list'; |
| 327 | + |
| 328 | + const descriptors = [ |
| 329 | + { key: 'manifest', label: 'Manifest', missing: 'Manifest not generated' }, |
| 330 | + { key: 'embeddings', label: 'Embeddings', missing: 'No embedding stores tracked' }, |
| 331 | + { key: 'similarity', label: 'Similarity', missing: 'No similarity reports' }, |
| 332 | + ]; |
| 333 | + |
| 334 | + descriptors.forEach((descriptor) => { |
| 335 | + const info = assessment.statuses[descriptor.key]; |
| 336 | + const item = document.createElement('li'); |
| 337 | + item.className = `status-chip ${info.className || 'status-muted'}`; |
| 338 | + |
| 339 | + const title = document.createElement('span'); |
| 340 | + title.className = 'status-chip__title'; |
| 341 | + title.textContent = descriptor.label; |
| 342 | + item.appendChild(title); |
| 343 | + |
| 344 | + const value = document.createElement('span'); |
| 345 | + value.className = 'status-chip__value'; |
| 346 | + if (info.ageDays === null) { |
| 347 | + value.textContent = info.count > 0 ? 'Timestamp missing' : descriptor.missing; |
| 348 | + } else { |
| 349 | + value.textContent = formatRelativeDays(info.ageDays); |
| 350 | + } |
| 351 | + item.appendChild(value); |
| 352 | + |
| 353 | + const detailParts = []; |
| 354 | + |
| 355 | + if (info.ageDays !== null) { |
| 356 | + detailParts.push(info.toneLabel); |
| 357 | + } |
| 358 | + |
| 359 | + if (descriptor.key === 'embeddings') { |
| 360 | + const storeLabel = `${formatNumber(info.count)} store${info.count === 1 ? '' : 's'}`; |
| 361 | + detailParts.push(storeLabel); |
| 362 | + if (info.spanDays && info.spanDays > 0.4) { |
| 363 | + detailParts.push(`spread ${decimalFormatter.format(info.spanDays)}d`); |
| 364 | + } |
| 365 | + } else if (descriptor.key === 'similarity') { |
| 366 | + const reportLabel = `${formatNumber(info.count)} report${info.count === 1 ? '' : 's'}`; |
| 367 | + detailParts.push(reportLabel); |
| 368 | + if (info.spanDays && info.spanDays > 0.4) { |
| 369 | + detailParts.push(`span ${decimalFormatter.format(info.spanDays)}d`); |
| 370 | + } |
| 371 | + } else if (descriptor.key === 'manifest' && info.count > 0 && info.ageDays !== null) { |
| 372 | + detailParts.push('1 file'); |
| 373 | + } |
| 374 | + |
| 375 | + if (info.newestDate) { |
| 376 | + detailParts.push(formatTimestamp(info.newestDate.toISOString())); |
| 377 | + } |
| 378 | + |
| 379 | + if (detailParts.length > 0) { |
| 380 | + const detail = document.createElement('span'); |
| 381 | + detail.className = 'status-chip__detail'; |
| 382 | + detail.textContent = detailParts.join(' • '); |
| 383 | + item.appendChild(detail); |
| 384 | + } |
| 385 | + |
| 386 | + statusList.appendChild(item); |
| 387 | + }); |
| 388 | + |
| 389 | + card.appendChild(statusList); |
| 390 | + freshnessGrid.appendChild(card); |
| 391 | + }); |
| 392 | + }; |
| 393 | + |
120 | 394 | const buildDatasetChart = () => { |
121 | 395 | datasetChartContainer.innerHTML = ''; |
122 | 396 | const svgNamespace = 'http://www.w3.org/2000/svg'; |
|
521 | 795 | }); |
522 | 796 | }; |
523 | 797 |
|
| 798 | + buildFreshnessCards(deckFreshness); |
524 | 799 | buildDatasetChart(); |
525 | 800 | buildProviderChart(); |
526 | 801 | buildDatasetTable(); |
|
0 commit comments