Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

polls.gno

6.99 Kb · 271 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/markdown"
 12	"strconv"
 13	"strings"
 14
 15	"gno.land/p/moul/kit/ui/v0"
 16	"gno.land/p/nt/avl/v0"
 17)
 18
 19// selfPath is this realm's own page. A root-relative link resolves against
 20// the domain, not the realm, so every link back into it names it in full.
 21const selfPath = "/r/moul/x/daily/polls/v1"
 22
 23// Poll is a single question with its options and per-address vote tracking.
 24type Poll struct {
 25	ID       int
 26	Question string
 27	Options  []string
 28	Tallies  []int
 29	Creator  string    // bech32 address of the poll creator
 30	voted    *avl.Tree // address string -> struct{}{}, one vote per address
 31}
 32
 33var (
 34	polls  []*Poll // index i holds the poll with ID i
 35	nextID int
 36)
 37
 38// CreatePoll registers a new poll. options is a comma-separated list; blank
 39// entries are dropped. Returns the new poll's id. Crossing function: an EOA
 40// invokes it via MsgCall; another realm via polls.CreatePoll(cross, q, opts).
 41func CreatePoll(cur realm, question string, options string) int {
 42	creator := caller(cur).String()
 43
 44	q := strings.TrimSpace(question)
 45	if strings.TrimSpace(markdown.StripBidiAndZeroWidth(q)) == "" {
 46		panic("question must not be empty")
 47	}
 48
 49	if len(q) > MaxQuestionLen {
 50		panic("question too long")
 51	}
 52	opts := parseOptions(options)
 53	if len(opts) < 2 {
 54		panic("a poll needs at least 2 options")
 55	}
 56	if len(opts) > MaxOptions {
 57		panic("a poll takes at most " + strconv.Itoa(MaxOptions) + " options")
 58	}
 59	for _, o := range opts {
 60		if len(o) > MaxOptionLen {
 61			panic("option too long")
 62		}
 63	}
 64	if len(polls) >= MaxPolls {
 65		panic("polls is full at " + strconv.Itoa(MaxPolls) + " polls")
 66	}
 67
 68	id := nextID
 69	nextID++
 70	p := &Poll{
 71		ID:       id,
 72		Question: q,
 73		Options:  opts,
 74		Tallies:  make([]int, len(opts)),
 75		Creator:  creator,
 76		voted:    avl.NewTree(),
 77	}
 78	polls = append(polls, p)
 79
 80	chain.Emit("PollCreated",
 81		"id", strconv.Itoa(id),
 82		"creator", creator,
 83		"options", strconv.Itoa(len(opts)),
 84	)
 85	return id
 86}
 87
 88// Vote casts one vote for optionIndex on poll pollID. Each caller address may
 89// vote at most once per poll. Crossing function.
 90func Vote(cur realm, pollID int, optionIndex int) {
 91	voter := caller(cur).String()
 92
 93	p := getPoll(pollID)
 94	if optionIndex < 0 || optionIndex >= len(p.Options) {
 95		panic("option index out of range")
 96	}
 97	if p.voted.Has(voter) {
 98		panic("address already voted on this poll")
 99	}
100
101	p.voted.Set(voter, struct{}{})
102	p.Tallies[optionIndex]++
103
104	chain.Emit("Voted",
105		"id", strconv.Itoa(pollID),
106		"voter", voter,
107		"option", strconv.Itoa(optionIndex),
108	)
109}
110
111// parseOptions splits a comma-separated option string, trimming whitespace and
112// dropping empties. Pure logic — no realm state.
113func parseOptions(options string) []string {
114	var out []string
115	for _, raw := range strings.Split(options, ",") {
116		o := strings.TrimSpace(raw)
117		if strings.TrimSpace(markdown.StripBidiAndZeroWidth(o)) != "" {
118			out = append(out, o)
119		}
120	}
121	return out
122}
123
124// getPoll returns the poll with the given id or panics if it does not exist.
125func getPoll(id int) *Poll {
126	if id < 0 || id >= len(polls) {
127		panic("poll not found")
128	}
129	return polls[id]
130}
131
132// PollCount returns the number of polls created so far (read-only helper).
133func PollCount() int {
134	return len(polls)
135}
136
137const barWidth = 20
138
139// The bounds on what a creator may store. Anyone may create a poll, and the
140// root page renders the newest ones.
141const (
142	MaxPolls       = 500
143	MaxQuestionLen = 200
144	MaxOptions     = 10
145	MaxOptionLen   = 64
146	IndexRecent    = 20 // polls the root page shows, newest first
147)
148
149// bar renders a proportional ▓░ bar of fixed width for count out of total.
150func bar(count, total int) string {
151	filled := 0
152	if total > 0 {
153		filled = count * barWidth / total
154	}
155	if filled > barWidth {
156		filled = barWidth
157	}
158	return strings.Repeat("▓", filled) + strings.Repeat("░", barWidth-filled)
159}
160
161// percent returns the integer percentage of count out of total.
162func percent(count, total int) int {
163	if total == 0 {
164		return 0
165	}
166	return count * 100 / total
167}
168
169// total sums a tally slice.
170func total(tallies []int) int {
171	sum := 0
172	for _, t := range tallies {
173		sum += t
174	}
175	return sum
176}
177
178// renderPoll writes one poll's Markdown block into b.
179func renderPoll(b *strings.Builder, p *Poll) {
180	sum := total(p.Tallies)
181
182	b.WriteString("## Poll #")
183	b.WriteString(strconv.Itoa(p.ID))
184	b.WriteString(": ")
185	b.WriteString(ui.Inline(p.Question))
186	b.WriteString("\n\n")
187
188	for i, opt := range p.Options {
189		c := p.Tallies[i]
190		b.WriteString("- **")
191		b.WriteString(ui.Inline(opt))
192		b.WriteString("** `")
193		b.WriteString(bar(c, sum))
194		b.WriteString("` ")
195		b.WriteString(strconv.Itoa(percent(c, sum)))
196		b.WriteString("% (")
197		b.WriteString(strconv.Itoa(c))
198		if c == 1 {
199			b.WriteString(" vote)")
200		} else {
201			b.WriteString(" votes)")
202		}
203		b.WriteString("\n")
204	}
205
206	b.WriteString("\n_Total votes: ")
207	b.WriteString(strconv.Itoa(sum))
208	b.WriteString("_\n\n")
209}
210
211// Render produces the Markdown page for gnoweb. It is NOT a crossing function.
212//
213//   - ""          -> the IndexRecent newest polls
214//   - "page/<n>"  -> the n-th page of IndexRecent, newest first (page/1 is "")
215//   - "<id>"      -> a single poll by id
216func Render(path string) string {
217	var b strings.Builder
218
219	path = strings.TrimSpace(path)
220	page := 1
221	if rest, ok := strings.CutPrefix(path, "page/"); ok {
222		n, err := strconv.Atoi(rest)
223		// Bounded before any multiply: the page comes from the URL.
224		if err != nil || n < 1 || n > 1+(len(polls)-1)/IndexRecent {
225			return "# Polls\n\nNo such page.\n\n[← all polls](" + selfPath + ")\n"
226		}
227		page, path = n, ""
228	}
229	if path != "" {
230		id, err := strconv.Atoi(path)
231		if err != nil || id < 0 || id >= len(polls) {
232			return "# Polls\n\nPoll not found.\n"
233		}
234		b.WriteString("# Polls\n\n")
235		renderPoll(&b, polls[id])
236		b.WriteString("[← all polls](" + selfPath + ")\n")
237		return b.String()
238	}
239
240	b.WriteString("# Polls & Voting\n\n")
241	if len(polls) == 0 {
242		b.WriteString("_No polls yet. Create one with `CreatePoll(question, \"a,b,c\")`._\n")
243		return b.String()
244	}
245
246	pages := 1 + (len(polls)-1)/IndexRecent
247	b.WriteString("Total polls: ")
248	b.WriteString(strconv.Itoa(len(polls)))
249	if pages > 1 {
250		b.WriteString(", page " + strconv.Itoa(page) + " of " + strconv.Itoa(pages))
251	}
252	b.WriteString("\n\n")
253	//gnovet:ignore page-offset-overflow page is bounded by pages above
254	top := len(polls) - 1 - (page-1)*IndexRecent
255	for i := top; i >= 0 && i > top-IndexRecent; i-- {
256		renderPoll(&b, polls[i])
257	}
258	if page < pages {
259		b.WriteString("[older polls →](" + selfPath + ":page/" + strconv.Itoa(page+1) + ")\n")
260	}
261	return b.String()
262}
263
264// caller is the address that crossed into this realm, after checking that the
265// realm token is this frame's own.
266func caller(cur realm) address {
267	if !cur.IsCurrent() {
268		panic("polls: spoofed realm")
269	}
270	return cur.Previous().Address()
271}