Skip to content

Commit 225a314

Browse files
authored
Leadboard (#20)
1 parent 3faf840 commit 225a314

11 files changed

Lines changed: 590 additions & 53 deletions

src/app/(app)/polls/[id]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export default async function PollDetailPage({
6868
if (poll.is_closed) {
6969
const results = await getPollResults(id)
7070
return (
71-
<div className="mx-auto max-w-2xl space-y-6">
71+
<div className="mx-auto max-w-5xl space-y-6">
7272
{header}
7373
<p className="text-xs text-gray-400">
7474
Closed on {formatPollDateTime(poll.closed_at ?? poll.closes_at)}
Lines changed: 87 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,77 @@
11
import type { PollResults } from '@/lib/polls-types'
2+
import { rankPollOptions, pollChartSlices } from '@/lib/poll-results'
3+
import { pollOptionColors, POLL_OTHER_CHART_COLOR } from '@/lib/transaction-groups'
4+
import { PollResultsPie } from '@/components/charts/poll-results-pie'
25

36
export function PollResultsView({ results }: { results: PollResults }) {
47
const total = results.total_voters
8+
const { ranked, leadingIds } = rankPollOptions(results.options)
9+
const slices = pollChartSlices(
10+
results.options,
11+
pollOptionColors(results.options.length),
12+
{ count: results.other_responses.length, color: POLL_OTHER_CHART_COLOR },
13+
)
14+
const hasVotes = slices.some((s) => s.value > 0)
15+
// Per-option slice color, so each breakdown row matches its donut slice.
16+
const colorById = new Map(slices.map((s) => [s.option_id, s.color]))
517
return (
6-
<section className="space-y-5">
7-
<div className="rounded-lg border bg-white p-5">
8-
<p className="text-sm font-medium text-gray-700">
9-
{total === 0
10-
? 'No votes recorded.'
11-
: `${total} ${total === 1 ? 'member' : 'members'} voted.`}
12-
</p>
13-
</div>
18+
<section>
19+
<div className="grid gap-5 lg:grid-cols-[300px_minmax(0,1fr)] lg:items-start">
20+
{/* Left: donut + full-text legend, sticky alongside the breakdown */}
21+
<div className="rounded-lg border bg-white p-5 lg:sticky lg:top-4">
22+
<p className="text-sm font-medium text-gray-700">
23+
{total === 0
24+
? 'No votes recorded.'
25+
: `${total} ${total === 1 ? 'member' : 'members'} voted.`}
26+
</p>
27+
{hasVotes ? (
28+
<div className="mt-5">
29+
<PollResultsPie slices={slices} totalVoters={total} />
30+
</div>
31+
) : null}
32+
</div>
1433

15-
<ul className="space-y-3">
16-
{results.options.map((o) => {
34+
{/* Right: ranked breakdown with voter chips, then Other responses —
35+
kept in this column so both align with the option cards. */}
36+
<div className="space-y-3">
37+
<ul className="space-y-3">
38+
{ranked.map((o) => {
1739
const pct = total > 0 ? Math.round((o.vote_count / total) * 100) : 0
40+
const isLeading = leadingIds.has(o.option_id)
41+
const color = colorById.get(o.option_id) ?? ''
1842
return (
19-
<li key={o.option_id} className="rounded-lg border bg-white p-4">
43+
<li
44+
key={o.option_id}
45+
className={
46+
'rounded-lg border p-4 ' +
47+
(isLeading ? 'border-blue-200 bg-blue-50/40' : 'bg-white')
48+
}
49+
>
2050
<div className="flex items-baseline justify-between gap-3">
21-
<p className="text-sm font-medium text-gray-900">{o.option_label}</p>
22-
<p className="text-xs text-gray-500">
51+
<p className="flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900">
52+
<span
53+
className="h-2.5 w-2.5 flex-none rounded-sm"
54+
style={{ backgroundColor: color }}
55+
aria-hidden
56+
/>
57+
<span>{o.option_label}</span>
58+
{isLeading ? (
59+
<span
60+
className="inline-flex items-center rounded-full bg-blue-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white"
61+
title="Leading option"
62+
>
63+
★ Leading
64+
</span>
65+
) : null}
66+
</p>
67+
<p className="whitespace-nowrap text-xs text-gray-500">
2368
{o.vote_count} {o.vote_count === 1 ? 'vote' : 'votes'} · {pct}%
2469
</p>
2570
</div>
2671
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-100">
2772
<div
28-
className="h-full bg-blue-500"
29-
style={{ width: `${pct}%` }}
73+
className="h-full rounded-full"
74+
style={{ width: `${pct}%`, backgroundColor: color }}
3075
aria-hidden
3176
/>
3277
</div>
@@ -35,7 +80,10 @@ export function PollResultsView({ results }: { results: PollResults }) {
3580
{o.voter_names.map((name, i) => (
3681
<li
3782
key={`${name}-${i}`}
38-
className="truncate rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700"
83+
className={
84+
'truncate rounded-md px-2 py-1 text-xs ' +
85+
(isLeading ? 'bg-blue-50 text-blue-800' : 'bg-gray-100 text-gray-700')
86+
}
3987
title={name}
4088
>
4189
{name}
@@ -46,28 +94,30 @@ export function PollResultsView({ results }: { results: PollResults }) {
4694
</li>
4795
)
4896
})}
49-
</ul>
50-
51-
{results.other_responses.length > 0 ? (
52-
<section className="rounded-lg border bg-white p-4">
53-
<p className="text-sm font-medium text-gray-900">
54-
Other responses ({results.other_responses.length})
55-
</p>
56-
<ul className="mt-3 space-y-2">
57-
{results.other_responses.map((r, i) => (
58-
<li
59-
key={i}
60-
className="rounded-md bg-gray-50 px-3 py-2 text-sm text-gray-800"
61-
>
62-
<p>{r.text}</p>
63-
{r.author ? (
64-
<p className="mt-1 text-xs text-gray-500">{r.author}</p>
65-
) : null}
66-
</li>
67-
))}
6897
</ul>
69-
</section>
70-
) : null}
98+
99+
{results.other_responses.length > 0 ? (
100+
<section className="rounded-lg border bg-white p-4">
101+
<p className="text-sm font-medium text-gray-900">
102+
Other responses ({results.other_responses.length})
103+
</p>
104+
<ul className="mt-3 space-y-2">
105+
{results.other_responses.map((r, i) => (
106+
<li
107+
key={i}
108+
className="rounded-md bg-gray-50 px-3 py-2 text-sm text-gray-800"
109+
>
110+
<p>{r.text}</p>
111+
{r.author ? (
112+
<p className="mt-1 text-xs text-gray-500">{r.author}</p>
113+
) : null}
114+
</li>
115+
))}
116+
</ul>
117+
</section>
118+
) : null}
119+
</div>
120+
</div>
71121
</section>
72122
)
73123
}

src/components/action-items-panel.tsx

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,12 @@ export function ActionItemsPanel({
3434
if (!canToggle) return
3535
const target = e.target as HTMLElement
3636
if (target.tagName !== 'INPUT' || (target as HTMLInputElement).type !== 'checkbox') return
37-
const all = (e.currentTarget.querySelectorAll('input[type=checkbox]') as NodeListOf<HTMLInputElement>)
38-
const nth = Array.from(all).indexOf(target as HTMLInputElement)
39-
if (nth < 0 || !source) return
40-
const lines = source.split('\n')
41-
let seen = -1
42-
let lineIndex = -1
43-
for (let i = 0; i < lines.length; i++) {
44-
if (/^\s*[-*]\s+\[( |x|X)\]/.test(lines[i])) {
45-
seen++
46-
if (seen === nth) { lineIndex = i; break }
47-
}
48-
}
49-
if (lineIndex < 0) return
37+
// The renderer stamps each task-list checkbox with its 0-based source line
38+
// (see rehypeTaskLine). Trust that rather than re-deriving the mapping with
39+
// a regex, which desyncs from how remark-gfm actually renders checkboxes.
40+
const lineAttr = target.dataset.line
41+
const lineIndex = Number(lineAttr)
42+
if (lineAttr == null || !Number.isInteger(lineIndex)) return
5043
const checked = (target as HTMLInputElement).checked
5144
startTransition(async () => {
5245
const fd = new FormData()
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use client'
2+
3+
import { Cell, Pie, PieChart } from 'recharts'
4+
import {
5+
ChartContainer,
6+
ChartTooltip,
7+
ChartTooltipContent,
8+
type ChartConfig,
9+
} from '@/components/ui/chart'
10+
import type { PollChartSlice } from '@/lib/poll-results'
11+
12+
/**
13+
* Donut summary of a closed poll's results plus a full-text legend.
14+
*
15+
* Slices are share-of-all-votes (they sum to the whole), so for multi-select
16+
* polls the legend percentages can differ from the per-voter percentages on
17+
* the ranked breakdown bars — that's expected. The legend shows the complete
18+
* option label (wrapping as needed); the donut centre shows the voter count.
19+
*/
20+
export function PollResultsPie({
21+
slices,
22+
totalVoters,
23+
}: {
24+
slices: PollChartSlice[]
25+
totalVoters: number
26+
}) {
27+
const totalVotes = slices.reduce((s, x) => s + x.value, 0)
28+
29+
const config = Object.fromEntries(
30+
slices.map((s) => [s.option_id, { label: s.label, color: s.color }]),
31+
) satisfies ChartConfig
32+
33+
return (
34+
<div className="flex flex-col items-center gap-5">
35+
<div className="relative">
36+
<ChartContainer config={config} className="aspect-square h-44 w-44">
37+
<PieChart>
38+
<Pie
39+
data={slices}
40+
dataKey="value"
41+
nameKey="label"
42+
cx="50%"
43+
cy="50%"
44+
outerRadius={84}
45+
innerRadius={52}
46+
paddingAngle={2}
47+
stroke="white"
48+
strokeWidth={2}
49+
>
50+
{slices.map((s) => (
51+
<Cell key={s.option_id} fill={s.color} />
52+
))}
53+
</Pie>
54+
<ChartTooltip
55+
wrapperStyle={{ zIndex: 50 }}
56+
content={
57+
<ChartTooltipContent
58+
hideLabel
59+
nameKey="label"
60+
valueFormatter={(v) => {
61+
const n = Number(v ?? 0)
62+
const pct = totalVotes > 0 ? (n / totalVotes) * 100 : 0
63+
return `${n} ${n === 1 ? 'vote' : 'votes'} · ${Math.round(pct)}%`
64+
}}
65+
indicator="dot"
66+
/>
67+
}
68+
/>
69+
</PieChart>
70+
</ChartContainer>
71+
{/* Centre label — overlaid because the shadcn chart wrapper has no
72+
native donut-centre slot. Kept at z-0 so the tooltip (z-50) paints
73+
above it instead of hiding behind it. */}
74+
<div className="pointer-events-none absolute inset-0 z-0 flex flex-col items-center justify-center">
75+
<span className="text-2xl font-semibold text-gray-900">{totalVoters}</span>
76+
<span className="text-xs text-gray-500">
77+
{totalVoters === 1 ? 'voter' : 'voters'}
78+
</span>
79+
</div>
80+
</div>
81+
82+
<ul className="w-full space-y-2">
83+
{slices.map((s) => (
84+
<li key={s.option_id} className="flex items-start gap-2 text-sm">
85+
<span
86+
className="mt-1 h-3 w-3 flex-none rounded-sm"
87+
style={{ backgroundColor: s.color }}
88+
aria-hidden
89+
/>
90+
<span className="flex-1 break-words text-gray-700">{s.label}</span>
91+
<span className="flex-none font-semibold text-gray-900">
92+
{Math.round(s.pct)}%
93+
</span>
94+
</li>
95+
))}
96+
</ul>
97+
</div>
98+
)
99+
}

src/components/markdown-view.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Fragment, type ReactNode } from 'react'
33
import Link from 'next/link'
44
import ReactMarkdown from 'react-markdown'
55
import remarkGfm from 'remark-gfm'
6+
import { rehypeTaskLine } from '@/lib/rehype-task-line'
67

78
type Props = {
89
source: string
@@ -80,16 +81,21 @@ export function MarkdownView({ source, className, mentions, interactiveCheckboxe
8081

8182
if (interactiveCheckboxes) {
8283
components.input = ({
84+
node,
8385
type,
8486
checked,
8587
}: {
88+
node?: { properties?: Record<string, unknown> }
8689
type?: string
8790
checked?: boolean
8891
}) => {
8992
if (type === 'checkbox') {
93+
// `data-line` is the 0-based source line, stamped by rehypeTaskLine, so
94+
// the panel can map a click back to the exact action_items_md line.
9095
// Uncontrolled (defaultChecked) so an optimistic click-toggle is not
9196
// reverted before `source` re-renders. No `disabled` → clicks fire.
92-
return <input type="checkbox" defaultChecked={Boolean(checked)} />
97+
const line = node?.properties?.dataLine as number | undefined
98+
return <input type="checkbox" defaultChecked={Boolean(checked)} data-line={line} />
9399
}
94100
return <input type={type} />
95101
}
@@ -108,6 +114,7 @@ export function MarkdownView({ source, className, mentions, interactiveCheckboxe
108114
>
109115
<ReactMarkdown
110116
remarkPlugins={[remarkGfm]}
117+
rehypePlugins={interactiveCheckboxes ? ([rehypeTaskLine] as never) : undefined}
111118
components={hasComponents ? (components as never) : undefined}
112119
>
113120
{source}

0 commit comments

Comments
 (0)