class PoliticsElections extends Shadow { constructor(elections, levelFilter, onVote, onThumbsUp) { super() this.elections = elections this.levelFilter = levelFilter this.onVote = onVote this.onThumbsUp = onThumbsUp // which election card is "expanded" for voting this.expandedId = null } get visible() { let list = this.levelFilter === "all" ? this.elections : this.elections.filter(e => e.level === this.levelFilter) return [...list].sort((a, b) => new Date(a.date) - new Date(b.date)) } get upcoming() { return this.visible.filter(e => new Date(e.date) >= new Date()) } get past() { return this.visible.filter(e => new Date(e.date) < new Date()) } render() { VStack(() => { // Header VStack(() => { h2("Elections") .margin(0).fontSize(1.1, em).fontWeight("700").color("var(--headertext)") p(`${this.upcoming.length} upcoming Β· ${this.past.length} recent`) .margin(0).marginTop(0.2, em).fontSize(0.78, em) .color("var(--headertext)").opacity(0.42) }) .paddingHorizontal(1.75, em).paddingVertical(1.25, em) .borderBottom("1px solid var(--divider)").flexShrink(0) VStack(() => { if (this.upcoming.length > 0) { this.sectionLabel("UPCOMING") this.upcoming.forEach(e => this.renderElectionCard(e, false)) } if (this.past.length > 0) { this.sectionLabel("RECENT") this.past.forEach(e => this.renderElectionCard(e, true)) } if (this.visible.length === 0) { VStack(() => { p("πŸ—³") .margin(0).fontSize(2.2, em).opacity(0.18) p("No elections found for this level") .margin(0).marginTop(0.6, em).fontSize(0.88, em) .color("var(--headertext)").opacity(0.32) }) .flex(1).justifyContent("center").alignItems("center").paddingTop(4, em) } }) .overflowY("auto").flex(1) .paddingHorizontal(1.75, em).paddingBottom(2, em).paddingTop(0.5, em) }) .height(100, pct).width(100, pct).overflow("hidden") } renderElectionCard(election, isPast) { const isExpanded = this.expandedId === election.id const hasVoted = !!election.userVoteId const daysAway = Math.ceil((new Date(election.date) - new Date()) / 86400000) VStack(() => { // ── Card top row ────────────────────────────────────────── HStack(() => { // Level badge VStack(() => { p(this.levelIcon(election.level)) .margin(0).fontSize(0.8, em).lineHeight("1") }) .width(2, em).height(2, em).borderRadius(0.38, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .justifyContent("center").alignItems("center").flexShrink(0) VStack(() => { HStack(() => { p(election.title) .margin(0).fontSize(0.95, em).fontWeight("700") .color("var(--headertext)").flex(1).minWidth(0) // Date chip HStack(() => { if (!isPast && daysAway <= 30) { p(`${daysAway}d away`) .margin(0).fontSize(0.68, em).fontWeight("700") .color(daysAway <= 7 ? "#ef4444" : "#f59e0b") } else { p(this.formatDate(election.date)) .margin(0).fontSize(0.72, em) .color("var(--headertext)").opacity(0.4) } }) .paddingHorizontal(0.55, em).paddingVertical(0.18, em) .background( !isPast && daysAway <= 7 ? "rgba(239,68,68,0.08)" : !isPast && daysAway <= 30 ? "rgba(245,158,11,0.08)" : "var(--darkaccent)" ) .border(`1px solid ${ !isPast && daysAway <= 7 ? "rgba(239,68,68,0.2)" : !isPast && daysAway <= 30 ? "rgba(245,158,11,0.2)" : "var(--divider)" }`) .borderRadius(100, px).flexShrink(0) }) .alignItems("center").gap(0.5, em) HStack(() => { p(this.capitalize(election.type)) .margin(0).paddingHorizontal(0.45, em).paddingVertical(0.1, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(100, px).fontSize(0.68, em) .color("var(--headertext)").opacity(0.55) p(this.capitalize(election.level)) .margin(0).paddingHorizontal(0.45, em).paddingVertical(0.1, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(100, px).fontSize(0.68, em) .color("var(--headertext)").opacity(0.55) if (election.description) { p(election.description) .margin(0).fontSize(0.75, em) .color("var(--headertext)").opacity(0.45) } }) .gap(0.35, em).alignItems("center").marginTop(0.38, em) }) .flex(1).minWidth(0).gap(0) }) .gap(0.75, em).alignItems("flex-start") // ── Candidates ──────────────────────────────────────────── if (election.candidates.length > 0) { VStack(() => { // Candidate vs. row if (election.candidates.length >= 2) { this.renderCandidateMatchup(election, isPast) } else { // Single candidate / unopposed this.renderSingleCandidate(election.candidates[0], election, isPast) } }) .marginTop(1, em) } // ── Footer: thumbs up + weigh in ────────────────────────── HStack(() => { // Thumbs up / follow this race HStack(() => { button(election.userThumbsUp ? "πŸ‘" : "πŸ‘") .padding(0).border("none").background("transparent") .cursor("pointer").fontSize(0.95, em) .onClick((done) => {if(!done) return; this.onThumbsUp(election.id)}) p(election.thumbsUp > 0 ? `${election.thumbsUp}` : "Follow") .margin(0).fontSize(0.75, em) .color("var(--headertext)") .opacity(election.userThumbsUp ? 1 : 0.42) .fontWeight(election.userThumbsUp ? "600" : "400") }) .gap(0.3, em).alignItems("center") .paddingHorizontal(0.65, em).paddingVertical(0.32, em) .background(election.userThumbsUp ? "rgba(59,130,246,0.08)" : "transparent") .border(`1px solid ${election.userThumbsUp ? "rgba(59,130,246,0.25)" : "var(--divider)"}`) .borderRadius(100, px).cursor("pointer") .onClick((done) => {if(!done) return; this.onThumbsUp(election.id)}) HStack(() => {}).flex(1) if (!isPast && !hasVoted) { button(isExpanded ? "Close" : "Weigh In β†’") .paddingHorizontal(1, em).paddingVertical(0.38, em) .background(isExpanded ? "transparent" : "var(--quillred)") .border(isExpanded ? "1px solid var(--divider)" : "none") .borderRadius(0.45, em) .color(isExpanded ? "var(--headertext)" : "white") .fontSize(0.82, em).fontWeight("600").cursor("pointer") .opacity(isExpanded ? 0.6 : 1) .onClick((done) => {if(!done) return; this.expandedId = isExpanded ? null : election.id this.rerender() }) } else if (hasVoted) { HStack(() => { p("βœ“ You weighed in") .margin(0).fontSize(0.75, em).fontWeight("600").color("#22c55e") }) .paddingHorizontal(0.75, em).paddingVertical(0.32, em) .background("rgba(34,197,94,0.08)").border("1px solid rgba(34,197,94,0.2)") .borderRadius(100, px) } }) .alignItems("center").marginTop(0.9, em) // ── Expanded vote panel ─────────────────────────────────── if (isExpanded && !hasVoted) { VStack(() => { p("Who do you support?") .margin(0).marginBottom(0.75, em).fontSize(0.8, em).fontWeight("600") .color("var(--headertext)").opacity(0.55) .textAlign("center") HStack(() => { election.candidates.forEach((candidate, i) => { const color = this.partyColor(candidate.party) if (i > 0) { p("vs") .margin(0).paddingHorizontal(0.6, em) .fontSize(0.72, em).fontWeight("700") .color("var(--headertext)").opacity(0.3) .alignSelf("center") } button(() => { VStack(() => { p(candidate.initials || candidate.name[0]) .margin(0).fontSize(0.9, em).fontWeight("800").color("white").lineHeight("1") }) .width(2.2, em).height(2.2, em).borderRadius(50, pct) .background(color).justifyContent("center").alignItems("center") .marginBottom(0.5, em) p(candidate.name) .margin(0).fontSize(0.82, em).fontWeight("700") .color("var(--headertext)").textAlign("center") p(`${candidate.party}${candidate.incumbent ? " Β· Incumbent" : ""}`) .margin(0).marginTop(0.15, em).fontSize(0.68, em) .color(color).textAlign("center") }) .flex(1) .paddingVertical(0.9, em) .paddingHorizontal(0.5, em) .background(`${color}0d`) .border(`2px solid ${color}33`) .borderRadius(0.65, em) .cursor("pointer") .display("flex").flexDirection("column").alignItems("center") .onClick((done) => {if(!done) return; this.expandedId = null this.onVote(election.id, candidate.id) }) }) }) .gap(0.5, em).width(100, pct) }) .marginTop(0.85, em) .padding(1, em) .background("var(--darkaccent)") .border("1px solid var(--divider)") .borderRadius(0.65, em) .alignItems("center") } }) .padding(1.2, em) .border("1px solid var(--divider)") .borderRadius(0.75, em) .marginBottom(0.85, em) .boxSizing("border-box") .opacity(isPast ? 0.72 : 1) } renderCandidateMatchup(election, isPast) { const totalVotes = election.candidates.reduce((s, c) => s + (c.votes || 0), 0) HStack(() => { election.candidates.slice(0, 4).forEach((candidate, i) => { const pct = totalVotes > 0 ? Math.round((candidate.votes / totalVotes) * 100) : null const color = this.partyColor(candidate.party) const isUserVote = election.userVoteId === candidate.id if (i > 0) { VStack(() => { p("vs").margin(0).fontSize(0.68, em).fontWeight("700") .color("var(--headertext)").opacity(0.28) }) .justifyContent("center").paddingHorizontal(0.5, em).flexShrink(0) } VStack(() => { // Party color bar top VStack(() => {}) .height(3, px).width(100, pct).background(color).flexShrink(0) VStack(() => { // Name + incumbent HStack(() => { VStack(() => { p(candidate.initials || candidate.name.split(" ").map(w => w[0]).join("")) .margin(0).fontSize(0.72, em).fontWeight("800").color("white").lineHeight("1") }) .width(1.9, em).height(1.9, em).borderRadius(50, pct) .background(color).justifyContent("center").alignItems("center").flexShrink(0) VStack(() => { p(candidate.name) .margin(0).fontSize(0.82, em).fontWeight("700").color("var(--headertext)") .overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis") HStack(() => { p(candidate.party) .margin(0).paddingHorizontal(0.35, em).paddingVertical(0.08, em) .background(`${color}18`).border(`1px solid ${color}33`) .borderRadius(100, px).fontSize(0.62, em).fontWeight("700").color(color) if (candidate.incumbent) { p("Incumbent") .margin(0).paddingHorizontal(0.35, em).paddingVertical(0.08, em) .background("var(--darkaccent)").border("1px solid var(--divider)") .borderRadius(100, px).fontSize(0.62, em).color("var(--headertext)").opacity(0.45) } }) .gap(0.25, em).alignItems("center").marginTop(0.18, em) }) .flex(1).minWidth(0) }) .gap(0.55, em).alignItems("center") // Support bar if (pct !== null && totalVotes > 0) { VStack(() => { VStack(() => { HStack(() => { VStack(() => {}) .height(100, pct).width(pct, pct) .background(color).borderRadius(100, px) .transition("width 0.5s ease") }) .height(100, pct).width(100, pct).alignItems("center") }) .width(100, pct).height(6, px).background(`${color}22`).borderRadius(100, px).overflow("hidden") HStack(() => { p(`${pct}% community support`) .margin(0).fontSize(0.68, em).color("var(--headertext)").opacity(0.42) if (isUserVote) { p("Β· Your pick βœ“") .margin(0).fontSize(0.68, em).color(color).fontWeight("600") } }) .gap(0.3, em).alignItems("center").marginTop(0.35, em) }) .marginTop(0.65, em).width(100, pct) } }) .padding(0.75, em).flex(1) }) .flex(1).minWidth(0) .background("var(--darkaccent)") .border(`1px solid ${isUserVote ? color + "55" : "var(--divider)"}`) .borderRadius(0.55, em).overflow("hidden") .boxSizing("border-box") }) }) .gap(0).alignItems("stretch").width(100, pct) } renderSingleCandidate(candidate, election, isPast) { const color = this.partyColor(candidate.party) HStack(() => { VStack(() => { p(candidate.initials || candidate.name[0]) .margin(0).fontSize(0.85, em).fontWeight("800").color("white").lineHeight("1") }) .width(2.4, em).height(2.4, em).borderRadius(50, pct) .background(color).justifyContent("center").alignItems("center").flexShrink(0) VStack(() => { p(candidate.name) .margin(0).fontSize(0.9, em).fontWeight("700").color("var(--headertext)") HStack(() => { p(candidate.party).margin(0).fontSize(0.7, em).fontWeight("600").color(color) if (candidate.incumbent) { p("Β· Incumbent").margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.42) } p("Β· Unopposed").margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.35) }) .gap(0.35, em).alignItems("center").marginTop(0.2, em) }) .flex(1) }) .gap(0.75, em).alignItems("center") .padding(0.85, em) .background("var(--darkaccent)").border("1px solid var(--divider)").borderRadius(0.55, em) } sectionLabel(text) { p(text) .margin(0).marginTop(0.85, em).marginBottom(0.65, em) .fontSize(0.62, em).fontWeight("700").letterSpacing("0.07em") .color("var(--headertext)").opacity(0.35) } levelIcon(level) { return { federal: "πŸ‡ΊπŸ‡Έ", state: "🏠", local: "πŸ“" }[level] || "πŸ—³" } formatDate(raw) { return new Date(raw).toLocaleDateString([], { month: "long", day: "numeric", year: "numeric" }) } capitalize(s) { return s ? s[0].toUpperCase() + s.slice(1) : s } partyColor(party) { return { R: "#ef4444", D: "#3b82f6", I: "#8b5cf6", L: "#f59e0b", G: "#22c55e" }[party] || "#6b7280" } } register(PoliticsElections)