Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}