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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

render.gno

22.22 Kb Β· 669 lines
  1package gnosplit
  2
  3import (
  4	"encoding/base64"
  5	"html"
  6	"strconv"
  7	"strings"
  8	"time"
  9
 10	"gno.land/p/nym-alexiscolin000/gnosplit/ledger/v0"
 11	"gno.land/p/nt/markdown/sanitize/v0"
 12)
 13
 14// categories are an expense's kinds, in the order ByCat counts them. The last
 15// one is the default. A form sends the name.
 16var categories = []struct{ Name, Icon, Color string }{
 17	{"Food", "🍽", "#4e79a7"},
 18	{"Groceries", "πŸ›’", "#f28e2b"},
 19	{"Transport", "πŸš—", "#59a14f"},
 20	{"Lodging", "🏠", "#e15759"},
 21	{"Activities", "🎟", "#76b7b2"},
 22	{"Shopping", "πŸ›", "#edc948"},
 23	{"Bills", "🧾", "#b07aa1"},
 24	{"Other", "πŸ“¦", "#9c755f"},
 25}
 26
 27func categoryOf(name string) int {
 28	name = strings.ToLower(strings.TrimSpace(name))
 29	if name == "" {
 30		return len(categories) - 1
 31	}
 32	for i, c := range categories {
 33		if strings.ToLower(c.Name) == name {
 34			return i
 35		}
 36	}
 37	panic("gnosplit: no category " + name)
 38}
 39
 40const (
 41	pageSize = 25 // entries per history page
 42	latest   = 8  // entries on a group's page
 43)
 44
 45// Render serves "" (home), "u/<address>" (a wallet's groups) and a group's
 46// pages: "<id>", then "/add", "/settle", "/settle/<from>/<to>/<cents>",
 47// "/members", "/stats", "/history[/<page>]", "/e/<entry>" and "/csv".
 48func Render(path string) string {
 49	if path == "" {
 50		return renderHome()
 51	}
 52	if strings.HasPrefix(path, "u/") {
 53		return renderWallet(address(strings.TrimPrefix(path, "u/")))
 54	}
 55	parts := strings.Split(path, "/")
 56	g := getGroup(parts[0])
 57	if g == nil {
 58		return "# Not found\n\nNo group " + sanitize.InlineText(parts[0]) + ". [Back home](" + hub + ")\n"
 59	}
 60	page := ""
 61	if len(parts) > 1 {
 62		page = parts[1]
 63	}
 64	switch {
 65	case page == "" && len(parts) == 1:
 66		return renderGroup(g)
 67	case page == "add" && len(parts) == 2:
 68		return renderAdd(g)
 69	case page == "settle" && len(parts) == 2:
 70		return renderSettle(g, -1, -1, 0)
 71	case page == "settle" && len(parts) == 5:
 72		f, t, c := atoi(parts[2]), atoi(parts[3]), atoi(parts[4])
 73		if f >= 0 && t >= 0 && f < len(g.Members) && t < len(g.Members) && c > 0 {
 74			return renderSettle(g, f, t, int64(c))
 75		}
 76	case page == "members" && len(parts) == 2:
 77		return renderMembers(g)
 78	case page == "stats" && len(parts) == 2:
 79		return renderStats(g)
 80	case page == "history" && len(parts) <= 3:
 81		n := 1
 82		if len(parts) == 3 {
 83			n = atoi(parts[2])
 84		}
 85		if n >= 1 {
 86			return renderHistory(g, n)
 87		}
 88	case page == "e" && len(parts) == 3:
 89		if v := g.Entries.Get(key(atoi(parts[2]))); v != nil {
 90			return renderEntry(g, v.(*Entry))
 91		}
 92	case page == "csv" && len(parts) == 2:
 93		return header(g, "Export") + "Every member and entry, as CSV.\n\n" + sanitize.CodeBlock(Export(parts[0]))
 94	}
 95	return "# Not found\n\n[Back to the group](" + link(g, "") + ")\n"
 96}
 97
 98func renderHome() string {
 99	var b strings.Builder
100	b.WriteString("# gnosplit\n\n")
101	b.WriteString("**Share expenses with friends, then settle up in as few transfers as possible.** ")
102	b.WriteString("Trips, flatshares, dinners: like Tricount, on gno.land, with no account and no ads.\n\n")
103	b.WriteString("<gno-columns>\n### 1. Create a group\nName it, pick a currency, list your friends. They don't need a wallet.\n")
104	b.WriteString("<gno-columns-sep />\n### 2. Add expenses\nWho paid, for whom: split equally, by shares or by exact amounts. Foreign currencies too.\n")
105	b.WriteString("<gno-columns-sep />\n### 3. Settle up\ngnosplit works out who pays whom, in the fewest transfers. Friends with a wallet get notified.\n")
106	b.WriteString("</gno-columns>\n\n")
107	b.WriteString("## Create a group\n\n")
108	b.WriteString("<gno-form exec=\"CreateGroup\">\n")
109	b.WriteString(input("name", "Group name, like Ski trip", "", true))
110	b.WriteString(input("currency", "Currency, like € or CHF", "€", true))
111	b.WriteString(input("you", "Your name in the group", "", true))
112	b.WriteString(input("others", "Your friends, comma-separated: Alice, Bob", "", false))
113	b.WriteString("</gno-form>\n\n")
114	b.WriteString("> [!NOTE] Everything is public\n> A group lives on gno.land: anyone with its link can read it. ")
115	b.WriteString("Use first names, and keep private details out of titles.\n\n")
116	b.WriteString("## Your groups\n\nOpen `" + hub + ":u/<your address>` to list the groups your wallet is in. ")
117	b.WriteString(plural(groups.Size(), "group") + " so far.\n")
118	return b.String()
119}
120
121func renderWallet(a address) string {
122	if !a.IsValid() {
123		return "# Not found\n\nNot an address. [Back home](" + hub + ")\n"
124	}
125	var b strings.Builder
126	b.WriteString("# Your groups\n\n" + a.String() + "\n\n")
127	n := 0
128	byAddr.Iterate(a.String()+"/", a.String()+"0", func(k string, _ any) bool {
129		g := groups.Get(k[strings.LastIndex(k, "/")+1:]).(*Group)
130		m := g.Members[g.editor(a)]
131		if n == 0 {
132			b.WriteString("| Group | You | Your balance |\n|---|---|---:|\n")
133		}
134		b.WriteString("| [" + cellText(g.Name) + "](" + link(g, "") + ") | " + sanitize.TableCell(m.Name) +
135			" | " + ledger.Signed(m.Bal) + " " + g.Currency + " |\n")
136		n++
137		return false
138	})
139	if n == 0 {
140		b.WriteString("This wallet is in no group yet. [Create one](" + hub + ")\n")
141	}
142	return b.String()
143}
144
145func renderGroup(g *Group) string {
146	var b strings.Builder
147	b.WriteString(header(g, ""))
148	b.WriteString("<gno-columns>\n## Balances\n\n| Member | | Balance |\n|---|---|---:|\n")
149	var max int64
150	for _, m := range g.Members {
151		if abs(m.Bal) > max {
152			max = abs(m.Bal)
153		}
154	}
155	for _, m := range g.Members {
156		b.WriteString("| " + sanitize.TableCell(m.Name) + " | " + balanceBar(m.Bal, max) + " | **" + g.money(m.Bal) + "** |\n")
157	}
158	b.WriteString("\n<gno-columns-sep />\n## Settle up\n\n")
159	b.WriteString(transfers(g, true))
160	b.WriteString("</gno-columns>\n\n## Latest\n\n")
161	if g.Entries.Size() == 0 {
162		b.WriteString("No expense yet. [Add the first one](" + link(g, "/add") + ")\n")
163		return b.String()
164	}
165	b.WriteString(entryTable(g, 0, latest))
166	if g.Entries.Size() > latest {
167		b.WriteString("\n[All " + plural(g.Entries.Size(), "entry") + " β†’](" + link(g, "/history") + ")\n")
168	}
169	return b.String()
170}
171
172// transfers lists the payments that settle the group, each with a link that
173// fills in the form to record it.
174func transfers(g *Group, short bool) string {
175	bal := make([]int64, len(g.Members))
176	for i, m := range g.Members {
177		bal[i] = m.Bal
178	}
179	ts := ledger.Settle(bal)
180	if len(ts) == 0 {
181		return "> [!SUCCESS] All settled up\n> Nobody owes anything.\n\n"
182	}
183	var b strings.Builder
184	b.WriteString("| Who | Pays | Amount | |\n|---|---|---:|---|\n")
185	for _, t := range ts {
186		b.WriteString("| " + sanitize.TableCell(g.Members[t.From].Name) + " | " + sanitize.TableCell(g.Members[t.To].Name) +
187			" | " + g.money(t.Amount) + " | [Record](" + link(g, "/settle/"+itoa(t.From)+"/"+itoa(t.To)+"/"+strconv.FormatInt(t.Amount, 10)) + ") |\n")
188	}
189	if short {
190		b.WriteString("\n" + plural(len(ts), "transfer") + " settle everything. [Settle up β†’](" + link(g, "/settle") + ")\n")
191	}
192	return b.String()
193}
194
195func renderAdd(g *Group) string {
196	var b strings.Builder
197	b.WriteString(header(g, "Add"))
198	if g.Moved {
199		return b.String()
200	}
201	b.WriteString("Amounts are like `42.50`. Paid in another currency? Write `50 USD x0.92`: 50 dollars at 0.92 " +
202		g.Currency + " each.\n\n")
203
204	b.WriteString("## Split equally\n\n<gno-form exec=\"AddExpense\">\n")
205	b.WriteString(groupField(g) + input("title", "What, like Groceries", "", true) + input("amount", "Amount", "", true))
206	b.WriteString(memberSelect(g, "paidBy", "Paid by", -1))
207	b.WriteString(memberChecks(g, "forWhom", "For whom"))
208	b.WriteString(categorySelect())
209	b.WriteString("</gno-form>\n\n")
210
211	var shares, amounts []string
212	for _, m := range g.Members {
213		shares = append(shares, m.Name+":1")
214		amounts = append(amounts, m.Name+":")
215	}
216	b.WriteString("## Split by shares\n\nGive each a number of shares: `Alice:2, Bob:1` makes Alice pay two thirds. Leave out who is not in.\n\n")
217	b.WriteString("<gno-form exec=\"AddByShares\">\n")
218	b.WriteString(groupField(g) + input("title", "What", "", true) + input("amount", "Amount", "", true))
219	b.WriteString(memberSelect(g, "paidBy", "Paid by", -1))
220	b.WriteString(input("shares", "Shares", strings.Join(shares, ", "), true))
221	b.WriteString(categorySelect())
222	b.WriteString("</gno-form>\n\n")
223
224	b.WriteString("## Split by amounts\n\nWrite each one's exact part: `Alice:12.50, Bob:7.50`. The expense is their sum.\n\n")
225	b.WriteString("<gno-form exec=\"AddByAmounts\">\n")
226	b.WriteString(groupField(g) + input("title", "What", "", true))
227	b.WriteString(memberSelect(g, "paidBy", "Paid by", -1))
228	b.WriteString(input("amounts", "Amounts, like "+strings.Join(amounts, "12.50, ")+"12.50", "", true))
229	b.WriteString(categorySelect())
230	b.WriteString("</gno-form>\n\n")
231
232	b.WriteString("## Money received\n\nA refund, a deposit returned: who received it, and for whom.\n\n")
233	b.WriteString("<gno-form exec=\"AddIncome\">\n")
234	b.WriteString(groupField(g) + input("title", "What, like Deposit back", "", true) + input("amount", "Amount", "", true))
235	b.WriteString(memberSelect(g, "receivedBy", "Received by", -1))
236	b.WriteString(memberChecks(g, "forWhom", "For whom"))
237	b.WriteString("</gno-form>\n")
238	return b.String()
239}
240
241func renderSettle(g *Group, from, to int, amount int64) string {
242	var b strings.Builder
243	b.WriteString(header(g, "Settle up"))
244	if from < 0 {
245		b.WriteString(transfers(g, false) + "\n")
246	}
247	if g.Moved {
248		return b.String()
249	}
250	b.WriteString("## Record a payment\n\nA payment made outside gnosplit, by bank transfer or in cash.\n\n")
251	b.WriteString("<gno-form exec=\"AddTransfer\">\n" + groupField(g))
252	if to < 0 && len(g.Members) > 1 {
253		to = 1 // a blank form starts on two different members
254	}
255	b.WriteString(memberSelect(g, "from", "From", from))
256	b.WriteString(memberSelect(g, "to", "To", to))
257	amt := ""
258	if amount > 0 {
259		amt = cents(amount)
260	}
261	b.WriteString(input("amount", "Amount", amt, true))
262	b.WriteString("</gno-form>\n\n")
263	if from >= 0 {
264		return b.String()
265	}
266	var debtors []int
267	for i, m := range g.Members {
268		if m.Bal < 0 && m.Addr != "" {
269			debtors = append(debtors, i)
270		}
271	}
272	if len(debtors) > 0 {
273		b.WriteString("## Send a reminder\n\nA notification to a member who owes, at most one every 12 hours.\n\n")
274		b.WriteString("<gno-form exec=\"Remind\">\n" + groupField(g))
275		for k, i := range debtors {
276			b.WriteString(selectOpt("member", g.Members[i].Name, first(k, "Who"), k == 0))
277		}
278		b.WriteString("</gno-form>\n")
279	}
280	return b.String()
281}
282
283func renderMembers(g *Group) string {
284	var b strings.Builder
285	b.WriteString(header(g, "Members"))
286	b.WriteString("| Member | Wallet | Paid | Share | Balance |\n|---|---|---:|---:|---:|\n")
287	var free []int
288	for i, m := range g.Members {
289		w := "β€”"
290		if m.Addr != "" {
291			w = "[" + short(m.Addr) + "](" + hub + ":u/" + m.Addr.String() + ")"
292		} else {
293			free = append(free, i)
294		}
295		b.WriteString("| " + sanitize.TableCell(m.Name) + " | " + w + " | " + g.money(m.Paid) + " | " +
296			g.money(m.Share) + " | **" + g.money(m.Bal) + "** |\n")
297	}
298	b.WriteString("\nMembers with a wallet can add and change entries, and get notified. ")
299	b.WriteString("To turn notifications on, open [gnotif](https://gnotif.xyz) with your address.\n\n")
300	if g.Moved {
301		return b.String()
302	}
303	b.WriteString("## Add a member\n\n<gno-form exec=\"AddMember\">\n" + groupField(g))
304	b.WriteString(input("name", "Name", "", true) + input("addr", "Their g1 address, if they have a wallet", "", false))
305	b.WriteString("</gno-form>\n\n")
306	if len(free) > 0 && len(g.Members) > 0 {
307		b.WriteString("## Link a wallet\n\nA friend got a wallet: link it to their name. A linked wallet is never replaced.\n\n")
308		b.WriteString("<gno-form exec=\"LinkMember\">\n" + groupField(g))
309		for k, i := range free {
310			b.WriteString(selectOpt("name", g.Members[i].Name, first(k, "Member"), k == 0))
311		}
312		b.WriteString(input("addr", "Their g1 address", "", true))
313		b.WriteString("</gno-form>\n")
314	}
315	return b.String()
316}
317
318func renderStats(g *Group) string {
319	var b strings.Builder
320	b.WriteString(header(g, "Stats"))
321	var total int64
322	for _, c := range g.ByCat {
323		total += c
324	}
325	if total <= 0 {
326		b.WriteString("No expense yet.\n")
327		return b.String()
328	}
329	b.WriteString("## By category\n\n" + categoryBar(g.ByCat, total) + "\n\n")
330	b.WriteString("| | Category | Spent | Share |\n|---|---|---:|---:|\n")
331	for i, c := range categories {
332		if g.ByCat[i] == 0 {
333			continue
334		}
335		b.WriteString("| " + swatch(c.Color) + " | " + c.Icon + " " + c.Name + " | " + g.money(g.ByCat[i]) +
336			" | " + percent(g.ByCat[i], total) + " |\n")
337	}
338	b.WriteString("\n## By member\n\nWhat each one paid, and what their part of the expenses came to.\n\n")
339	b.WriteString("| Member | Paid | Share | Of all paid |\n|---|---:|---:|---:|\n")
340	for _, m := range g.Members {
341		b.WriteString("| " + sanitize.TableCell(m.Name) + " | " + g.money(m.Paid) + " | " + g.money(m.Share) +
342			" | " + percent(m.Paid, g.Spent) + " |\n")
343	}
344	return b.String()
345}
346
347func renderHistory(g *Group, page int) string {
348	var b strings.Builder
349	b.WriteString(header(g, "History"))
350	n := g.Entries.Size()
351	pages := (n + pageSize - 1) / pageSize
352	if n == 0 || page > pages {
353		b.WriteString("Nothing here.\n")
354		return b.String()
355	}
356	b.WriteString(entryTable(g, (page-1)*pageSize, pageSize) + "\n")
357	if page > 1 {
358		b.WriteString("[← Newer](" + link(g, "/history/"+itoa(page-1)) + ") ")
359	}
360	if page < pages {
361		b.WriteString("[Older β†’](" + link(g, "/history/"+itoa(page+1)) + ")")
362	}
363	b.WriteString("\n\nPage " + itoa(page) + " of " + itoa(pages) + " Β· [Export as CSV](" + link(g, "/csv") + ")\n")
364	return b.String()
365}
366
367func renderEntry(g *Group, e *Entry) string {
368	var b strings.Builder
369	b.WriteString(header(g, "Entry"))
370	b.WriteString("## " + entryIcon(e) + " " + sanitize.InlineText(e.Title) + "\n\n")
371	total := abs(e.Total)
372	b.WriteString("**" + g.money(total) + "**")
373	if e.Note != "" {
374		b.WriteString(" (" + e.Note + ")")
375	}
376	b.WriteString(" Β· " + verb(e) + " **" + sanitize.InlineText(g.Members[e.Payer].Name) + "** Β· " + date(e.At, "Mon Jan 2, 2006"))
377	b.WriteString("\n\nRecorded by " + e.By.String() + "\n\n")
378	if e.Kind != KindTransfer {
379		b.WriteString("| Member | Part |\n|---|---:|\n")
380		for i, m := range e.Who {
381			b.WriteString("| " + sanitize.TableCell(g.Members[m].Name) + " | " + g.money(abs(e.Owe[i])) + " |\n")
382		}
383		b.WriteString("\n")
384	}
385	if g.Moved {
386		return b.String()
387	}
388	b.WriteString("## Delete\n\nRemoves the entry and undoes it on every balance.\n\n")
389	b.WriteString("<gno-form exec=\"Delete\">\n" + groupField(g) + readonly("entry", "Entry", itoa(e.ID)) + "</gno-form>\n")
390	return b.String()
391}
392
393// entryTable writes count entries, newest first, from offset.
394func entryTable(g *Group, offset, count int) string {
395	var b strings.Builder
396	b.WriteString("| Date | What | Paid by | For | Amount |\n|---|---|---|---|---:|\n")
397	g.Entries.ReverseIterateByOffset(offset, count, func(_ string, v any) bool {
398		e := v.(*Entry)
399		title := "[" + cellText(e.Title) + "](" + link(g, "/e/"+itoa(e.ID)) + ")"
400		amount := g.money(abs(e.Total))
401		if e.Kind == KindIncome {
402			amount = "βˆ’" + amount
403		}
404		b.WriteString("| " + date(e.At, "Jan 2") + " | " + entryIcon(e) + " " + title + " | " +
405			sanitize.TableCell(g.Members[e.Payer].Name) + " | " + forWhom(g, e) + " | " + amount + " |\n")
406		return false
407	})
408	return b.String()
409}
410
411func header(g *Group, page string) string {
412	var b strings.Builder
413	if page == "" {
414		b.WriteString("# " + sanitize.InlineText(g.Name) + "\n\n")
415	} else {
416		b.WriteString("# [" + sanitize.InlineText(g.Name) + "](" + link(g, "") + ") Β· " + page + "\n\n")
417	}
418	b.WriteString(plural(len(g.Members), "member") + " Β· **" + g.money(g.Spent) + "** spent Β· ")
419	b.WriteString("[Add](" + link(g, "/add") + ") Β· [Settle up](" + link(g, "/settle") + ") Β· [Members](" +
420		link(g, "/members") + ") Β· [Stats](" + link(g, "/stats") + ") Β· [History](" + link(g, "/history") + ")\n\n")
421	if g.Moved {
422		b.WriteString("> [!WARNING] This group moved\n> It is kept read-only here. Its new home is [" + successor + "](" +
423			strings.TrimPrefix(successor, "gno.land") + ":" + itoa(g.ID) + ").\n\n")
424	}
425	return b.String()
426}
427
428// Export writes a group as CSV: its members, then every entry, oldest first.
429// MoveOut hands it to the successor, and the /csv page shows it.
430func Export(group string) string {
431	g := mustGroup(group)
432	var b strings.Builder
433	b.WriteString("member,address,balance,paid,share\n")
434	for _, m := range g.Members {
435		b.WriteString(csv(m.Name) + "," + m.Addr.String() + "," + cents(m.Bal) + "," + cents(m.Paid) + "," + cents(m.Share) + "\n")
436	}
437	b.WriteString("\nentry,kind,time,title,category,payer,total,note,split\n")
438	g.Entries.Iterate("", "", func(_ string, v any) bool {
439		e := v.(*Entry)
440		split := make([]string, len(e.Who))
441		for i, m := range e.Who {
442			split[i] = g.Members[m].Name + ":" + cents(e.Owe[i])
443		}
444		b.WriteString(itoa(e.ID) + "," + [...]string{"expense", "income", "transfer"}[e.Kind] + "," +
445			date(e.At, "2006-01-02T15:04:05Z") + "," + csv(e.Title) + "," + categories[e.Cat].Name + "," +
446			csv(g.Members[e.Payer].Name) + "," + cents(e.Total) + "," + csv(e.Note) + "," + csv(strings.Join(split, ";")) + "\n")
447		return false
448	})
449	return b.String()
450}
451
452// Form fields. Every value is escaped for the attribute it lands in; a form's
453// fields are in the order of its function's parameters, since gnoweb passes
454// them by position.
455
456func input(name, placeholder, value string, required bool) string {
457	s := `<gno-input name="` + name + `" placeholder="` + attr(placeholder) + `"`
458	if value != "" {
459		s += ` value="` + attr(value) + `"`
460	}
461	if required {
462		s += ` required="true"`
463	}
464	return s + " />\n"
465}
466
467func readonly(name, placeholder, value string) string {
468	return `<gno-input name="` + name + `" placeholder="` + placeholder + `" value="` + attr(value) + `" readonly="true" />` + "\n"
469}
470
471func groupField(g *Group) string { return readonly("group", "Group", itoa(g.ID)) }
472
473// selectOpt is one option of a select; gnoweb reads the description off
474// the first one, so only that one carries it.
475func selectOpt(name, value, description string, selected bool) string {
476	s := `<gno-select name="` + name + `" value="` + attr(value) + `"`
477	if description != "" {
478		s += ` description="` + attr(description) + `"`
479	}
480	if selected {
481		s += ` selected="true"`
482	}
483	return s + " />\n"
484}
485
486func memberSelect(g *Group, name, description string, selected int) string {
487	var b strings.Builder
488	if selected < 0 {
489		selected = 0
490	}
491	for i, m := range g.Members {
492		b.WriteString(selectOpt(name, m.Name, first(i, description), i == selected))
493	}
494	return b.String()
495}
496
497// first is s for the first item of a list, "" after.
498func first(i int, s string) string {
499	if i == 0 {
500		return s
501	}
502	return ""
503}
504
505func memberChecks(g *Group, name, description string) string {
506	var b strings.Builder
507	for i, m := range g.Members {
508		s := `<gno-input type="checkbox" name="` + name + `" value="` + attr(m.Name) + `" checked="true"`
509		if i == 0 {
510			s += ` description="` + description + `"`
511		}
512		b.WriteString(s + " />\n")
513	}
514	return b.String()
515}
516
517func categorySelect() string {
518	var b strings.Builder
519	for i, c := range categories {
520		b.WriteString(selectOpt("category", c.Name, first(i, "Category"), i == len(categories)-1))
521	}
522	return b.String()
523}
524
525// Pictures: small SVGs as data URIs, the one image source gnoweb always shows.
526
527// balanceBar draws a balance against the largest one: right and teal when the
528// group owes the member, left and orange when they owe.
529func balanceBar(v, max int64) string {
530	w := 0
531	if max > 0 {
532		w = int(abs(v) * 56 / max)
533		if w == 0 && v != 0 {
534			w = 1
535		}
536	}
537	x, color := 60, "#1a9e8f"
538	if v < 0 {
539		x, color = 60-w, "#e07b39"
540	}
541	return svg(120, 14, `<rect x="59.5" y="0" width="1" height="14" fill="#8b8f98"/><rect x="`+itoa(x)+
542		`" y="2" width="`+itoa(w)+`" height="10" rx="2" fill="`+color+`"/>`)
543}
544
545// categoryBar is one bar split by category, in the table's order.
546func categoryBar(byCat []int64, total int64) string {
547	const w = 600
548	var s strings.Builder
549	x := 0
550	for i, c := range byCat {
551		if c <= 0 {
552			continue
553		}
554		cw := int(c * w / total)
555		if x+cw > w {
556			cw = w - x
557		}
558		s.WriteString(`<rect x="` + itoa(x) + `" y="0" width="` + itoa(cw) + `" height="24" fill="` + categories[i].Color + `"/>`)
559		x += cw
560	}
561	return svg(w, 24, s.String())
562}
563
564func swatch(color string) string {
565	return svg(12, 12, `<rect width="12" height="12" rx="3" fill="`+color+`"/>`)
566}
567
568func svg(w, h int, body string) string {
569	doc := `<svg xmlns="http://www.w3.org/2000/svg" width="` + itoa(w) + `" height="` + itoa(h) +
570		`" viewBox="0 0 ` + itoa(w) + ` ` + itoa(h) + `">` + body + `</svg>`
571	return "![](data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(doc)) + ")"
572}
573
574// Small helpers.
575
576func link(g *Group, page string) string { return hub + ":" + itoa(g.ID) + page }
577
578func entryIcon(e *Entry) string {
579	switch e.Kind {
580	case KindIncome:
581		return "↩"
582	case KindTransfer:
583		return "⇄"
584	}
585	return categories[e.Cat].Icon
586}
587
588func verb(e *Entry) string {
589	if e.Kind == KindIncome {
590		return "received by"
591	}
592	return "paid by"
593}
594
595func forWhom(g *Group, e *Entry) string {
596	if e.Kind == KindTransfer {
597		return sanitize.TableCell(g.Members[e.Who[0]].Name)
598	}
599	if len(e.Who) == len(g.Members) {
600		return "everyone"
601	}
602	var names []string
603	for i, m := range e.Who {
604		if i == 3 {
605			return sanitize.TableCell(strings.Join(names, ", ")) + " +" + itoa(len(e.Who)-3)
606		}
607		names = append(names, g.Members[m].Name)
608	}
609	return sanitize.TableCell(strings.Join(names, ", "))
610}
611
612func date(unix int64, layout string) string { return time.Unix(unix, 0).UTC().Format(layout) }
613
614func short(a address) string {
615	s := a.String()
616	if len(s) < 12 {
617		return s
618	}
619	return s[:6] + "…" + s[len(s)-4:]
620}
621
622func percent(part, whole int64) string {
623	if whole <= 0 {
624		return "β€”"
625	}
626	return strconv.FormatInt((part*100+whole/2)/whole, 10) + "%"
627}
628
629func plural(n int, word string) string {
630	if n == 1 {
631		return "1 " + word
632	}
633	if strings.HasSuffix(word, "y") {
634		return itoa(n) + " " + word[:len(word)-1] + "ies"
635	}
636	return itoa(n) + " " + word + "s"
637}
638
639// cents writes an amount for CSV: "42.50", no thousands separator.
640func cents(c int64) string { return strings.ReplaceAll(ledger.Format(c), ",", "") }
641
642func csv(s string) string {
643	if !strings.ContainsAny(s, ",\"\n;") {
644		return s
645	}
646	return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
647}
648
649func attr(s string) string { return html.EscapeString(s) }
650
651// cellText is link text inside a table cell: InlineText leaves "|" alone,
652// and a bare one would end the cell.
653func cellText(s string) string { return strings.ReplaceAll(sanitize.InlineText(s), "|", `\|`) }
654
655func abs(v int64) int64 {
656	if v < 0 {
657		return -v
658	}
659	return v
660}
661
662// atoi is a non-negative number, -1 for anything else.
663func atoi(s string) int {
664	n, err := strconv.Atoi(s)
665	if err != nil || n < 0 {
666		return -1
667	}
668	return n
669}