main.go (14507B)
1 package main 2 3 import ( 4 "embed" 5 "encoding/json" 6 "flag" 7 "fmt" 8 "log" 9 "math" 10 "net/http" 11 "os" 12 "sort" 13 "strconv" 14 "strings" 15 "time" 16 ) 17 18 //go:embed static 19 var staticFiles embed.FS 20 21 var volumes = []int{25, 125, 330, 440, 500, 568, 660, 750} 22 23 func calcUnits(ml int, abv float64) float64 { 24 return float64(ml) * abv / 1000.0 25 } 26 27 // Data structures 28 29 // LoggedDrink stores enough to recalculate units at any time. 30 type LoggedDrink struct { 31 VolumeMl int `json:"volume_ml"` 32 ABV float64 `json:"abv"` 33 Count int `json:"count"` 34 } 35 36 func (d LoggedDrink) Units() float64 { return calcUnits(d.VolumeMl, d.ABV) * float64(d.Count) } 37 func (d LoggedDrink) UnitEach() float64 { return calcUnits(d.VolumeMl, d.ABV) } 38 func (d LoggedDrink) Key() string { return fmt.Sprintf("%d@%.2f", d.VolumeMl, d.ABV) } 39 func (d LoggedDrink) Label() string { return fmt.Sprintf("%dml @ %.1f%%", d.VolumeMl, d.ABV) } 40 41 type DayLog struct { 42 Date string `json:"date"` // YYYY-MM-DD 43 Drinks []LoggedDrink `json:"drinks"` 44 } 45 46 // Database now only holds day logs — no drink type catalogue. 47 type Database struct { 48 DayLogs []DayLog `json:"day_logs"` 49 } 50 51 var ( 52 dbPath string 53 db Database 54 ) 55 56 // Persistence 57 58 func loadDB() error { 59 data, err := os.ReadFile(dbPath) 60 if err != nil { 61 if os.IsNotExist(err) { 62 db = Database{DayLogs: []DayLog{}} 63 return saveDB() 64 } 65 return err 66 } 67 return json.Unmarshal(data, &db) 68 } 69 70 func saveDB() error { 71 var lines []string 72 for _, day := range db.DayLogs { 73 b, err := json.Marshal(day) 74 if err != nil { 75 return err 76 } 77 lines = append(lines, " "+string(b)) 78 } 79 var sb strings.Builder 80 sb.WriteString("{\n \"day_logs\": [") 81 if len(lines) > 0 { 82 sb.WriteString("\n") 83 sb.WriteString(strings.Join(lines, ",\n")) 84 sb.WriteString("\n ") 85 } 86 sb.WriteString("]\n}\n") 87 return os.WriteFile(dbPath, []byte(sb.String()), 0644) 88 } 89 90 // Day log helpers 91 92 func totalUnits(day DayLog) float64 { 93 var t float64 94 for _, d := range day.Drinks { 95 t += d.Units() 96 } 97 return t 98 } 99 100 func findDayLog(date string) *DayLog { 101 for i := range db.DayLogs { 102 if db.DayLogs[i].Date == date { 103 return &db.DayLogs[i] 104 } 105 } 106 return nil 107 } 108 109 func ensureDayLog(date string) *DayLog { 110 for i := range db.DayLogs { 111 if db.DayLogs[i].Date == date { 112 return &db.DayLogs[i] 113 } 114 } 115 db.DayLogs = append(db.DayLogs, DayLog{Date: date}) 116 return &db.DayLogs[len(db.DayLogs)-1] 117 } 118 119 func dayUnits(date string) float64 { 120 dl := findDayLog(date) 121 if dl == nil { 122 return 0 123 } 124 return totalUnits(*dl) 125 } 126 127 func sortDayLogs() { 128 sort.Slice(db.DayLogs, func(i, j int) bool { 129 return db.DayLogs[i].Date < db.DayLogs[j].Date 130 }) 131 } 132 133 // Summary 134 135 func renderSummary() string { 136 now := time.Now() 137 today := now.Format("2006-01-02") 138 139 var totalUnitsVal float64 140 totalDrinks := 0 141 freeDays := 0 142 143 for i := 1; i <= 7; i++ { 144 date := now.AddDate(0, 0, -i).Format("2006-01-02") 145 dl := findDayLog(date) 146 if dl == nil || len(dl.Drinks) == 0 { 147 freeDays++ 148 } else { 149 for _, d := range dl.Drinks { 150 totalDrinks += d.Count 151 totalUnitsVal += d.Units() 152 } 153 } 154 } 155 156 // Also include today if it has drinks 157 if dl := findDayLog(today); dl != nil { 158 for _, d := range dl.Drinks { 159 totalDrinks += d.Count 160 totalUnitsVal += d.Units() 161 } 162 } 163 164 return `<div class="summary-grid">` + 165 `<div class="summary-card"><span class="summary-val">` + strconv.Itoa(totalDrinks) + `</span><span class="summary-label">drinks</span></div>` + 166 `<div class="summary-card"><span class="summary-val">` + formatUnits(totalUnitsVal) + `</span><span class="summary-label">units</span></div>` + 167 `<div class="summary-card"><span class="summary-val">` + strconv.Itoa(freeDays) + `<span class="summary-val-sub">/7</span></span><span class="summary-label">drink-free days</span></div>` + 168 `</div>` 169 } 170 171 // Tile rendering 172 173 func dateColorClass(units float64, date string) string { 174 today := time.Now().Format("2006-01-02") 175 if date < today && units == 0 { 176 return "empty-past" 177 } 178 switch { 179 case units == 0: 180 return "zero" 181 case units < 2: 182 return "blue" 183 case units < 4: 184 return "green" 185 case units < 8: 186 return "yellow" 187 case units < 14: 188 return "orange" 189 case units < 20: 190 return "red" 191 case units < 30: 192 return "purple" 193 default: 194 return "black" 195 } 196 } 197 198 func formatUnits(u float64) string { 199 if u == math.Trunc(u) { 200 return fmt.Sprintf("%.0f", u) 201 } 202 return fmt.Sprintf("%.1f", u) 203 } 204 205 func renderTile(date, label string, units float64) string { 206 cls := dateColorClass(units, date) 207 todayCls := "" 208 if date == time.Now().Format("2006-01-02") { 209 todayCls = " today" 210 } 211 unitsSpan := "" 212 if units > 0 { 213 unitsSpan = fmt.Sprintf(`<span class="units">%s u</span>`, formatUnits(units)) 214 } 215 return fmt.Sprintf( 216 `<div class="tile %s%s" onclick="openDay('%s')" title="%s"><span class="date-label">%s</span>%s</div>`, 217 cls, todayCls, date, date, label, unitsSpan, 218 ) 219 } 220 221 // Views 222 223 func renderDaysRow(offset int) string { 224 today, _ := time.Parse("2006-01-02", time.Now().Format("2006-01-02")) 225 var b strings.Builder 226 start := today.AddDate(0, 0, -offset-2) 227 for i := 0; i < 5; i++ { 228 d := start.AddDate(0, 0, i) 229 ds := d.Format("2006-01-02") 230 b.WriteString(renderTile(ds, d.Format("Mon 2"), dayUnits(ds))) 231 } 232 return b.String() 233 } 234 235 func renderWeekRow(weekOffset int) string { 236 now := time.Now() 237 wd := int(now.Weekday()) 238 if wd == 0 { 239 wd = 7 240 } 241 monday := now.AddDate(0, 0, -(wd-1)-(weekOffset*7)) 242 var b strings.Builder 243 for i := 0; i < 7; i++ { 244 d := monday.AddDate(0, 0, i) 245 ds := d.Format("2006-01-02") 246 b.WriteString(renderTile(ds, d.Format("Mon 2"), dayUnits(ds))) 247 } 248 return b.String() 249 } 250 251 func renderMonthGrid(monthOffset int) string { 252 now := time.Now() 253 first := time.Date(now.Year(), now.Month()-time.Month(monthOffset), 1, 0, 0, 0, 0, time.Local) 254 last := first.AddDate(0, 1, -1) 255 256 var b strings.Builder 257 b.WriteString(`<div class="cal-grid">`) 258 for _, h := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} { 259 b.WriteString(fmt.Sprintf(`<div class="cal-header">%s</div>`, h)) 260 } 261 startWd := int(first.Weekday()) 262 if startWd == 0 { 263 startWd = 7 264 } 265 for i := 1; i < startWd; i++ { 266 b.WriteString(`<div class="cal-empty"></div>`) 267 } 268 for d := first; !d.After(last); d = d.AddDate(0, 0, 1) { 269 ds := d.Format("2006-01-02") 270 b.WriteString(renderTile(ds, d.Format("2"), dayUnits(ds))) 271 } 272 b.WriteString(`</div>`) 273 return b.String() 274 } 275 276 func weekLabel(offset int) string { 277 now := time.Now() 278 wd := int(now.Weekday()) 279 if wd == 0 { 280 wd = 7 281 } 282 monday := now.AddDate(0, 0, -(wd-1)-(offset*7)) 283 sunday := monday.AddDate(0, 0, 6) 284 return monday.Format("2 Jan") + " – " + sunday.Format("2 Jan") 285 } 286 287 func monthLabel(offset int) string { 288 now := time.Now() 289 return time.Date(now.Year(), now.Month()-time.Month(offset), 1, 0, 0, 0, 0, time.Local).Format("January 2006") 290 } 291 292 // Modal 293 294 func renderModal(date string) string { 295 dl := findDayLog(date) 296 t, _ := time.Parse("2006-01-02", date) 297 298 // Logged drinks table 299 var loggedHTML strings.Builder 300 if dl != nil && len(dl.Drinks) > 0 { 301 loggedHTML.WriteString(`<table class="drink-table"> 302 <tr><th>Drink</th><th>×</th><th>Units</th><th></th></tr>`) 303 for _, drink := range dl.Drinks { 304 key := drink.Key() 305 loggedHTML.WriteString(fmt.Sprintf(` 306 <tr> 307 <td>%s</td> 308 <td>%d</td> 309 <td>%.2f</td> 310 <td class="actions"> 311 <button class="btn-sm btn-minus" onclick="adjustDrink('%s','%s',-1)">−</button> 312 <button class="btn-sm btn-plus" onclick="adjustDrink('%s','%s', 1)">+</button> 313 <button class="btn-sm btn-del" onclick="removeDrink('%s','%s')">✕</button> 314 </td> 315 </tr>`, 316 drink.Label(), drink.Count, drink.Units(), 317 date, key, 318 date, key, 319 date, key, 320 )) 321 } 322 loggedHTML.WriteString(`</table>`) 323 } else { 324 loggedHTML.WriteString(`<p class="no-drinks">No drinks logged.</p>`) 325 } 326 327 // Volume options 328 var volOpts strings.Builder 329 for _, v := range volumes { 330 volOpts.WriteString(fmt.Sprintf(`<option value="%d">%dml</option>`, v, v)) 331 } 332 333 totalU := 0.0 334 if dl != nil { 335 totalU = totalUnits(*dl) 336 } 337 338 // Use concatenation — never fmt.Sprintf — so that % signs inside 339 // loggedHTML (e.g. "5.0%" in drink labels) can't corrupt the output. 340 return `<div class="modal-overlay" id="day-modal" onclick="closeModal(event)">` + 341 `<div class="modal-box">` + 342 `<h2>` + t.Format("Monday 2 January 2006") + `</h2>` + 343 `<p class="total-units">Total: <strong>` + formatUnits(totalU) + ` units</strong></p>` + 344 `<div id="logged-drinks">` + loggedHTML.String() + `</div>` + 345 `<div class="add-drink-form">` + 346 `<h3>Add a drink</h3>` + 347 `<div class="add-drink-row">` + 348 `<div class="add-field"><label>Volume</label><select id="drink-volume">` + volOpts.String() + `</select></div>` + 349 `<div class="add-field"><label>ABV %</label>` + 350 `<input id="drink-abv" type="number" min="0.1" max="99" step="0.1" value="5.0" placeholder="e.g. 13.5"></div>` + 351 `<button class="btn-add" onclick="addDrink('` + date + `')">Add</button>` + 352 `</div>` + 353 `<p class="abv-preview" id="abv-preview"></p>` + 354 `</div>` + 355 `<button class="btn-close" onclick="document.getElementById('day-modal').remove()">Close</button>` + 356 `</div></div>` 357 } 358 359 // HTTP 360 361 func handleIndex(w http.ResponseWriter, r *http.Request) { 362 if r.URL.Path != "/" { 363 http.NotFound(w, r) 364 return 365 } 366 tmpl, err := staticFiles.ReadFile("static/index.html") 367 if err != nil { 368 http.Error(w, "template not found", 500) 369 return 370 } 371 page := string(tmpl) 372 page = strings.ReplaceAll(page, "{{SUMMARY}}", renderSummary()) 373 page = strings.ReplaceAll(page, "{{DAYS_TILES}}", renderDaysRow(0)) 374 page = strings.ReplaceAll(page, "{{WEEK_TILES}}", renderWeekRow(0)) 375 page = strings.ReplaceAll(page, "{{MONTH_GRID}}", renderMonthGrid(0)) 376 page = strings.ReplaceAll(page, "{{WEEK_LABEL}}", weekLabel(0)) 377 page = strings.ReplaceAll(page, "{{MONTH_LABEL}}", monthLabel(0)) 378 w.Header().Set("Content-Type", "text/html; charset=utf-8") 379 fmt.Fprint(w, page) 380 } 381 382 383 func handleSummary(w http.ResponseWriter, r *http.Request) { 384 w.Header().Set("Content-Type", "text/html; charset=utf-8") 385 fmt.Fprint(w, renderSummary()) 386 } 387 388 func handleTilesDays(w http.ResponseWriter, r *http.Request) { 389 offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) 390 w.Header().Set("Content-Type", "text/html; charset=utf-8") 391 fmt.Fprint(w, renderDaysRow(offset)) 392 } 393 394 func handleTilesWeek(w http.ResponseWriter, r *http.Request) { 395 offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) 396 w.Header().Set("Content-Type", "text/html; charset=utf-8") 397 fmt.Fprint(w, renderWeekRow(offset)) 398 } 399 400 func handleTilesMonth(w http.ResponseWriter, r *http.Request) { 401 offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) 402 w.Header().Set("Content-Type", "text/html; charset=utf-8") 403 fmt.Fprint(w, renderMonthGrid(offset)) 404 } 405 406 func handleLabelWeek(w http.ResponseWriter, r *http.Request) { 407 offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) 408 fmt.Fprint(w, weekLabel(offset)) 409 } 410 411 func handleLabelMonth(w http.ResponseWriter, r *http.Request) { 412 offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) 413 fmt.Fprint(w, monthLabel(offset)) 414 } 415 416 func handleModal(w http.ResponseWriter, r *http.Request) { 417 date := r.URL.Query().Get("date") 418 if date == "" { 419 http.Error(w, "missing date", 400) 420 return 421 } 422 w.Header().Set("Content-Type", "text/html; charset=utf-8") 423 fmt.Fprint(w, renderModal(date)) 424 } 425 426 // parseKey splits a key like "330@5.00" into (330, 5.0). 427 func parseKey(key string) (int, float64, bool) { 428 parts := strings.SplitN(key, "@", 2) 429 if len(parts) != 2 { 430 return 0, 0, false 431 } 432 ml, err1 := strconv.Atoi(parts[0]) 433 abv, err2 := strconv.ParseFloat(parts[1], 64) 434 return ml, abv, err1 == nil && err2 == nil 435 } 436 437 func handleAddDrink(w http.ResponseWriter, r *http.Request) { 438 r.ParseForm() 439 date := r.FormValue("date") 440 ml, err1 := strconv.Atoi(r.FormValue("volume_ml")) 441 abv, err2 := strconv.ParseFloat(r.FormValue("abv"), 64) 442 if err1 != nil || err2 != nil || ml <= 0 || abv <= 0 { 443 http.Error(w, "invalid volume or abv", 400) 444 return 445 } 446 447 day := ensureDayLog(date) 448 // Match on volume+abv — increment count if already present 449 for i := range day.Drinks { 450 if day.Drinks[i].VolumeMl == ml && day.Drinks[i].ABV == abv { 451 day.Drinks[i].Count++ 452 saveDB() 453 w.WriteHeader(200) 454 return 455 } 456 } 457 day.Drinks = append(day.Drinks, LoggedDrink{VolumeMl: ml, ABV: abv, Count: 1}) 458 saveDB() 459 w.WriteHeader(200) 460 } 461 462 func handleRemoveDrink(w http.ResponseWriter, r *http.Request) { 463 r.ParseForm() 464 date := r.FormValue("date") 465 ml, abv, ok := parseKey(r.FormValue("key")) 466 if !ok { 467 http.Error(w, "invalid key", 400) 468 return 469 } 470 471 day := findDayLog(date) 472 if day == nil { 473 w.WriteHeader(200) 474 return 475 } 476 kept := day.Drinks[:0] 477 for _, d := range day.Drinks { 478 if d.VolumeMl != ml || d.ABV != abv { 479 kept = append(kept, d) 480 } 481 } 482 day.Drinks = kept 483 saveDB() 484 w.WriteHeader(200) 485 } 486 487 func handleAdjustDrink(w http.ResponseWriter, r *http.Request) { 488 r.ParseForm() 489 date := r.FormValue("date") 490 ml, abv, ok := parseKey(r.FormValue("key")) 491 delta, _ := strconv.Atoi(r.FormValue("delta")) 492 if !ok { 493 http.Error(w, "invalid key", 400) 494 return 495 } 496 497 day := findDayLog(date) 498 if day == nil { 499 w.WriteHeader(200) 500 return 501 } 502 var kept []LoggedDrink 503 for _, d := range day.Drinks { 504 if d.VolumeMl == ml && d.ABV == abv { 505 d.Count += delta 506 if d.Count > 0 { 507 kept = append(kept, d) 508 } 509 } else { 510 kept = append(kept, d) 511 } 512 } 513 day.Drinks = kept 514 saveDB() 515 w.WriteHeader(200) 516 } 517 518 // Entry point 519 520 func main() { 521 filePath := flag.String("f", "units.json", "Path to the JSON database file") 522 port := flag.Int("p", 8080, "Port to listen on") 523 flag.Parse() 524 525 dbPath = *filePath 526 if err := loadDB(); err != nil { 527 log.Fatalf("Failed to load database: %v", err) 528 } 529 sortDayLogs() 530 531 mux := http.NewServeMux() 532 mux.Handle("/static/", http.FileServer(http.FS(staticFiles))) 533 mux.HandleFunc("/", handleIndex) 534 mux.HandleFunc("/summary", handleSummary) 535 mux.HandleFunc("/tiles/days", handleTilesDays) 536 mux.HandleFunc("/tiles/week", handleTilesWeek) 537 mux.HandleFunc("/tiles/month", handleTilesMonth) 538 mux.HandleFunc("/label/week", handleLabelWeek) 539 mux.HandleFunc("/label/month", handleLabelMonth) 540 mux.HandleFunc("/modal", handleModal) 541 mux.HandleFunc("/drink/add", handleAddDrink) 542 mux.HandleFunc("/drink/remove", handleRemoveDrink) 543 mux.HandleFunc("/drink/adjust", handleAdjustDrink) 544 545 addr := fmt.Sprintf(":%d", *port) 546 log.Printf("Unit Tracker running at http://localhost%s", addr) 547 log.Printf("Database: %s", dbPath) 548 if err := http.ListenAndServe(addr, mux); err != nil { 549 log.Fatalf("Server error: %v", err) 550 } 551 }