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: #d97706; 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; } .options-list { flex: 1; overflow-y: auto; padding: 8px 12px; } .option-row { display: flex; align-items: center; gap: 6px; padding: 8px; border-radius: 6px; margin-bottom: 6px; background: #fffbeb; border: 1px solid #fde68a; position: relative; overflow: hidden; } .conviction-bg { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 6px; opacity: 0.12; transition: width 0.4s ease; } .option-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; z-index: 1; } .option-label { flex: 1; font-size: 13px; color: #1e293b; z-index: 1; } .stake-controls { display: flex; align-items: center; gap: 4px; z-index: 1; } .stake-btn { width: 22px; height: 22px; border: 1px solid #fbbf24; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; color: #92400e; } .stake-btn:hover { background: #fef3c7; } .stake-btn:disabled { opacity: 0.3; cursor: default; } .stake-count { font-size: 12px; font-variant-numeric: tabular-nums; min-width: 14px; text-align: center; color: #92400e; font-weight: 600; z-index: 1; } .option-conviction { font-size: 11px; color: #d97706; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 40px; text-align: right; z-index: 1; } .option-time { font-size: 10px; color: #94a3b8; min-width: 32px; text-align: right; z-index: 1; } .weight-bar { padding: 4px 12px; font-size: 11px; color: #64748b; border-top: 1px solid #e2e8f0; text-align: center; } .weight-bar .used { font-weight: 600; color: #d97706; } .voters-count { padding: 4px 12px; 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: #d97706; } .add-btn { background: #d97706; color: white; border: none; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 12px; font-weight: 500; } .add-btn:hover { background: #b45309; } .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: #d97706; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-weight: 500; font-size: 13px; } .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 ConvictionOption { id: string; label: string; color: string; } export interface ConvictionStake { userId: string; userName: string; optionId: string; weight: number; since: number; } // -- Pure aggregation functions -- export function convictionScore( stakes: ConvictionStake[], optionId: string, now: number, ): number { let total = 0; for (const s of stakes) { if (s.optionId !== optionId) continue; total += s.weight * Math.max(0, now - s.since) / 3600000; } return total; } export function convictionVelocity( stakes: ConvictionStake[], optionId: string, ): number { let total = 0; for (const s of stakes) { if (s.optionId !== optionId) continue; total += s.weight; } return total; } // -- Component -- declare global { interface HTMLElementTagNameMap { "folk-choice-conviction": FolkChoiceConviction; } } const DEFAULT_COLORS = ["#f59e0b", "#3b82f6", "#22c55e", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; export class FolkChoiceConviction extends FolkShape { static override tagName = "folk-choice-conviction"; 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 = "Conviction Ranking"; #options: ConvictionOption[] = []; #stakes: ConvictionStake[] = []; #userId = ""; #userName = ""; #drawerOpen = false; #tickInterval: ReturnType | null = null; // DOM refs #wrapperEl: HTMLElement | null = null; #bodyEl: HTMLElement | null = null; #optionsEl: HTMLElement | null = null; #weightEl: HTMLElement | null = null; #votersEl: 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: ConvictionOption[]) { this.#options = v; this.#render(); this.requestUpdate("options"); } get stakes() { return this.#stakes; } set stakes(v: ConvictionStake[]) { this.#stakes = v; this.#render(); this.requestUpdate("stakes"); } #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); } #getMyStake(optionId: string): ConvictionStake | undefined { return this.#stakes.find((s) => s.userId === this.#userId && s.optionId === optionId); } #setStake(optionId: string, delta: number) { if (!this.#ensureIdentity()) return; const existing = this.#getMyStake(optionId); const currentWeight = existing ? existing.weight : 0; const newWeight = Math.max(0, Math.min(10, currentWeight + delta)); if (newWeight === 0) { // Remove stake this.#stakes = this.#stakes.filter( (s) => !(s.userId === this.#userId && s.optionId === optionId), ); } else if (existing) { // Update — reset conviction timer on weight change existing.weight = newWeight; existing.since = Date.now(); } else { // New stake this.#stakes.push({ userId: this.#userId, userName: this.#userName, optionId, weight: newWeight, since: Date.now(), }); } this.#render(); this.dispatchEvent(new CustomEvent("content-change")); } override createRenderRoot() { const root = super.createRenderRoot(); this.#ensureIdentity(); const wrapper = document.createElement("div"); wrapper.className = "wrapper"; wrapper.innerHTML = html`
Conviction
`; 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.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement; this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement; this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement; this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement; const titleEl = wrapper.querySelector(".title-text") 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.#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(); }); // Add option const addOption = () => { const label = addInput.value.trim(); if (!label) return; this.#options.push({ id: `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, label, color: DEFAULT_COLORS[this.#options.length % DEFAULT_COLORS.length], }); addInput.value = ""; this.#render(); this.dispatchEvent(new CustomEvent("content-change")); }; 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")); }); if (this.#title) titleEl.textContent = this.#title; // Live conviction update this.#tickInterval = setInterval(() => this.#render(), 10000); this.#render(); return root; } disconnectedCallback() { super.disconnectedCallback(); if (this.#tickInterval) { clearInterval(this.#tickInterval); this.#tickInterval = null; } } #render() { this.#renderOptions(); if (this.#drawerOpen) this.#renderDrawer(); } #renderOptions() { if (!this.#optionsEl) return; const now = Date.now(); const convictions = this.#options.map((opt) => ({ id: opt.id, score: convictionScore(this.#stakes, opt.id, now), })); const maxConv = Math.max(1, ...convictions.map((c) => c.score)); const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size; this.#optionsEl.innerHTML = this.#options .map((opt) => { const conv = convictions.find((c) => c.id === opt.id)!; const barWidth = (conv.score / maxConv) * 100; const myStake = this.#getMyStake(opt.id); const myWeight = myStake ? myStake.weight : 0; const timeHeld = myStake ? this.#formatDuration(now - myStake.since) : ""; return `
${this.#escapeHtml(opt.label)}
${myWeight}
${timeHeld} ${this.#formatConviction(conv.score)}
`; }) .join(""); // Wire buttons this.#optionsEl.querySelectorAll(".stake-plus").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); this.#setStake((btn as HTMLElement).dataset.opt!, 1); }); }); this.#optionsEl.querySelectorAll(".stake-minus").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); this.#setStake((btn as HTMLElement).dataset.opt!, -1); }); }); // Weight bar if (this.#weightEl) { const totalWeight = this.#stakes .filter((s) => s.userId === this.#userId) .reduce((sum, s) => sum + s.weight, 0); this.#weightEl.innerHTML = `Your weight: ${totalWeight} across ${this.#stakes.filter((s) => s.userId === this.#userId).length} option${this.#stakes.filter((s) => s.userId === this.#userId).length !== 1 ? "s" : ""}`; } // Voters count if (this.#votersEl) { this.#votersEl.textContent = uniqueParticipants === 0 ? "No stakes yet" : `${uniqueParticipants} participant${uniqueParticipants !== 1 ? "s" : ""}`; } } #renderDrawer() { if (!this.#drawerEl) return; const now = Date.now(); const optMap = new Map(this.#options.map((o) => [o.id, o])); // Group Results: conviction leaderboard const convictions = this.#options.map((opt) => ({ id: opt.id, label: opt.label, color: opt.color, score: convictionScore(this.#stakes, opt.id, now), velocity: convictionVelocity(this.#stakes, opt.id), rawWeight: this.#stakes.filter((s) => s.optionId === opt.id).reduce((sum, s) => sum + s.weight, 0), })); convictions.sort((a, b) => b.score - a.score); const maxConv = Math.max(1, ...convictions.map((c) => c.score)); let resultsHtml = '
Conviction Leaderboard
'; for (const c of convictions) { const pct = (c.score / maxConv) * 100; resultsHtml += `
${this.#escapeHtml(c.label)}
${this.#formatConviction(c.score)}
`; } resultsHtml += "
"; // Statistics const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size; const totalConv = convictions.reduce((sum, c) => sum + c.score, 0); const totalVelocity = convictions.reduce((sum, c) => sum + c.velocity, 0); let statsHtml = '
Statistics
'; statsHtml += `
Participants${uniqueParticipants}
`; statsHtml += `
Total conviction${this.#formatConviction(totalConv)}
`; statsHtml += `
Velocity (wt/hr)${totalVelocity.toFixed(1)}
`; // Raw weight vs conviction rank comparison const byRawWeight = [...convictions].sort((a, b) => b.rawWeight - a.rawWeight); const byConviction = convictions; // already sorted let rankDiff = false; for (let i = 0; i < convictions.length; i++) { if (byRawWeight[i]?.id !== byConviction[i]?.id) { rankDiff = true; break; } } if (rankDiff && convictions.length >= 2) { statsHtml += '
Raw Weight vs Conviction
'; for (const c of convictions) { const rawRank = byRawWeight.findIndex((r) => r.id === c.id) + 1; const convRank = byConviction.findIndex((r) => r.id === c.id) + 1; const diff = rawRank - convRank; const arrow = diff > 0 ? `↑${diff}` : diff < 0 ? `↓${Math.abs(diff)}` : "="; statsHtml += `
${this.#escapeHtml(c.label)}#${convRank} (${arrow})
`; } statsHtml += "
"; } statsHtml += "
"; // Participants const userStakes = new Map(); for (const s of this.#stakes) { const u = userStakes.get(s.userId) || { name: s.userName, totalWeight: 0, totalConv: 0, optCount: 0 }; u.name = s.userName; u.totalWeight += s.weight; u.totalConv += s.weight * Math.max(0, now - s.since) / 3600000; u.optCount++; userStakes.set(s.userId, u); } let participantsHtml = '
Participants
'; for (const [, u] of userStakes) { participantsHtml += `
${this.#escapeHtml(u.name)} wt:${u.totalWeight} conv:${this.#formatConviction(u.totalConv)}
`; } participantsHtml += "
"; this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml; } #formatConviction(score: number): string { if (score < 1) return score.toFixed(2); if (score < 100) return score.toFixed(1); return Math.round(score).toString(); } #formatDuration(ms: number): string { if (ms < 60000) return "<1m"; if (ms < 3600000) return `${Math.floor(ms / 60000)}m`; if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`; return `${Math.floor(ms / 86400000)}d`; } #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-conviction", title: this.#title, options: this.#options, stakes: this.#stakes, }; } }