github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/web/stats-dialog.ts (about) 1 // Copyright 2021 Daniel Erat. 2 // All rights reserved. 3 4 import { 5 $, 6 createElement, 7 createTemplate, 8 formatRelativeTime, 9 handleFetchError, 10 } from './common.js'; 11 import { createDialog } from './dialog.js'; 12 13 const template = createTemplate(` 14 <style> 15 :host { 16 width: 400px; 17 } 18 hr.title { 19 margin-bottom: var(--margin); 20 } 21 22 #stats-div { 23 display: none; 24 width: 100%; 25 } 26 #stats-div.ready { 27 display: block; 28 } 29 30 #summary-div { 31 display: flex; 32 justify-content: space-between; 33 margin-bottom: var(--margin); 34 } 35 #songs, 36 #albums, 37 #duration { 38 font-weight: bold; 39 } 40 41 .chart-wrapper { 42 display: flex; 43 margin-bottom: var(--margin); 44 } 45 .chart-wrapper .label { 46 min-width: 5em; 47 } 48 .chart { 49 border-radius: 4px; 50 display: flex; 51 height: 16px; 52 outline: solid 1px var(--border-color); 53 overflow: hidden; 54 width: 100%; 55 } 56 .chart span { 57 color: var(--chart-text-color); 58 font-size: 10px; 59 overflow: hidden; 60 padding-top: 2.5px; 61 text-align: center; 62 text-overflow: ellipsis; 63 text-shadow: 0 0 4px black; 64 user-select: none; 65 } 66 67 #years-div { 68 line-height: 1.2em; 69 margin-bottom: var(--margin); 70 max-height: 180px; 71 overflow: scroll; 72 width: 100%; 73 } 74 #years-table { 75 border-spacing: 0; 76 table-layout: fixed; 77 width: 100%; 78 } 79 #years-table th { 80 background-color: var(--bg-color); 81 position: sticky; 82 top: 0; 83 z-index: 1; 84 } 85 /* Gross hack from https://stackoverflow.com/a/57170489/6882947 to keep 86 * border from scrolling along with table contents. */ 87 #years-table th:after { 88 border-bottom: solid 1px var(--border-color); 89 border-collapse: collapse; 90 bottom: 0; 91 content: ''; 92 position: absolute; 93 left: 0; 94 width: 100%; 95 } 96 #years-table th, 97 #years-div td { 98 text-align: right; 99 } 100 #years-table th:first-child, 101 #years-div td:first-child { 102 text-align: left; 103 width: 2.5em; 104 } 105 106 #updated-div { 107 font-size: 90%; 108 opacity: 50%; 109 } 110 </style> 111 112 <div class="title">Stats</div> 113 <hr class="title" /> 114 115 <div id="stats-div"> 116 <div id="summary-div"> 117 <span>Songs: <span id="songs"></span></span> 118 <span>Albums: <span id="albums"></span></span> 119 <span>Duration: <span id="duration"></span></span> 120 </div> 121 122 <div class="chart-wrapper"> 123 <span class="label">Decades:</span> 124 <div id="decades-chart" class="chart"></div> 125 </div> 126 127 <div class="chart-wrapper"> 128 <span class="label">Ratings:</span> 129 <div id="ratings-chart" class="chart"></div> 130 </div> 131 132 <div id="years-div"> 133 <table id="years-table"> 134 <thead> 135 <tr> 136 <th>Year</th> 137 <th>First plays</th> 138 <th>Last plays</th> 139 <th>Plays</th> 140 <th>Playtime</th> 141 </tr> 142 </thead> 143 <tbody></tbody> 144 </table> 145 </div> 146 </div> 147 148 <div id="updated-div">Loading stats...</div> 149 150 <form method="dialog"> 151 <div class="button-container"> 152 <button id="dismiss-button" autofocus>Dismiss</button> 153 </div> 154 </form> 155 `); 156 157 // Shows a modal dialog containing stats fetched from the server. 158 export function showStatsDialog() { 159 const dialog = createDialog(template, 'stats'); 160 const shadow = dialog.firstElementChild!.shadowRoot!; 161 $('dismiss-button', shadow).addEventListener('click', () => dialog.close()); 162 163 if (cachedStats) updateDialog(shadow, cachedStats); 164 165 fetchStats() 166 .then((stats: Stats) => { 167 if (cachedStats && stats.updateTime === cachedStats.updateTime) return; 168 cachedStats = stats; 169 updateDialog(shadow, cachedStats); 170 }) 171 .catch((err) => { 172 $('updated-div', shadow).innerText = err.toString(); 173 }); 174 } 175 176 // Preloads stats from the server so they'll be available when showStatsDialog() 177 // is called. 178 export const preloadStats = () => 179 fetchStats().then((stats) => (cachedStats = stats)); 180 181 // This corresponds to the Stats struct in server/db/stats.go. 182 interface Stats { 183 songs: number; 184 albums: number; 185 totalSec: number; 186 ratings: Record<string, number>; 187 songDecades: Record<string, number>; 188 tags: Record<string, number>; 189 years: Record<string, PlayStats>; 190 updateTime: string; 191 } 192 193 interface PlayStats { 194 plays: number; 195 totalSec: number; 196 firstPlays: number; 197 lastPlays: number; 198 } 199 200 let cachedStats: Stats | null = null; 201 202 const formatDays = (sec: number) => `${(sec / 86400).toFixed(1)} days`; 203 204 // Fetches stats from the server. 205 const fetchStats = () => 206 fetch('stats', { method: 'GET' }) 207 .then((res) => handleFetchError(res)) 208 .then((res) => res.json()); 209 210 // Updates |shadow| to reflect |stats|. 211 function updateDialog(shadow: ShadowRoot, stats: Stats) { 212 $('songs', shadow).innerText = stats.songs.toLocaleString(); 213 $('albums', shadow).innerText = stats.albums.toLocaleString(); 214 $('duration', shadow).innerText = formatDays(stats.totalSec); 215 216 const decades = Object.entries(stats.songDecades).sort(); 217 fillChart( 218 $('decades-chart', shadow), 219 decades.map(([_, c]) => c), 220 stats.songs, 221 decades.map(([d, _]) => (d === '0' ? '-' : d.slice(2) + 's')), 222 decades.map( 223 ([d, c]) => 224 `${d === '0' ? 'Unset' : d + 's'} - ` + 225 `${c} ${c !== 1 ? 'songs' : 'song'}` 226 ) 227 ); 228 229 const ratingCounts = [...Array(6).keys()].map((i) => stats.ratings[i] ?? 0); 230 fillChart( 231 $('ratings-chart', shadow), 232 ratingCounts, 233 stats.songs, 234 ratingCounts.map((_, i) => (i === 0 ? '-' : `${i}`)), 235 ratingCounts.map( 236 (c, i) => 237 `${i === 0 ? 'Unrated' : '★'.repeat(i)} - ` + 238 `${c} ${c !== 1 ? 'songs' : 'song'}` 239 ) 240 ); 241 242 const tbody = shadow.querySelector('#years-table tbody') as HTMLElement; 243 while (tbody.lastChild) tbody.removeChild(tbody.lastChild); 244 for (const [year, ystats] of Object.entries(stats.years).sort()) { 245 const row = createElement('tr', null, tbody); 246 createElement('td', null, row, year); 247 createElement('td', null, row, ystats.firstPlays.toLocaleString()); 248 createElement('td', null, row, ystats.lastPlays.toLocaleString()); 249 createElement('td', null, row, ystats.plays.toLocaleString()); 250 createElement('td', null, row, formatDays(ystats.totalSec)); 251 } 252 window.setTimeout(() => 253 tbody.lastElementChild?.scrollIntoView(false /* alignToTop */) 254 ); 255 256 const updateTime = formatRelativeTime( 257 (Date.parse(stats.updateTime) - Date.now()) / 1000 258 ); 259 $('updated-div', shadow).innerText = `Updated ${updateTime}`; 260 261 $('stats-div', shadow).classList.add('ready'); 262 } 263 264 // Adds spans within |div| corresponding to |vals| and |titles|. 265 function fillChart( 266 div: HTMLElement, 267 vals: number[], 268 total: number, 269 labels: string[], 270 titles: string[] 271 ) { 272 while (div.lastChild) div.removeChild(div.lastChild); 273 if (total <= 0) return; 274 for (let i = 0; i < vals.length; i++) { 275 const pct = vals[i] / total; 276 // Omit labels in tiny spans. 277 const el = createElement('span', null, div, pct >= 0.04 ? labels[i] : null); 278 el.style.width = `${100 * pct}%`; 279 // Add slop to the final span to deal with rounding errors. 280 if (i === vals.length - 1) el.style.width = `calc(${el.style.width} + 2px)`; 281 const opacity = (i / (vals.length - 1)) ** 2; 282 el.style.backgroundColor = `rgba(var(--chart-bar-rgb), ${opacity})`; 283 el.title = titles[i]; 284 } 285 }