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  }