Files
apps/politics/desktop/PoliticsElections.js
metacryst 0d6c7683ff init
2026-04-28 20:05:00 -05:00

415 lines
20 KiB
JavaScript

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)