Skip to content
Snippets Groups Projects
Select Git revision
  • 9c1c5285b306a3161160d4dda8563ef337d131b9
  • main default protected
  • renovate/node-22.x-lockfile
  • renovate/vitest-3.x-lockfile
  • renovate/node-24.x
  • renovate/pnpm-10.x
  • renovate/prettier-3.x-lockfile
  • renovate/esbuild-0.x-lockfile
  • renovate/bootstrap-5.x
  • feat/prettier-attendence-sum
  • local-test
  • show_missing
  • feat/show-which-resolutions-are-void
  • cedric-hates-this
14 results

rendering.ts

Blame
  • Code owners
    Assign users and groups as approvers for specific file changes. Learn more.
    rendering.ts 25.50 KiB
    import { FinishedTranscriptMeta, Resolution, Todo } from "./parsing";
    import { Attendance } from "./attendance";
    
    export function renderContainerToAlert(context: String, title?: String) {
        return function (tokens, idx) {
            return tokens[idx].nesting === 1
                ? `<div class="alert ${context}" role="alert"">\n${
                      title === undefined
                          ? ""
                          : `<h5 class="alert-heading">${title}</h5>\n`
                  }`
                : "</div>\n";
        };
    }
    
    export function renderResolutionToHtml(resolution: Resolution): string {
        return `
      <div class="card mb-3 text-center" id="${resolution.number}">
        <div class="card-header" >Beschluss <code>${resolution.number}</code></div>
        <div class="card-body">
          <h5 class="card-title"></h5>
          <div class="card-text">${resolution.html}</div>
          <div class="container container-sm">
            <div class="row align-items-center">
                <div class="col">
                    <span class="p-1 border-bottom border-success border-2">
                        Ja: ${resolution.votes.yes}
                    </span>
                </div>
                <div class="col">
                    <span class="p-1 border-bottom border-danger border-2">
                        Nein: ${resolution.votes.no}
                    </span>
                </div>
                <div class="col">
                    <span class="p-1 border-bottom border-info border-2">
                        Enthaltung: ${resolution.votes.abstention}
                    </span>
                </div>
            </div>
          </div>
        </div>
        <div class="card-footer ${
            resolution.accepted ? "bg-success" : "bg-danger"
        } bg-opacity-25">
          ${resolution.result}
        </div>
          ${
              resolution.provisional
                  ? `<div class="card-footer bg-warning bg-opacity-25">
                <strong class="font-bold">Kommissarisch, weil:</strong> ${resolution.provisional}
               </div>`
                  : ""
          }
      </div>`;
    }
    
    export function renderTodoToHtml(todo: Todo): string {
        return `
      <div class="card mb-3 text-center">
        <div class="card-header">Neues To-Do</div>
        <div class="card-body">
          <h5 class="card-title"></h5>
          <div class="card-text">${todo.html}</div>
        </div>
        <div class="card-footer bg-info bg-opacity-25">
          <div class="bg-opacity-100">
            ${
                todo.team
                    ? `<span class="badge text-bg-info mb-1 me-1">Team: ${todo.team}</span>`
                    : ""
            }
            ${
                todo.people
                    ? `<span class="badge text-bg-info mb-1 me-1">Zuständig: ${todo.people}</span>`
                    : ""
            }
          </div>
        </div>
      </div>`;
    }
    
    export function generateFooterPartialHtml(depth: number = 0): string {
        const depthTraversal = new Array(depth).fill("..").join("/");
        return `
          <footer class="my-5 container text-center">
            <p>
              <a href="https://fsinfo.cs.tu-dortmund.de/impressum">Impressum</a>
              |
              <a href="https://fsinfo.cs.tu-dortmund.de/datenschutz/start">Datenschutz</a>
            </p>
            <p class="text-muted">
              <small>
                <a href="./${depthTraversal}/index.json">JSON</a> <a href="./${depthTraversal}/index.xml">RSS-Feed</a>
              </small>
            </p>
            <p class="text-muted">
              <small>
                HTML generiert: ${new Date().toLocaleString("de-DE")}
              </small>
            </p>
          </footer>`;
    }
    
    export function renderTranscriptPageHtml(
        transcriptHtml: string,
        meta: any,
    ): string {
        return `<!DOCTYPE html>
      <html lang="de">
        <head>
          <meta charset="UTF-8" />
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <title>${meta.title}</title>
          <link href="../../bootstrap.min.css" rel="stylesheet">
          <style>
          div:target {
            border-width: 0.4rem;
            border-color: #f4c4aa;
            scroll-margin-top: 4rem;
          }
          /* Bootstrap <p> style. Necessary, since our internal architecture changed */
          .card-text:has(.resolution, .todo) {
            margin-top: 0;
            margin-bottom: 1rem;
          }
          h1 {
            border-top: solid 0.1rem;
            margin-top: 2rem;
            padding-top: 1rem;
          }
          @media print {
            .hide-in-print {
              display: none !important;
            }
          }
          </style>
          <script>
            window.addEventListener("beforeprint", () => document.getElementById("metadata").open = true );
            window.addEventListener("afterprint", () => document.getElementById("metadata").open = false );
          </script>
        </head>
        <body class="bg-body">
          <div>
            <header class="hide-in-print">
              <nav class="navbar navbar-expand bg-body-tertiary">
                <ul class="navbar-nav">
                <li class="nav-item">
                  <a class="nav-link" href="../..">Protokolle</a>
                </li>
                <li class="nav-item">
                  <a class="nav-link" href="../../resolutions/">Beschlüsse</a>
                </li>
                <!-- WIP:
                <li class="nav-item">
                  <a class="nav-link" href="../../relevant/">Relevante Beschlüsse</a>
                </li>
                -->
                <li class="nav-item">
                  <a class="nav-link" href="../../attendance/">Anwesenheit</a>
                </li>
                <li class="nav-item">
                  <a class="nav-link" href="https://gitlab.fachschaften.org/search?group_id=29&project_id=77&repository_ref=master&scope=blobs&search=">Suche</a>
                </li>
              </ul>
              </nav>
            </header>
          </div>
          <div class="container mt-2 mb-2">
            <div class="bg-body-tertiary">
              <article class="p-4">
              <div class="p-2 rounded text-center" style="background-color: #DDDDDD">
                Fehler in diesem Protokoll können von Gremien-Mitgliedern
                    <a href="https://gitlab.fachschaften.org/tudo-fsinfo/fsr/sitzungen/-/merge_requests">direkt per Merge Request</a>
                  behoben werden. Andernfalls bitte an die Protokollanten (siehe <a href="https://oh14.de/fsr">oh14.de/fsr</a>) wenden.
              </div>
              <h1 class="mb-4">${meta.title}</h1>
              <div class="card mb-4">
              <details class="card-body" id="metadata">
                <summary class="hide-in-print"><b>Metadaten</b></summary>
                <p>
                  Start: ${meta.start}, Ende: ${meta.end}
                  <br />
                  Sitzungsleitung: ${meta.head}, Protokoll: ${meta.author}
                </p>
                <div class="row">
                  <div class="col">
                    <b>Anwesend:</b>
                    <ul>
                      ${meta.present.map((p) => "<li>" + p + "</li>").join("\n")}
                    </ul>
                  </div>
                  <div class="col">
                    <b>Abwesend:</b>
                    <ul>
                      ${meta.absent.map((p) => "<li>" + p + "</li>").join("\n")}
                    </ul>
                  </div>
                  <div class="col">
                    <b>Gäste:</b>
                    <ul>
                      ${meta.guests.map((p) => "<li>" + p + "</li>").join("\n")}
                    </ul>
                  </div>
                </div>
              </details>
              </div>
              <div class="ms-2 me-2">
                <p><i>Die Sitzung wird um ${meta.start} eröffnet.</i></p>
                ${transcriptHtml}
                <p><i>Die Sitzung wird um ${meta.end} geschlossen.</i></p>
              </div>
              </article>
            </div>
          </div>
          ${generateFooterPartialHtml(2)}
        </body>
      </html>`;
    }
    
    function getTranscriptFilename(t: FinishedTranscriptMeta) {
        return (
            [
                "fsr-sitzung",
                typeof t.number === "number"
                    ? `${t.number}`.padStart(3, "0")
                    : null,
                `${t.date}`,
                t.label && t.spec_version === -1
                    ? `${t.label.toLowerCase()}`
                    : null,
            ]
                .filter((x) => x !== null)
                .join("-") + (t.spec_version === -1 ? ".pdf" : "")
        );
    }
    
    function getTranscriptTitle(t: FinishedTranscriptMeta) {
        const interjection =
            (typeof t.number === "number" ? ` ${t.number}` : "")
            + (t.label ? ` ${t.label}` : "");
    
        return `FSR-Sitzung${interjection} (${t.date})`;
    }
    
    export function generateIndexHtml(
        transcripts: FinishedTranscriptMeta[],
    ): string {
        return `<!DOCTYPE html>
      <html lang="de">
        <head>
          <meta charset="UTF-8" />
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <title>FSR-Protokolle</title>
          <link href="./bootstrap.min.css" rel="stylesheet">
        </head>
        <body>
          <div class="">
            <header>
              <nav class="navbar navbar-expand bg-body-tertiary">
                <ul class="navbar-nav">
                <li class="nav-item">
                  <a class="nav-link" href="#">Protokolle</a>
                </li>
                <li class="nav-item">
                  <a class="nav-link" href="./resolutions/">Beschlüsse</a>
                </li>
                <!-- WIP:
                <li class="nav-item">
                  <a class="nav-link" href="./relevant/">Relevante Beschlüsse</a>
                </li>
                -->
                <li class="nav-item">
                  <a class="nav-link" href="./attendance/">Anwesenheit</a>
                </li>
                <li class="nav-item">
                  <a class="nav-link" href="https://gitlab.fachschaften.org/search?group_id=29&project_id=77&repository_ref=master&scope=blobs&search=">Suche</a>
                </li>
              </ul>
              </nav>
            </header>
          </div>
          <div class="container mt-2 mb-2">
            <ul>
              ${transcripts
                  .map((t) => {
                      const title = getTranscriptTitle(t);
                      return `<li>${!t.no_link ? `<a href="./protokolle/${getTranscriptFilename(t)}">${title}</a>` : title}</li>`;
                  })
                  .join("\n")}
            </ul>
          </div>
          ${generateFooterPartialHtml(0)}
        </body>
      </html>`;
    }
    
    export function generateResolutionsHtml(
        transcripts: FinishedTranscriptMeta[],
        modifiedBy: Map<string, Resolution[]>,
        revokedBy: Map<string, Resolution[]>,
        note?: string,
    ): string {
        return `<!DOCTYPE html>
        <html lang="de">
          <head>
            <meta charset="UTF-8" />
            <meta http-equiv="X-UA-Compatible" content="IE=edge" />
            <meta name="viewport" content="width=device-width, initial-scale=1.0" />
            <title>FSR-Protokolle</title>
            <link href="../bootstrap.min.css" rel="stylesheet">
            <style>
              tr:target {
                border-width: 0.4rem;
                border-color: #f4c4aa;
                scroll-margin-top: 4rem;
              }
              details > summary {
                color: #a7a8aa;
              }
              details[open] > summary {
                color: #a7a8aa;
              }
            </style>
          </head>
          <body>
            <svg style="display: none" version="2.0">
              <defs>
                <symbol id="clipboard-icon" viewbox="0 0 16 16" fill="currentColor">
                  <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
                  <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
                </symbol>
              </defs>
              <use href="#clipboard-icon"/>
            </svg>
    
            <div class="">
              <header>
                <nav class="navbar navbar-expand bg-body-tertiary">
                  <ul class="navbar-nav">
                  <li class="nav-item">
                    <a class="nav-link" href="../">Protokolle</a>
                    <i class="fa fa-link"></i>    </li>
                  <li class="nav-item">
                    <a class="nav-link" href="../resolutions/">Beschlüsse</a>
                  </li>
                  <!-- WIP:
                  <li class="nav-item">
                    <a class="nav-link" href="../relevant/">Relevante Beschlüsse</a>
                  </li>
                  -->
                  <li class="nav-item">
                    <a class="nav-link" href="../attendance/">Anwesenheit</a>
                  </li>
                  <li class="nav-item">
                    <a class="nav-link" href="https://gitlab.fachschaften.org/search?group_id=29&project_id=77&repository_ref=master&scope=blobs&search=">Suche</a>
                  </li>
                </ul>
                </nav>
              </header>
            </div>
            ${note ?? ""}
            <div class="container mt-2 mb-2">
              <table class="table">
                <thead>
                  <tr>
                      <th>
                          Datum
                      </th>
                      <th>
                          Nummer
                      </th>
                      <th>
                          Text
                      </th>
                      <th>
                          Ja/Nein/Enth.
                      </th>
                      <th>
                          Ergebnis
                      </th>
                      <th>
                      ⚓️
                      </th>
                  </tr>
                </thead>
                <tbody>
                  ${transcripts
                      .map((t) => {
                          const transcriptFilename = !t.no_link
                              ? getTranscriptFilename(t)
                              : null;
                          const resolutions = t.resolutions ?? [];
    
                          return resolutions.map((r) =>
                              generateResolutionRowHtml(
                                  r,
                                  transcriptFilename,
                                  t.date,
                                  modifiedBy.get(r.number),
                                  revokedBy.get(r.number),
                              ),
                          );
                      })
                      .flat()
                      .join("\n")}
                </tbody>
              </table>
            </div>
          ${generateFooterPartialHtml(0)}
          </body>
        </html>`;
    }
    
    function generateResolutionRowHtml(
        resolution: Resolution,
        transcriptFileName?: string,
        date: string,
        modifiedBy?: Resolution[],
        revokedBy?: Resolution[],
    ): string {
        function samePageResolutionLink(other: Resolution | string) {
            const number = other?.number ?? other;
            // TODO: check if link exists.
            const link = `<a href=${"#" + number}>${number}</a>`;
            return typeof other === "string" || other.isActive !== false
                ? link
                : `<del>${link}</del>`;
        }
        function linkResolutions(name: string, others: Resolution[] | string[]) {
            function genInnerHTML(name: string, others: Resolution[] | string[]) {
                return `<strong>${name}:</strong> ${others.map(samePageResolutionLink).join(", ")}`;
            }
            if (others && others.length > 0)
                return `<p>${genInnerHTML(name, others)}</p>`;
            else return "";
        }
    
        let linkedNumber =
            transcriptFileName !== null
                ? `<a href="../protokolle/${transcriptFileName}/#${resolution.number}">${resolution.number}</a>`
                : `${resolution.number}`;
    
        let votes;
        if (resolution.votes === undefined) {
            votes = "";
        } else if (
            resolution.votes.yes === undefined
            || resolution.votes.no === undefined
        ) {
            votes = Object.keys(resolution.votes)
                .map(
                    (r) =>
                        `${r === "abstention" ? "Enth." : r}: ${resolution.votes[r]}`,
                )
                .join("<br>");
        } else {
            votes = `${resolution.votes.yes} / ${resolution.votes.no} / ${resolution.votes.abstention}`;
        }
    
        return `<tr id="${resolution.number}">
        <td>
          ${date}
        </td>
        <td>
          ${linkedNumber}
        </td>
        <td>
          <div style="${resolution.isActive === false ? "color: red" : ""}">
            ${resolution.html}
            ${linkResolutions("Modifiziert", resolution.modifies)}
            ${linkResolutions("Widerruft", resolution.revokes)}
          </div>
          ${linkResolutions("Modifiziert durch", modifiedBy)}
          ${linkResolutions("Widerrufen durch", revokedBy)}
          ${
              resolution.provisional
                  ? `<p><strong>Kommissarisch, weil:</strong> ${resolution.provisional}</p>`
                  : ""
          }
          ${
              typeof resolution.money_granted === "string"
                  ? generateHledgerDetailsHtml(resolution)
                  : ""
          }
          ${
              resolution.note
                  ? `<p><strong>Notiz:</strong> ${resolution.note}</p>`
                  : ""
          }
        </td>
        <td>
          ${votes}
        </td>
        <td>
        ${resolution.result ?? "Unbekannt"}
        </td>
        <td>
            <a href="#${resolution.number}">
            🔗
            </a>
        </td>
    </tr>`;
    }
    
    export function generateTranscriptsRssXml(
        transcripts: FinishedTranscriptMeta[],
        rootUrl: string | null = null,
    ): string {
        return `<?xml version="1.0" encoding="UTF-8" ?>
    <rss version="2.0">
      <channel>
        <title>Protokolle</title>
        ${transcripts
            .map(
                (t) =>
                    `<item>${
                        !t.no_link
                            ? `<link>${rootUrl ?? "."}/protokolle/${getTranscriptFilename(t)}</link>`
                            : ``
                    }<description>${getTranscriptTitle(t)}</description><title>${getTranscriptTitle(t)}</title><pubDate>${t.date}</pubDate></item>`,
            )
            .join("\n    ")}
      </channel>
    </rss>`;
    }
    
    function generateHledgerDetailsHtml(resolution: Resolution): string {
        const hledgerText = `account Gebunden:${resolution.number}
    ${resolution.date} ${resolution.number}: beschlossen
    	[Gebunden:${resolution.number}]  ${resolution.money_granted}
    	[Vermögen:FSR:Gebunden]`;
    
        return `<details><summary><small class="text-body-secondary fw-light">Hledger-Statement für die Buchhaltung</small></summary><pre><code id="reso-text-${resolution.number}">${hledgerText}</code></pre><button class="btn btn-outline-primary btn-sm" onclick="navigator.clipboard.writeText(document.getElementById(\'reso-text-${resolution.number}\').innerText)"><svg width="16" height="16" version="2.0"><use href="#clipboard-icon" /></svg> Hledger-Statement kopieren</button></details>`;
    }
    
    export function generateAttendanceHtmlWrapper(
        attendanceParts: [
            Map<string, Attendance>,
            string,
            number,
            number | undefined,
        ][],
    ): string {
        return `<!DOCTYPE html>
        <html lang="de">
          <head>
            <meta charset="UTF-8" />
            <meta http-equiv="X-UA-Compatible" content="IE=edge" />
            <meta name="viewport" content="width=device-width, initial-scale=1.0" />
            <title>FSR-Anwesenheit</title>
            <link href="../bootstrap.min.css" rel="stylesheet">
            <style>
              .percentage-bar {
                display: flex;
                height: 15px;
                width: 100%;
                border-radius: 8px;
                overflow: hidden;
                border: 1px solid #ccc;
                margin-top: 5px;
              }
              body:has(#grouped-bar-toggle:checked) .grouped-bar {
                visibility: visible;
                position: static;
              }
              body:has(#grouped-bar-toggle:checked) .ungrouped-bar {
                visibility: hidden;
                position: absolute;
              }
              .grouped-bar {
                visibility: hidden;
                position: absolute;
              }
              .ungrouped-bar {
                visibility: visible;
                position: static;
              }
            </style>
            <script>
              document.addEventListener('DOMContentLoaded', function () {
                const getCellValue = (tr, idx) => tr.children[idx].innerText || tr.children[idx].textContent;
              
                const comparer = (idx, asc) => (a, b) => {
                  const v1 = getCellValue(a, idx);
                  const v2 = getCellValue(b, idx);
              
                  const f1 = parseFloat(v1);
                  const f2 = parseFloat(v2);
                  if (!isNaN(f1) && !isNaN(f2)) {
                    return (f1 - f2) * (asc ? 1 : -1);
                  } else {
                    return v1.toString().localeCompare(v2) * (asc ? 1 : -1);
                  }
                };
              
                const sortTable = (table, columnIndex, asc) => {
                  const tbody = table.querySelector('tbody');
                  const rows = Array.from(tbody.querySelectorAll('tr'));
                  rows.sort(comparer(columnIndex, asc));
                  rows.forEach(row => tbody.appendChild(row));
              
                  // Clean up previous sort indicators
                  table.querySelectorAll('th').forEach(th => th.classList.remove('asc', 'desc'));
                  const th = table.querySelectorAll('th')[columnIndex];
                  th.classList.toggle('asc', asc);
                  th.classList.toggle('desc', !asc);
                };
              
                // Enable click-to-sort
                document.querySelectorAll('table').forEach(table => {
                  table.querySelectorAll('th').forEach((th, idx) => {
                    th.addEventListener('click', function () {
                      const isAsc = !th.classList.contains('asc');
                      sortTable(table, idx, isAsc);
                    });
                  });
              
                  // Sort third column descending by default on page load
                  sortTable(table, 2, false);
                });
              });
              </script>
          </head>
          <body>
            <div class="">
              <header>
                <nav class="navbar navbar-expand bg-body-tertiary">
                  <ul class="navbar-nav">
                    <li class="nav-item">
                      <a class="nav-link" href="../">Protokolle</a>
                      <i class="fa fa-link"></i>    </li>
                    <li class="nav-item">
                      <a class="nav-link" href="../resolutions/">Beschlüsse</a>
                    </li>
                    <!-- WIP:
                    <li class="nav-item">
                      <a class="nav-link" href="../relevant/">Relevante Beschlüsse</a>
                    </li>
                    -->
                    <li class="nav-item">
                      <a class="nav-link" href="../attendance/">Anwesenheit</a>
                    </li>
                    <li class="nav-item">
                      <a class="nav-link" href="https://gitlab.fachschaften.org/search?group_id=29&project_id=77&repository_ref=master&scope=blobs&search=">Suche</a>
                    </li> 
                  </ul>
                  <div class="ms-auto d-flex-flex align-items-center form-check me-3">
                    <input class="form-check-input me-2" type="checkbox" id="grouped-bar-toggle" value="" checked />
                    <label class="form-check-label" for="grouped-bar-toggle">
                      Group attendance bar
                    </label>
                  </div>
                </nav>
              </header>
            </div>
            ${attendanceParts.map(([attendanceMap, summary, from, to]) => generateAttendanceHtml(attendanceMap, summary, from, to)).join("\n")}
          ${generateFooterPartialHtml(0)}
          </body>
        </html>`;
    }
    
    export function generateAttendanceHtml(
        attendanceMap: Map<string, Attendance>,
        summary: string,
        from: number,
        to?: number,
    ): string {
        const open = to === undefined;
    
        return `
          <details class="border"${open ? " open" : ""}>
            <summary class="fs-4 mx-3 p-2">${summary} von Sitzung ${from} bis ${to === undefined ? "heute" : "Sitzung " + to}</summary>
            <div class="container my-2">
              <table class="table table-striped">
                <thead>
                  <tr>
                      <th>
                          Name
                      </th>
                      <th>
                          Anwesehnheit
                      </th>
                      <th>
                          Vor Ort
                      </th>
                      <th>
                          Nicht da (entschuldigt)
                      </th>
                      <th>
                          Nicht da (unentschuldigt)
                      </th>
                      <th>
                          Anwesenheitsquotient
                      </th>
                  </tr>
                </thead>
                <tbody>
                  ${[...attendanceMap.entries()].map(([k, v]) => generateAttendanceRowHtml(k, v)).join("")}
                </tbody>
              </table>
            </div>
          </details>`;
    }
    
    function generateAttendanceRowHtml(name: string, data: Attendance): string {
        return `
          <tr>
            <td> ${name} </td> 
            <td> ${generateGroupedPercentageBar(data)} ${generateUngroupedPercentageBar(data)} </td> 
            <td> ${data.present} </td> 
            <td> ${data.noshow_excused} </td> 
            <td> ${data.noshow}</td> 
            <td> ${((data.present * 100) / data.sum()).toFixed(1)}% </td> 
          </tr>`;
    }
    
    function generateUngroupedPercentageBar(data: Attendance): string {
        const width = 100 / data.sum();
    
        const tagged: { value: number; color: string }[] = [
            ...data.presents.map((value) => ({ value, color: "darkgreen" })),
            ...data.noshows.map((value) => ({ value, color: "red" })),
            ...data.noshow_excuseds.map((value) => ({ value, color: "yellow" })),
        ];
    
        // Sort by number
        tagged.sort((a, b) => a.value - b.value);
    
        var barHtml = '<div class="percentage-bar ungrouped-bar">';
        // Iterate in order
        for (const { color } of tagged) {
            barHtml += `<div style="width: ${width}%;background-color: ${color}"></div>`;
        }
        barHtml += "</div>";
        return barHtml;
    }
    
    function generateGroupedPercentageBar(data: Attendance): string {
        return `
          <div class="percentage-bar grouped-bar">
            <div style="width: ${(data.present * 100) / data.sum()}%;background-color: darkgreen"></div>
            <div style="width: ${(data.noshow_excused * 100) / data.sum()}%;background-color: yellow"></div>
            <div style="width: ${(data.noshow * 100) / data.sum()}%;background-color: red"></div>
          </div>`;
    }