import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const USER_ID_KEY = "folk-choice-userid"; const USER_NAME_KEY = "folk-choice-username"; const styles = css` :host { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 340px; min-height: 380px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #4f46e5; color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .body { display: flex; flex-direction: column; height: calc(100% - 36px); overflow: hidden; } .tab-bar { display: flex; border-bottom: 1px solid #e2e8f0; } .tab { flex: 1; padding: 6px 8px; text-align: center; font-size: 11px; font-weight: 500; cursor: pointer; border: none; background: transparent; color: #64748b; transition: all 0.15s; } .tab.active { color: #4f46e5; border-bottom: 2px solid #4f46e5; background: #eef2ff; } .panel { flex: 1; overflow-y: auto; padding: 8px 12px; } .panel.hidden { display: none; } /* Rank list */ .rank-item { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 6px; margin-bottom: 4px; background: #f8fafc; border: 1px solid #e2e8f0; cursor: grab; user-select: none; transition: transform 0.15s, box-shadow 0.15s; } .rank-item:active { cursor: grabbing; } .rank-item.dragging { opacity: 0.5; background: #eef2ff; } .rank-item.drag-over { border-color: #4f46e5; transform: scale(1.02); box-shadow: 0 2px 8px rgba(79, 70, 229, 0.15); } .grip { color: #94a3b8; font-size: 10px; line-height: 1; flex-shrink: 0; } .rank-num { font-size: 13px; font-weight: 700; min-width: 18px; text-align: center; flex-shrink: 0; } .rank-label { flex: 1; font-size: 13px; color: #1e293b; } /* Results */ .results-section { margin-bottom: 12px; } .results-heading { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } .borda-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .borda-label { font-size: 12px; min-width: 80px; color: #1e293b; } .borda-bar-bg { flex: 1; height: 14px; background: #f1f5f9; border-radius: 3px; overflow: hidden; } .borda-bar-fill { height: 100%; background: #4f46e5; border-radius: 3px; transition: width 0.4s ease; } .borda-pts { font-size: 11px; font-weight: 600; color: #4f46e5; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; } .irv-round { font-size: 11px; padding: 4px 0; border-bottom: 1px solid #f1f5f9; color: #475569; } .irv-round .round-num { font-weight: 600; color: #4f46e5; } .irv-round .eliminated { color: #ef4444; font-weight: 500; } .irv-round .winner { color: #22c55e; font-weight: 600; } .voters-count { padding: 4px 0; font-size: 11px; color: #94a3b8; text-align: center; } .add-form { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid #e2e8f0; } .add-input { flex: 1; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; } .add-input:focus { border-color: #4f46e5; } .add-btn { background: #4f46e5; color: white; border: none; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 12px; font-weight: 500; } .add-btn:hover { background: #4338ca; } .username-prompt { padding: 16px; text-align: center; } .username-prompt p { font-size: 13px; color: #64748b; margin: 0 0 8px; } .username-input { border: 1px solid #e2e8f0; border-radius: 6px; padding: 8px 12px; font-size: 13px; outline: none; width: 100%; box-sizing: border-box; margin-bottom: 8px; } .username-btn { background: #4f46e5; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-weight: 500; font-size: 13px; } .empty-state { text-align: center; padding: 24px 12px; color: #94a3b8; font-size: 12px; } .wrapper { position: relative; height: 100%; } .results-drawer { position: absolute; top: 0; left: 100%; width: 300px; height: 100%; background: white; border-radius: 0 8px 8px 0; box-shadow: 4px 0 12px rgba(0,0,0,0.08); overflow-y: auto; display: none; flex-direction: column; font-size: 12px; z-index: 10; } .drawer-open .results-drawer { display: flex; } .drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; } .drawer-heading { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; } .stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; } .stat-label { color: #64748b; } .stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; } .drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } .drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; } .drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; } .drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } .drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; } .participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; } .participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .drawer-toggle.active { background: rgba(255,255,255,0.3); } `; // -- Data types -- export interface RankOption { id: string; label: string; } export interface UserRanking { userId: string; userName: string; ordering: string[]; timestamp: number; } export interface IRVRound { round: number; counts: Record; eliminated: string | null; } // -- Pure aggregation functions -- export function bordaCount( rankings: UserRanking[], options: RankOption[], ): Map { const n = options.length; const scores = new Map(); for (const opt of options) scores.set(opt.id, 0); for (const r of rankings) { for (let i = 0; i < r.ordering.length; i++) { const points = n - 1 - i; const optId = r.ordering[i]; if (scores.has(optId)) { scores.set(optId, scores.get(optId)! + points); } } } return scores; } export function instantRunoff( rankings: UserRanking[], options: RankOption[], ): IRVRound[] { if (rankings.length === 0 || options.length === 0) return []; const rounds: IRVRound[] = []; const remaining = new Set(options.map((o) => o.id)); let round = 1; while (remaining.size > 1) { // Count first-place votes among remaining options const counts: Record = {}; for (const id of remaining) counts[id] = 0; for (const r of rankings) { const top = r.ordering.find((id) => remaining.has(id)); if (top) counts[top]++; } const totalVotes = rankings.length; // Check for majority let majorityWinner: string | null = null; for (const [id, count] of Object.entries(counts)) { if (count > totalVotes / 2) { majorityWinner = id; break; } } if (majorityWinner) { rounds.push({ round, counts, eliminated: null }); break; } // Find minimum vote count let minCount = Infinity; for (const count of Object.values(counts)) { if (count < minCount) minCount = count; } // Eliminate first option with minimum count let eliminated: string | null = null; for (const [id, count] of Object.entries(counts)) { if (count === minCount) { eliminated = id; break; } } rounds.push({ round, counts, eliminated }); if (eliminated) remaining.delete(eliminated); round++; } // If eliminated down to 1 if (remaining.size === 1 && (rounds.length === 0 || rounds[rounds.length - 1].eliminated !== null)) { const lastId = [...remaining][0]; rounds.push({ round, counts: { [lastId]: rankings.length }, eliminated: null }); } return rounds; } export function kendallTauB(rankings: UserRanking[], options: RankOption[]): number { if (rankings.length < 2) return 0; const optIds = options.map((o) => o.id); let totalTau = 0; let pairs = 0; for (let i = 0; i < rankings.length; i++) { for (let j = i + 1; j < rankings.length; j++) { const posA = new Map(rankings[i].ordering.map((id, idx) => [id, idx])); const posB = new Map(rankings[j].ordering.map((id, idx) => [id, idx])); let concordant = 0; let discordant = 0; for (let a = 0; a < optIds.length; a++) { for (let b = a + 1; b < optIds.length; b++) { const dA = (posA.get(optIds[a]) ?? 0) - (posA.get(optIds[b]) ?? 0); const dB = (posB.get(optIds[a]) ?? 0) - (posB.get(optIds[b]) ?? 0); if (dA * dB > 0) concordant++; else if (dA * dB < 0) discordant++; } } const total = concordant + discordant; if (total > 0) { totalTau += (concordant - discordant) / total; pairs++; } } } return pairs > 0 ? totalTau / pairs : 0; } export function positionFrequency( rankings: UserRanking[], options: RankOption[], ): Map { const result = new Map(); const n = options.length; for (const opt of options) result.set(opt.id, new Array(n).fill(0)); for (const r of rankings) { for (let i = 0; i < r.ordering.length; i++) { const counts = result.get(r.ordering[i]); if (counts && i < counts.length) counts[i]++; } } return result; } export function headToHead( rankings: UserRanking[], options: RankOption[], ): Map> { const result = new Map>(); for (const a of options) { result.set(a.id, new Map()); for (const b of options) { if (a.id !== b.id) result.get(a.id)!.set(b.id, 0); } } for (const r of rankings) { const pos = new Map(r.ordering.map((id, idx) => [id, idx])); for (const a of options) { for (const b of options) { if (a.id === b.id) continue; if ((pos.get(a.id) ?? Infinity) < (pos.get(b.id) ?? Infinity)) { result.get(a.id)!.set(b.id, result.get(a.id)!.get(b.id)! + 1); } } } } return result; } export function condorcetWinner(rankings: UserRanking[], options: RankOption[]): string | null { const h2h = headToHead(rankings, options); for (const a of options) { let wins = true; for (const b of options) { if (a.id === b.id) continue; if ((h2h.get(a.id)?.get(b.id) ?? 0) <= (h2h.get(b.id)?.get(a.id) ?? 0)) { wins = false; break; } } if (wins) return a.id; } return null; } // -- Component -- declare global { interface HTMLElementTagNameMap { "folk-choice-rank": FolkChoiceRank; } } const MEDAL_COLORS = ["#f59e0b", "#94a3b8", "#cd7f32", "#64748b", "#64748b"]; export class FolkChoiceRank extends FolkShape { static override tagName = "folk-choice-rank"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #title = "Rank Choices"; #options: RankOption[] = []; #rankings: UserRanking[] = []; #userId = ""; #userName = ""; #activeTab: "rank" | "results" = "rank"; #drawerOpen = false; // Drag state #dragIdx: number | null = null; #myOrdering: string[] = []; // DOM refs #wrapperEl: HTMLElement | null = null; #bodyEl: HTMLElement | null = null; #rankPanel: HTMLElement | null = null; #resultsPanel: HTMLElement | null = null; #drawerEl: HTMLElement | null = null; get title() { return this.#title; } set title(v: string) { this.#title = v; this.requestUpdate("title"); } get options() { return this.#options; } set options(v: RankOption[]) { this.#options = v; this.#syncMyOrdering(); this.#render(); this.requestUpdate("options"); } get rankings() { return this.#rankings; } set rankings(v: UserRanking[]) { this.#rankings = v; this.#syncMyOrdering(); this.#render(); this.requestUpdate("rankings"); } #ensureIdentity(): boolean { if (this.#userId && this.#userName) return true; this.#userId = localStorage.getItem(USER_ID_KEY) || ""; this.#userName = localStorage.getItem(USER_NAME_KEY) || localStorage.getItem("rspace-username") || ""; if (!this.#userId) { this.#userId = crypto.randomUUID().slice(0, 8); localStorage.setItem(USER_ID_KEY, this.#userId); } return !!this.#userName; } #setUserName(name: string) { this.#userName = name; localStorage.setItem(USER_NAME_KEY, name); localStorage.setItem("rspace-username", name); } #syncMyOrdering() { const mine = this.#rankings.find((r) => r.userId === this.#userId); if (mine) { // Keep existing ordering, add new options at end const existing = new Set(mine.ordering); const extra = this.#options.filter((o) => !existing.has(o.id)).map((o) => o.id); this.#myOrdering = [...mine.ordering.filter((id) => this.#options.some((o) => o.id === id)), ...extra]; } else { this.#myOrdering = this.#options.map((o) => o.id); } } #saveMyRanking() { if (!this.#ensureIdentity()) return; const idx = this.#rankings.findIndex((r) => r.userId === this.#userId); const entry: UserRanking = { userId: this.#userId, userName: this.#userName, ordering: [...this.#myOrdering], timestamp: Date.now(), }; if (idx >= 0) { this.#rankings[idx] = entry; } else { this.#rankings.push(entry); } this.dispatchEvent(new CustomEvent("content-change")); } override createRenderRoot() { const root = super.createRenderRoot(); this.#ensureIdentity(); this.#syncMyOrdering(); const wrapper = document.createElement("div"); wrapper.className = "wrapper"; wrapper.innerHTML = html`
📊 Rank
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) containerDiv.replaceWith(wrapper); this.#wrapperEl = wrapper; this.#bodyEl = wrapper.querySelector(".body") as HTMLElement; this.#rankPanel = wrapper.querySelector(".rank-panel") as HTMLElement; this.#resultsPanel = wrapper.querySelector(".results-panel") as HTMLElement; this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement; const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement; drawerToggle.addEventListener("click", (e) => { e.stopPropagation(); this.#drawerOpen = !this.#drawerOpen; this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen); drawerToggle.classList.toggle("active", this.#drawerOpen); if (this.#drawerOpen) this.#renderDrawer(); }); const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; const addInput = wrapper.querySelector(".add-input") as HTMLInputElement; const addBtn = wrapper.querySelector(".add-btn") as HTMLButtonElement; if (!this.#userName) { this.#bodyEl.style.display = "none"; usernamePrompt.style.display = "block"; } const submitName = () => { const name = usernameInput.value.trim(); if (name) { this.#setUserName(name); this.#bodyEl!.style.display = "flex"; usernamePrompt.style.display = "none"; this.#saveMyRanking(); this.#render(); } }; usernameBtn.addEventListener("click", (e) => { e.stopPropagation(); submitName(); }); usernameInput.addEventListener("click", (e) => e.stopPropagation()); usernameInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") submitName(); }); // Tabs wrapper.querySelectorAll(".tab").forEach((tab) => { tab.addEventListener("click", (e) => { e.stopPropagation(); const t = (tab as HTMLElement).dataset.tab as "rank" | "results"; this.#activeTab = t; wrapper.querySelectorAll(".tab").forEach((tb) => tb.classList.remove("active")); tab.classList.add("active"); this.#rankPanel!.classList.toggle("hidden", t !== "rank"); this.#resultsPanel!.classList.toggle("hidden", t !== "results"); this.#render(); }); }); // Add option const addOption = () => { const label = addInput.value.trim(); if (!label) return; const id = `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; this.#options.push({ id, label }); this.#myOrdering.push(id); addInput.value = ""; this.#saveMyRanking(); this.#render(); }; addBtn.addEventListener("click", (e) => { e.stopPropagation(); addOption(); }); addInput.addEventListener("click", (e) => e.stopPropagation()); addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOption(); }); wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); this.#render(); return root; } #render() { if (this.#activeTab === "rank") this.#renderRankList(); else this.#renderResults(); if (this.#drawerOpen) this.#renderDrawer(); } #renderRankList() { if (!this.#rankPanel) return; if (this.#myOrdering.length === 0) { this.#rankPanel.innerHTML = '
Add options below to start ranking
'; return; } const optMap = new Map(this.#options.map((o) => [o.id, o])); this.#rankPanel.innerHTML = this.#myOrdering .map((id, i) => { const opt = optMap.get(id); if (!opt) return ""; const color = MEDAL_COLORS[Math.min(i, MEDAL_COLORS.length - 1)]; return `
≡≡ ${i + 1} ${this.#escapeHtml(opt.label)}
`; }) .join(""); // Wire pointer-based drag this.#rankPanel.querySelectorAll(".rank-item").forEach((item) => { const el = item as HTMLElement; el.addEventListener("pointerdown", (e) => { e.stopPropagation(); this.#dragIdx = parseInt(el.dataset.idx!); el.classList.add("dragging"); el.setPointerCapture(e.pointerId); const onMove = (me: PointerEvent) => { me.stopPropagation(); const target = this.#rankPanel!.querySelector( `.rank-item:not(.dragging):hover` ) as HTMLElement; // Clear previous drag-over states this.#rankPanel!.querySelectorAll(".drag-over").forEach((d) => d.classList.remove("drag-over")); if (target) target.classList.add("drag-over"); }; const onUp = (ue: PointerEvent) => { ue.stopPropagation(); el.classList.remove("dragging"); el.releasePointerCapture(ue.pointerId); el.removeEventListener("pointermove", onMove); el.removeEventListener("pointerup", onUp); // Find drop target const overEl = this.#rankPanel!.querySelector(".drag-over") as HTMLElement; if (overEl && this.#dragIdx !== null) { const targetIdx = parseInt(overEl.dataset.idx!); if (targetIdx !== this.#dragIdx) { const [moved] = this.#myOrdering.splice(this.#dragIdx, 1); this.#myOrdering.splice(targetIdx, 0, moved); this.#saveMyRanking(); } } this.#rankPanel!.querySelectorAll(".drag-over").forEach((d) => d.classList.remove("drag-over")); this.#dragIdx = null; this.#renderRankList(); }; el.addEventListener("pointermove", onMove); el.addEventListener("pointerup", onUp); }); // Keyboard reorder el.tabIndex = 0; el.addEventListener("keydown", (e) => { e.stopPropagation(); const idx = parseInt(el.dataset.idx!); if (e.key === "ArrowUp" && idx > 0) { [this.#myOrdering[idx - 1], this.#myOrdering[idx]] = [this.#myOrdering[idx], this.#myOrdering[idx - 1]]; this.#saveMyRanking(); this.#renderRankList(); (this.#rankPanel!.querySelector(`[data-idx="${idx - 1}"]`) as HTMLElement)?.focus(); } else if (e.key === "ArrowDown" && idx < this.#myOrdering.length - 1) { [this.#myOrdering[idx], this.#myOrdering[idx + 1]] = [this.#myOrdering[idx + 1], this.#myOrdering[idx]]; this.#saveMyRanking(); this.#renderRankList(); (this.#rankPanel!.querySelector(`[data-idx="${idx + 1}"]`) as HTMLElement)?.focus(); } }); }); } #renderResults() { if (!this.#resultsPanel) return; const uniqueVoters = new Set(this.#rankings.map((r) => r.userId)).size; if (this.#rankings.length === 0) { this.#resultsPanel.innerHTML = '
No rankings submitted yet
'; return; } const optMap = new Map(this.#options.map((o) => [o.id, o])); // Borda count const borda = bordaCount(this.#rankings, this.#options); const maxBorda = Math.max(1, ...borda.values()); const bordaSorted = [...borda.entries()].sort((a, b) => b[1] - a[1]); let bordaHtml = '
Borda Count
'; for (const [optId, pts] of bordaSorted) { const opt = optMap.get(optId); if (!opt) continue; const pct = (pts / maxBorda) * 100; bordaHtml += `
${this.#escapeHtml(opt.label)}
${pts}
`; } bordaHtml += "
"; // IRV const irvRounds = instantRunoff(this.#rankings, this.#options); let irvHtml = '
Instant-Runoff
'; for (const r of irvRounds) { const counts = Object.entries(r.counts) .map(([id, c]) => `${optMap.get(id)?.label || id}: ${c}`) .join(", "); if (r.eliminated) { const elimLabel = optMap.get(r.eliminated)?.label || r.eliminated; irvHtml += `
Round ${r.round}: ${counts} — ${this.#escapeHtml(elimLabel)} eliminated
`; } else { // Winner round const winnerId = Object.keys(r.counts)[0]; const winLabel = optMap.get(winnerId)?.label || winnerId; irvHtml += `
Round ${r.round}: ${counts} — ${this.#escapeHtml(winLabel)} wins!
`; } } irvHtml += "
"; this.#resultsPanel.innerHTML = bordaHtml + irvHtml + `
${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}
`; } #renderDrawer() { if (!this.#drawerEl) return; const optMap = new Map(this.#options.map((o) => [o.id, o])); // Borda count const borda = bordaCount(this.#rankings, this.#options); const maxBorda = Math.max(1, ...borda.values()); const bordaSorted = [...borda.entries()].sort((a, b) => b[1] - a[1]); let resultsHtml = '
Group Results
'; for (const [optId, pts] of bordaSorted) { const opt = optMap.get(optId); if (!opt) continue; const pct = (pts / maxBorda) * 100; resultsHtml += `
${this.#escapeHtml(opt.label)}
${pts}
`; } // IRV winner const irvRounds = instantRunoff(this.#rankings, this.#options); if (irvRounds.length > 0) { const lastRound = irvRounds[irvRounds.length - 1]; const winnerId = Object.keys(lastRound.counts)[0]; const winLabel = optMap.get(winnerId)?.label || winnerId; resultsHtml += `
IRV Winner${this.#escapeHtml(winLabel)}
`; } // Condorcet winner const cw = condorcetWinner(this.#rankings, this.#options); if (cw) { const cwLabel = optMap.get(cw)?.label || cw; resultsHtml += `
Condorcet Winner${this.#escapeHtml(cwLabel)}
`; } else { resultsHtml += `
Condorcet WinnerNone
`; } resultsHtml += "
"; // Statistics const uniqueVoters = new Set(this.#rankings.map((r) => r.userId)).size; const tau = kendallTauB(this.#rankings, this.#options); let statsHtml = '
Statistics
'; statsHtml += `
Voters${uniqueVoters}
`; statsHtml += `
Agreement (Kendall)${tau.toFixed(2)}
`; // Head-to-head const h2h = headToHead(this.#rankings, this.#options); for (const a of this.#options) { for (const b of this.#options) { if (a.id >= b.id) continue; const aWins = h2h.get(a.id)?.get(b.id) ?? 0; const bWins = h2h.get(b.id)?.get(a.id) ?? 0; statsHtml += `
${this.#escapeHtml(a.label)} v ${this.#escapeHtml(b.label)}${aWins}-${bWins}
`; } } statsHtml += "
"; // Participants let participantsHtml = '
Participants
'; for (const r of this.#rankings) { const ago = this.#timeAgo(r.timestamp); participantsHtml += `
${this.#escapeHtml(r.userName)} ${ago}
`; } participantsHtml += "
"; this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml; } #timeAgo(ts: number): string { const diff = Date.now() - ts; if (diff < 60000) return "just now"; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return `${Math.floor(diff / 86400000)}d ago`; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-choice-rank", title: this.#title, options: this.#options, rankings: this.#rankings, }; } }