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

5.05 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 p.voted.Has(voter) {
 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}