polls.gno
5.07 Kb · 214 lines
1// Package polls is an on-chain poll / voting realm for gno.land.
2//
3// Anyone can create a poll with a question and a comma-separated list of
4// options. Each caller address may cast exactly one vote per poll. Render
5// draws every poll with its options, tallies as ▓░ bar charts, percentages
6// and the total vote count.
7package polls
8
9import (
10 "chain"
11 "chain/runtime/unsafe"
12 "strconv"
13 "strings"
14
15 "gno.land/p/nt/avl/v0"
16)
17
18// Poll is a single question with its options and per-address vote tracking.
19type Poll struct {
20 ID int
21 Question string
22 Options []string
23 Tallies []int
24 Creator string // bech32 address of the poll creator
25 voted *avl.Tree // address string -> struct{}{}, one vote per address
26}
27
28var (
29 polls []*Poll // index i holds the poll with ID i
30 nextID int
31)
32
33// CreatePoll registers a new poll. options is a comma-separated list; blank
34// entries are dropped. Returns the new poll's id. Crossing function: an EOA
35// invokes it via MsgCall; another realm via polls.CreatePoll(cross, q, opts).
36func CreatePoll(cur realm, question string, options string) int {
37 creator := unsafe.PreviousRealm().Address().String()
38
39 q := strings.TrimSpace(question)
40 if q == "" {
41 panic("question must not be empty")
42 }
43
44 opts := parseOptions(options)
45 if len(opts) < 2 {
46 panic("a poll needs at least 2 options")
47 }
48
49 id := nextID
50 nextID++
51 p := &Poll{
52 ID: id,
53 Question: q,
54 Options: opts,
55 Tallies: make([]int, len(opts)),
56 Creator: creator,
57 voted: avl.NewTree(),
58 }
59 polls = append(polls, p)
60
61 chain.Emit("PollCreated",
62 "id", strconv.Itoa(id),
63 "creator", creator,
64 "options", strconv.Itoa(len(opts)),
65 )
66 return id
67}
68
69// Vote casts one vote for optionIndex on poll pollID. Each caller address may
70// vote at most once per poll. Crossing function.
71func Vote(cur realm, pollID int, optionIndex int) {
72 voter := unsafe.PreviousRealm().Address().String()
73
74 p := getPoll(pollID)
75 if optionIndex < 0 || optionIndex >= len(p.Options) {
76 panic("option index out of range")
77 }
78 if _, voted := p.voted.Get(voter); voted {
79 panic("address already voted on this poll")
80 }
81
82 p.voted.Set(voter, struct{}{})
83 p.Tallies[optionIndex]++
84
85 chain.Emit("Voted",
86 "id", strconv.Itoa(pollID),
87 "voter", voter,
88 "option", strconv.Itoa(optionIndex),
89 )
90}
91
92// parseOptions splits a comma-separated option string, trimming whitespace and
93// dropping empties. Pure logic — no realm state.
94func parseOptions(options string) []string {
95 var out []string
96 for _, raw := range strings.Split(options, ",") {
97 o := strings.TrimSpace(raw)
98 if o != "" {
99 out = append(out, o)
100 }
101 }
102 return out
103}
104
105// getPoll returns the poll with the given id or panics if it does not exist.
106func getPoll(id int) *Poll {
107 if id < 0 || id >= len(polls) {
108 panic("poll not found")
109 }
110 return polls[id]
111}
112
113// PollCount returns the number of polls created so far (read-only helper).
114func PollCount() int {
115 return len(polls)
116}
117
118const barWidth = 20
119
120// bar renders a proportional ▓░ bar of fixed width for count out of total.
121func bar(count, total int) string {
122 filled := 0
123 if total > 0 {
124 filled = count * barWidth / total
125 }
126 if filled > barWidth {
127 filled = barWidth
128 }
129 return strings.Repeat("▓", filled) + strings.Repeat("░", barWidth-filled)
130}
131
132// percent returns the integer percentage of count out of total.
133func percent(count, total int) int {
134 if total == 0 {
135 return 0
136 }
137 return count * 100 / total
138}
139
140// total sums a tally slice.
141func total(tallies []int) int {
142 sum := 0
143 for _, t := range tallies {
144 sum += t
145 }
146 return sum
147}
148
149// renderPoll writes one poll's Markdown block into b.
150func renderPoll(b *strings.Builder, p *Poll) {
151 sum := total(p.Tallies)
152
153 b.WriteString("## Poll #")
154 b.WriteString(strconv.Itoa(p.ID))
155 b.WriteString(": ")
156 b.WriteString(p.Question)
157 b.WriteString("\n\n")
158
159 for i, opt := range p.Options {
160 c := p.Tallies[i]
161 b.WriteString("- **")
162 b.WriteString(opt)
163 b.WriteString("** `")
164 b.WriteString(bar(c, sum))
165 b.WriteString("` ")
166 b.WriteString(strconv.Itoa(percent(c, sum)))
167 b.WriteString("% (")
168 b.WriteString(strconv.Itoa(c))
169 if c == 1 {
170 b.WriteString(" vote)")
171 } else {
172 b.WriteString(" votes)")
173 }
174 b.WriteString("\n")
175 }
176
177 b.WriteString("\n_Total votes: ")
178 b.WriteString(strconv.Itoa(sum))
179 b.WriteString("_\n\n")
180}
181
182// Render produces the Markdown page for gnoweb. It is NOT a crossing function.
183//
184// - "" -> index of all polls
185// - "<id>" -> a single poll by id
186func Render(path string) string {
187 var b strings.Builder
188
189 path = strings.TrimSpace(path)
190 if path != "" {
191 id, err := strconv.Atoi(path)
192 if err != nil || id < 0 || id >= len(polls) {
193 return "# Polls\n\nPoll not found.\n"
194 }
195 b.WriteString("# Polls\n\n")
196 renderPoll(&b, polls[id])
197 b.WriteString("[← all polls](/r/REPLACE_ADDR/polls)\n")
198 return b.String()
199 }
200
201 b.WriteString("# Polls & Voting\n\n")
202 if len(polls) == 0 {
203 b.WriteString("_No polls yet. Create one with `CreatePoll(question, \"a,b,c\")`._\n")
204 return b.String()
205 }
206
207 b.WriteString("Total polls: ")
208 b.WriteString(strconv.Itoa(len(polls)))
209 b.WriteString("\n\n")
210 for _, p := range polls {
211 renderPoll(&b, p)
212 }
213 return b.String()
214}