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 ") + ")"
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}