main.go (17027B)
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{50, 125, 187, 284, 330, 375, 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 // Week/Month period summaries 206 207 func ordinal(n int) string { 208 if n%100 >= 11 && n%100 <= 13 { 209 return fmt.Sprintf("%dth", n) 210 } 211 switch n % 10 { 212 case 1: 213 return fmt.Sprintf("%dst", n) 214 case 2: 215 return fmt.Sprintf("%dnd", n) 216 case 3: 217 return fmt.Sprintf("%drd", n) 218 } 219 return fmt.Sprintf("%dth", n) 220 } 221 222 func startOfDay(t time.Time) time.Time { 223 return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) 224 } 225 226 // weekStart returns the Monday of the week containing t. 227 func weekStart(t time.Time) time.Time { 228 t = startOfDay(t) 229 wd := int(t.Weekday()) 230 if wd == 0 { 231 wd = 7 232 } 233 return t.AddDate(0, 0, -(wd - 1)) 234 } 235 236 // earliestLoggedDay returns the earliest date with at least one drink, or 237 // today if nothing has been logged. 238 func earliestLoggedDay(today time.Time) time.Time { 239 earliest := today 240 for _, dl := range db.DayLogs { 241 if len(dl.Drinks) == 0 { 242 continue 243 } 244 t, err := time.ParseInLocation("2006-01-02", dl.Date, today.Location()) 245 if err == nil && t.Before(earliest) { 246 earliest = t 247 } 248 } 249 return earliest 250 } 251 252 // periodStats totals drinks/units and drink-free days over range 253 func periodStats(start, end, today time.Time) (free, days, drinks int, units float64) { 254 todayStr := today.Format("2006-01-02") 255 for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { 256 days++ 257 ds := d.Format("2006-01-02") 258 n := 0 259 if dl := findDayLog(ds); dl != nil { 260 for _, dr := range dl.Drinks { 261 n += dr.Count 262 units += dr.Units() 263 } 264 } 265 drinks += n 266 if n == 0 && ds < todayStr { 267 free++ 268 } 269 } 270 return 271 } 272 273 func renderPeriodRow(label string, current bool, free, days, drinks int, units float64) string { 274 cls := "period-row" 275 if current { 276 cls += " current" 277 } 278 return `<div class="` + cls + `">` + 279 `<span class="period-label">` + label + `</span>` + 280 `<span class="period-stat"><strong>` + strconv.Itoa(free) + `/` + strconv.Itoa(days) + `</strong><small>drink-free</small></span>` + 281 `<span class="period-stat"><strong>` + strconv.Itoa(drinks) + `</strong><small>drinks</small></span>` + 282 `<span class="period-stat"><strong>` + formatUnits(units) + `</strong><small>units</small></span>` + 283 `</div>` 284 } 285 286 func renderPeriods(mode string) string { 287 today := startOfDay(time.Now()) 288 earliest := earliestLoggedDay(today) 289 thisYear := today.Year() 290 var b strings.Builder 291 292 if mode == "months" { 293 cur := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()) 294 stop := time.Date(earliest.Year(), earliest.Month(), 1, 0, 0, 0, 0, today.Location()) 295 i := 0 296 for m := cur; i < 600 && (i < 4 || !m.Before(stop)); m = m.AddDate(0, -1, 0) { 297 end := m.AddDate(0, 1, -1) 298 free, days, drinks, units := periodStats(m, end, today) 299 label := m.Format("January") 300 if m.Year() != thisYear { 301 label = m.Format("January 2006") 302 } 303 b.WriteString(renderPeriodRow(label, i == 0, free, days, drinks, units)) 304 i++ 305 } 306 return b.String() 307 } 308 309 cur := weekStart(today) 310 stop := weekStart(earliest) 311 i := 0 312 for w := cur; i < 2600 && (i < 4 || !w.Before(stop)); w = w.AddDate(0, 0, -7) { 313 end := w.AddDate(0, 0, 6) 314 free, days, drinks, units := periodStats(w, end, today) 315 label := ordinal(w.Day()) + " " + w.Format("January") 316 if w.Year() != thisYear { 317 label += " " + w.Format("2006") 318 } 319 b.WriteString(renderPeriodRow(label, i == 0, free, days, drinks, units)) 320 i++ 321 } 322 return b.String() 323 } 324 325 // monthBounds returns the first and last day of the target month, where 326 // offset 0 = the current calendar month, -1 = previous month, 1 = next 327 // month, etc. 328 func monthBounds(offset int) (time.Time, time.Time) { 329 now := time.Now() 330 firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) 331 target := firstOfMonth.AddDate(0, offset, 0) 332 last := target.AddDate(0, 1, -1) 333 return target, last 334 } 335 336 func monthLabel(offset int) string { 337 target, _ := monthBounds(offset) 338 return target.Format("January 2006") 339 } 340 341 // renderCalendarTile renders a single day tile for the full-month grid. Days 342 // that fall outside the target month (used to pad out the leading/trailing 343 // weeks so the grid always shows whole weeks) get an "other-month" class so 344 // they can be dimmed in CSS. 345 func renderCalendarTile(date string, dayNum int, units float64, otherMonth bool) string { 346 cls := dateColorClass(units, date) 347 if otherMonth { 348 cls += " other-month" 349 } 350 todayCls := "" 351 if date == time.Now().Format("2006-01-02") { 352 todayCls = " today" 353 } 354 unitsSpan := "" 355 if units > 0 { 356 unitsSpan = fmt.Sprintf(`<span class="units">%s u</span>`, formatUnits(units)) 357 } 358 return fmt.Sprintf( 359 `<div class="tile %s%s" onclick="openDay('%s')" title="%s"><span class="date-label">%d</span>%s</div>`, 360 cls, todayCls, date, date, dayNum, unitsSpan, 361 ) 362 } 363 364 // renderMonthGrid renders a full calendar month (Monday-first weeks), 365 // padded at the start/end with days from the adjacent months so every row 366 // is a complete week. offset 0 = current month. 367 func renderMonthGrid(offset int) string { 368 first, last := monthBounds(offset) 369 370 wd := int(first.Weekday()) 371 if wd == 0 { 372 wd = 7 373 } 374 start := first.AddDate(0, 0, -(wd - 1)) 375 376 wd2 := int(last.Weekday()) 377 if wd2 == 0 { 378 wd2 = 7 379 } 380 end := last.AddDate(0, 0, 7-wd2) 381 382 var b strings.Builder 383 for d := start; !d.After(end); d = d.AddDate(0, 0, 7) { 384 b.WriteString(`<div class="month-week-row">`) 385 for i := 0; i < 7; i++ { 386 day := d.AddDate(0, 0, i) 387 ds := day.Format("2006-01-02") 388 b.WriteString(renderCalendarTile(ds, day.Day(), dayUnits(ds), day.Month() != first.Month())) 389 } 390 b.WriteString(`</div>`) 391 } 392 return b.String() 393 } 394 395 // Modal 396 397 func renderModal(date string) string { 398 dl := findDayLog(date) 399 t, _ := time.Parse("2006-01-02", date) 400 401 // Logged drinks table 402 var loggedHTML strings.Builder 403 if dl != nil && len(dl.Drinks) > 0 { 404 loggedHTML.WriteString(`<table class="drink-table"> 405 <tr><th>Drink</th><th>×</th><th>Units</th><th></th></tr>`) 406 for _, drink := range dl.Drinks { 407 key := drink.Key() 408 loggedHTML.WriteString(fmt.Sprintf(` 409 <tr> 410 <td>%s</td> 411 <td>%d</td> 412 <td>%.2f</td> 413 <td class="actions"> 414 <button class="btn-sm btn-minus" onclick="adjustDrink('%s','%s',-1)">−</button> 415 <button class="btn-sm btn-plus" onclick="adjustDrink('%s','%s', 1)">+</button> 416 <button class="btn-sm btn-del" onclick="removeDrink('%s','%s')">✕</button> 417 </td> 418 </tr>`, 419 drink.Label(), drink.Count, drink.Units(), 420 date, key, 421 date, key, 422 date, key, 423 )) 424 } 425 loggedHTML.WriteString(`</table>`) 426 } else { 427 loggedHTML.WriteString(`<p class="no-drinks">No drinks logged.</p>`) 428 } 429 430 // Volume options 431 var volOpts strings.Builder 432 for _, v := range volumes { 433 volOpts.WriteString(fmt.Sprintf(`<option value="%d">%dml</option>`, v, v)) 434 } 435 436 totalU := 0.0 437 if dl != nil { 438 totalU = totalUnits(*dl) 439 } 440 441 // Use concatenation — never fmt.Sprintf — so that % signs inside 442 // loggedHTML (e.g. "5.0%" in drink labels) can't corrupt the output. 443 return `<div class="modal-overlay" id="day-modal" onclick="closeModal(event)">` + 444 `<div class="modal-box">` + 445 `<h2>` + t.Format("Monday 2 January 2006") + `</h2>` + 446 `<p class="total-units">Total: <strong>` + formatUnits(totalU) + ` units</strong></p>` + 447 `<div id="logged-drinks">` + loggedHTML.String() + `</div>` + 448 `<div class="add-drink-form">` + 449 `<h3>Add a drink</h3>` + 450 `<div class="add-drink-row">` + 451 `<div class="add-field"><label>Volume</label><select id="drink-volume">` + volOpts.String() + `</select></div>` + 452 `<div class="add-field"><label>ABV %</label>` + 453 `<input id="drink-abv" type="number" min="0.1" max="99" step="0.1" value="5.0" placeholder="e.g. 13.5"></div>` + 454 `<button class="btn-add" onclick="addDrink('` + date + `')">Add</button>` + 455 `</div>` + 456 `<p class="abv-preview" id="abv-preview"></p>` + 457 `</div>` + 458 `<button class="btn-close" onclick="document.getElementById('day-modal').remove()">Close</button>` + 459 `</div></div>` 460 } 461 462 // HTTP 463 464 func handleIndex(w http.ResponseWriter, r *http.Request) { 465 if r.URL.Path != "/" { 466 http.NotFound(w, r) 467 return 468 } 469 tmpl, err := staticFiles.ReadFile("static/index.html") 470 if err != nil { 471 http.Error(w, "template not found", 500) 472 return 473 } 474 page := string(tmpl) 475 page = strings.ReplaceAll(page, "{{SUMMARY}}", renderSummary()) 476 page = strings.ReplaceAll(page, "{{PERIODS}}", renderPeriods("weeks")) 477 page = strings.ReplaceAll(page, "{{MONTH_LABEL}}", monthLabel(0)) 478 page = strings.ReplaceAll(page, "{{MONTH_GRID}}", renderMonthGrid(0)) 479 w.Header().Set("Content-Type", "text/html; charset=utf-8") 480 fmt.Fprint(w, page) 481 } 482 483 484 func handleSummary(w http.ResponseWriter, r *http.Request) { 485 w.Header().Set("Content-Type", "text/html; charset=utf-8") 486 fmt.Fprint(w, renderSummary()) 487 } 488 489 func handlePeriods(w http.ResponseWriter, r *http.Request) { 490 w.Header().Set("Content-Type", "text/html; charset=utf-8") 491 fmt.Fprint(w, renderPeriods(r.URL.Query().Get("mode"))) 492 } 493 494 func handleTilesMonth(w http.ResponseWriter, r *http.Request) { 495 offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) 496 resp := struct { 497 Label string `json:"label"` 498 Grid string `json:"grid"` 499 NextDisabled bool `json:"next_disabled"` 500 }{ 501 Label: monthLabel(offset), 502 Grid: renderMonthGrid(offset), 503 NextDisabled: offset >= 0, 504 } 505 w.Header().Set("Content-Type", "application/json") 506 json.NewEncoder(w).Encode(resp) 507 } 508 509 func handleModal(w http.ResponseWriter, r *http.Request) { 510 date := r.URL.Query().Get("date") 511 if date == "" { 512 http.Error(w, "missing date", 400) 513 return 514 } 515 w.Header().Set("Content-Type", "text/html; charset=utf-8") 516 fmt.Fprint(w, renderModal(date)) 517 } 518 519 // parseKey splits a key like "330@5.00" into (330, 5.0). 520 func parseKey(key string) (int, float64, bool) { 521 parts := strings.SplitN(key, "@", 2) 522 if len(parts) != 2 { 523 return 0, 0, false 524 } 525 ml, err1 := strconv.Atoi(parts[0]) 526 abv, err2 := strconv.ParseFloat(parts[1], 64) 527 return ml, abv, err1 == nil && err2 == nil 528 } 529 530 func handleAddDrink(w http.ResponseWriter, r *http.Request) { 531 r.ParseForm() 532 date := r.FormValue("date") 533 ml, err1 := strconv.Atoi(r.FormValue("volume_ml")) 534 abv, err2 := strconv.ParseFloat(r.FormValue("abv"), 64) 535 if err1 != nil || err2 != nil || ml <= 0 || abv <= 0 { 536 http.Error(w, "invalid volume or abv", 400) 537 return 538 } 539 540 day := ensureDayLog(date) 541 // Match on volume+abv — increment count if already present 542 for i := range day.Drinks { 543 if day.Drinks[i].VolumeMl == ml && day.Drinks[i].ABV == abv { 544 day.Drinks[i].Count++ 545 saveDB() 546 w.WriteHeader(200) 547 return 548 } 549 } 550 day.Drinks = append(day.Drinks, LoggedDrink{VolumeMl: ml, ABV: abv, Count: 1}) 551 saveDB() 552 w.WriteHeader(200) 553 } 554 555 func handleRemoveDrink(w http.ResponseWriter, r *http.Request) { 556 r.ParseForm() 557 date := r.FormValue("date") 558 ml, abv, ok := parseKey(r.FormValue("key")) 559 if !ok { 560 http.Error(w, "invalid key", 400) 561 return 562 } 563 564 day := findDayLog(date) 565 if day == nil { 566 w.WriteHeader(200) 567 return 568 } 569 kept := day.Drinks[:0] 570 for _, d := range day.Drinks { 571 if d.VolumeMl != ml || d.ABV != abv { 572 kept = append(kept, d) 573 } 574 } 575 day.Drinks = kept 576 saveDB() 577 w.WriteHeader(200) 578 } 579 580 func handleAdjustDrink(w http.ResponseWriter, r *http.Request) { 581 r.ParseForm() 582 date := r.FormValue("date") 583 ml, abv, ok := parseKey(r.FormValue("key")) 584 delta, _ := strconv.Atoi(r.FormValue("delta")) 585 if !ok { 586 http.Error(w, "invalid key", 400) 587 return 588 } 589 590 day := findDayLog(date) 591 if day == nil { 592 w.WriteHeader(200) 593 return 594 } 595 var kept []LoggedDrink 596 for _, d := range day.Drinks { 597 if d.VolumeMl == ml && d.ABV == abv { 598 d.Count += delta 599 if d.Count > 0 { 600 kept = append(kept, d) 601 } 602 } else { 603 kept = append(kept, d) 604 } 605 } 606 day.Drinks = kept 607 saveDB() 608 w.WriteHeader(200) 609 } 610 611 // Entry point 612 613 func main() { 614 filePath := flag.String("f", "units.json", "Path to the JSON database file") 615 port := flag.Int("p", 8080, "Port to listen on") 616 flag.Parse() 617 618 dbPath = *filePath 619 if err := loadDB(); err != nil { 620 log.Fatalf("Failed to load database: %v", err) 621 } 622 sortDayLogs() 623 624 mux := http.NewServeMux() 625 mux.Handle("/static/", http.FileServer(http.FS(staticFiles))) 626 mux.HandleFunc("/", handleIndex) 627 mux.HandleFunc("/summary", handleSummary) 628 mux.HandleFunc("/summary/periods", handlePeriods) 629 mux.HandleFunc("/tiles/month", handleTilesMonth) 630 mux.HandleFunc("/modal", handleModal) 631 mux.HandleFunc("/drink/add", handleAddDrink) 632 mux.HandleFunc("/drink/remove", handleRemoveDrink) 633 mux.HandleFunc("/drink/adjust", handleAdjustDrink) 634 635 addr := fmt.Sprintf(":%d", *port) 636 log.Printf("Unit Tracker running at http://localhost%s", addr) 637 log.Printf("Database: %s", dbPath) 638 if err := http.ListenAndServe(addr, mux); err != nil { 639 log.Fatalf("Server error: %v", err) 640 } 641 }