gnocchi

Golang weight tracking application
Log | Files | Refs

main.go (6909B)


      1 package main
      2 
      3 import (
      4 	"bufio"
      5 	"flag"
      6 	"fmt"
      7 	"log"
      8 	"net/http"
      9 	"os"
     10 	"os/exec"
     11 	"regexp"
     12 	"strings"
     13 	"time"
     14 )
     15 
     16 var filePath string
     17 
     18 func main() {
     19 	port := flag.String("p", "8080", "port to listen on")
     20 	file := flag.String("f", "", "path to markdown file (required)")
     21 	flag.Parse()
     22 
     23 	if *file == "" {
     24 		fmt.Fprintln(os.Stderr, "Usage: weight-tracker -f <file.md> [-p <port>]")
     25 		flag.PrintDefaults()
     26 		os.Exit(1)
     27 	}
     28 	filePath = *file
     29 
     30 	if _, err := os.Stat(filePath); err != nil {
     31 		fmt.Fprintf(os.Stderr, "Cannot open %s: %v\n", filePath, err)
     32 		os.Exit(1)
     33 	}
     34 
     35 	http.HandleFunc("/", handleIndex)
     36 	http.HandleFunc("/submit", handleSubmit)
     37 
     38 	addr := ":" + *port
     39 	fmt.Printf("Listening on %s\n", addr)
     40 	log.Fatal(http.ListenAndServe(addr, nil))
     41 }
     42 
     43 func readFile() (string, error) {
     44 	b, err := os.ReadFile(filePath)
     45 	if err != nil {
     46 		return "", err
     47 	}
     48 	return string(b), nil
     49 }
     50 
     51 func extractSVG(content string) string {
     52 	re := regexp.MustCompile(`(?s)<svg[\s\S]*?</svg>`)
     53 	return re.FindString(content)
     54 }
     55 
     56 func extractEntries(content string) []string {
     57 	re := regexp.MustCompile(`(?s)<pre>([\s\S]*?)</pre>`)
     58 	m := re.FindStringSubmatch(content)
     59 	if m == nil {
     60 		return nil
     61 	}
     62 	var entries []string
     63 	scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(m[1])))
     64 	for scanner.Scan() {
     65 		line := strings.TrimSpace(scanner.Text())
     66 		if line != "" {
     67 			entries = append(entries, line)
     68 		}
     69 	}
     70 	return entries
     71 }
     72 
     73 func todayStr() string {
     74 	return time.Now().Format("2006-01-02")
     75 }
     76 
     77 func entryExistsForToday(entries []string) bool {
     78 	today := todayStr()
     79 	for _, e := range entries {
     80 		if strings.HasPrefix(e, today) {
     81 			return true
     82 		}
     83 	}
     84 	return false
     85 }
     86 
     87 func updatePreBlock(content, weight string) string {
     88 	today := todayStr()
     89 	newLine := today + "," + weight
     90 
     91 	re := regexp.MustCompile(`(?s)(<pre>)([\s\S]*?)(</pre>)`)
     92 	return re.ReplaceAllStringFunc(content, func(block string) string {
     93 		parts := re.FindStringSubmatch(block)
     94 		if parts == nil {
     95 			return block
     96 		}
     97 		raw := parts[2]
     98 		lines := strings.Split(raw, "\n")
     99 		replaced := false
    100 		for i, line := range lines {
    101 			if strings.HasPrefix(strings.TrimSpace(line), today) {
    102 				lines[i] = newLine
    103 				replaced = true
    104 				break
    105 			}
    106 		}
    107 		if !replaced {
    108 			trimmed := strings.TrimRight(raw, "\n ")
    109 			raw = trimmed + "\n" + newLine + "\n"
    110 		} else {
    111 			raw = strings.Join(lines, "\n")
    112 		}
    113 		return parts[1] + raw + parts[3]
    114 	})
    115 }
    116 
    117 func handleSubmit(w http.ResponseWriter, r *http.Request) {
    118 	if r.Method != http.MethodPost {
    119 		http.Redirect(w, r, "/", http.StatusSeeOther)
    120 		return
    121 	}
    122 	weight := strings.TrimSpace(r.FormValue("weight"))
    123 	if weight == "" {
    124 		http.Redirect(w, r, "/", http.StatusSeeOther)
    125 		return
    126 	}
    127 	if !strings.Contains(weight, ".") {
    128 		weight += ".0"
    129 	}
    130 
    131 	content, err := readFile()
    132 	if err != nil {
    133 		http.Error(w, "Could not read file: "+err.Error(), http.StatusInternalServerError)
    134 		return
    135 	}
    136 
    137 	updated := updatePreBlock(content, weight)
    138 
    139 	if err := os.WriteFile(filePath, []byte(updated), 0644); err != nil {
    140 		http.Error(w, "Could not write file: "+err.Error(), http.StatusInternalServerError)
    141 		return
    142 	}
    143 
    144 	// Run blog-weight to regenerate graph / markdown.
    145 	cmd := exec.Command("blog-weight")
    146 	cmd.Stdout = os.Stdout
    147 	cmd.Stderr = os.Stderr
    148 	if err := cmd.Run(); err != nil {
    149 		log.Printf("blog-weight exited with error: %v", err)
    150 	}
    151 
    152 	http.Redirect(w, r, "/", http.StatusSeeOther)
    153 }
    154 
    155 func handleIndex(w http.ResponseWriter, r *http.Request) {
    156 	content, err := readFile()
    157 	if err != nil {
    158 		http.Error(w, "Could not read file: "+err.Error(), http.StatusInternalServerError)
    159 		return
    160 	}
    161 
    162 	svg := extractSVG(content)
    163 	entries := extractEntries(content)
    164 	today := todayStr()
    165 	alreadyLogged := entryExistsForToday(entries)
    166 
    167 	lastEntry := ""
    168 	if len(entries) > 0 {
    169 		lastEntry = entries[len(entries)-1]
    170 	}
    171 
    172 	start := len(entries) - 14
    173 	if start < 0 {
    174 		start = 0
    175 	}
    176 	recent := entries[start:]
    177 
    178 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
    179 
    180 	autofocusAttr := "autofocus"
    181 
    182 	alreadyLoggedHTML := ""
    183 	if alreadyLogged {
    184 		alreadyLoggedHTML = `<p class="logged">Already logged today: ` + lastEntry + `</p>`
    185 	}
    186 
    187 	recentRows := ""
    188 	for _, e := range recent {
    189 		parts := strings.SplitN(e, ",", 2)
    190 		date, val := parts[0], ""
    191 		if len(parts) == 2 {
    192 			val = parts[1]
    193 		}
    194 		recentRows += fmt.Sprintf("<tr><td>%s</td><td>%s kg</td></tr>\n", date, val)
    195 	}
    196 
    197 	recentTable := ""
    198 	if len(recent) > 0 {
    199 		recentTable = `<details class="card"><summary><h2>Recent entries</h2></summary><table>` + recentRows + `</table></details>`
    200 	}
    201 
    202 	svgBlock := ""
    203 	if svg != "" {
    204 		svgBlock = `<div class="card"><h2>Chart</h2>` + svg + `</div>`
    205 	}
    206 
    207 	html := `<!doctype html>
    208 <html lang="en">
    209 <head>
    210 <meta charset="UTF-8">
    211 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    212 <title>Weight Logger</title>
    213 <style>
    214 	*{box-sizing:border-box;margin:0;padding:0}
    215 	.card h2,body,button,button:hover{color:#d4d4d4}
    216 	.card h2,button{font-size:1rem;color:#d4d4d4}
    217 	.card h2{margin-bottom:12px}
    218 	.card,body{padding:16px}
    219 	.card{background:#222;border-radius:12px;margin-bottom:16px;border:1px solid #333;box-shadow:none}
    220 	.date-label{font-size:.85rem;color:#777;margin-bottom:8px}
    221 	.logged,table{font-size:.9rem}
    222 	.input-row{display:flex;gap:8px;align-items:center}
    223 	.logged{color:#7cbf7c;font-weight:600;margin-top:8px}
    224 	body{font-family:monospace;background:#1a1a1a;max-width:600px;margin:0 auto}
    225 	button:active{opacity:.8}
    226 	button:hover{background:#2e2e2e}
    227 	button{padding:10px 20px;background:#2a2a2a;border:1px solid #333;border-radius:8px;cursor:pointer;white-space:nowrap}
    228 	input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none}
    229 	input[type=number]{flex:1;font-size:1.4rem;padding:10px 14px;border:2px solid #333;border-radius:8px;background:#1a1a1a;color:#d4d4d4;-moz-appearance:textfield}
    230 	svg,table{width:100%}
    231 	svg{height:auto;display:block}
    232 	svg text{fill:#d4d4d4}
    233 	table{border-collapse:collapse}
    234 	td:last-child{text-align:right;font-variant-numeric:tabular-nums}
    235 	td{padding:6px 4px;border-bottom:1px solid #333}
    236 	tr:last-child td{border-bottom:none;font-weight:600}
    237 	details summary{cursor:pointer;list-style:none}
    238 	details summary::-webkit-details-marker{display:none}
    239 	details summary h2{display:inline}
    240 	details summary:hover{color:#5b9bd5}
    241 	details summary::after{content:" ▸";color:#777}
    242 	details[open] summary::after{content:" ▾"}
    243 </style>
    244 </head>
    245 <body>
    246 <div class="card">
    247   <h2>Log today's weight</h2>
    248   <p class="date-label">` + today + `</p>
    249   <form method="POST" action="/submit">
    250     <div class="input-row">
    251       <input type="number" name="weight" step="0.1" min="30" max="300"
    252              placeholder="kg" inputmode="decimal" ` + autofocusAttr + `>
    253       <button type="submit">Save</button>
    254     </div>
    255   </form>
    256   ` + alreadyLoggedHTML + `
    257 </div>
    258 ` + recentTable + `
    259 ` + svgBlock + `
    260 </body></html>`
    261 
    262 	fmt.Fprint(w, html)
    263 }