main.go (14552B)
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, 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 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 // monthBounds returns the first and last day of the target month, where 236 // offset 0 = the current calendar month, -1 = previous month, 1 = next 237 // month, etc. 238 func monthBounds(offset int) (time.Time, time.Time) { 239 now := time.Now() 240 firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) 241 target := firstOfMonth.AddDate(0, offset, 0) 242 last := target.AddDate(0, 1, -1) 243 return target, last 244 } 245 246 func monthLabel(offset int) string { 247 target, _ := monthBounds(offset) 248 return target.Format("January 2006") 249 } 250 251 // renderCalendarTile renders a single day tile for the full-month grid. Days 252 // that fall outside the target month (used to pad out the leading/trailing 253 // weeks so the grid always shows whole weeks) get an "other-month" class so 254 // they can be dimmed in CSS. 255 func renderCalendarTile(date string, dayNum int, units float64, otherMonth bool) string { 256 cls := dateColorClass(units, date) 257 if otherMonth { 258 cls += " other-month" 259 } 260 todayCls := "" 261 if date == time.Now().Format("2006-01-02") { 262 todayCls = " today" 263 } 264 unitsSpan := "" 265 if units > 0 { 266 unitsSpan = fmt.Sprintf(`<span class="units">%s u</span>`, formatUnits(units)) 267 } 268 return fmt.Sprintf( 269 `<div class="tile %s%s" onclick="openDay('%s')" title="%s"><span class="date-label">%d</span>%s</div>`, 270 cls, todayCls, date, date, dayNum, unitsSpan, 271 ) 272 } 273 274 // renderMonthGrid renders a full calendar month (Monday-first weeks), 275 // padded at the start/end with days from the adjacent months so every row 276 // is a complete week. offset 0 = current month. 277 func renderMonthGrid(offset int) string { 278 first, last := monthBounds(offset) 279 280 wd := int(first.Weekday()) 281 if wd == 0 { 282 wd = 7 283 } 284 start := first.AddDate(0, 0, -(wd - 1)) 285 286 wd2 := int(last.Weekday()) 287 if wd2 == 0 { 288 wd2 = 7 289 } 290 end := last.AddDate(0, 0, 7-wd2) 291 292 var b strings.Builder 293 for d := start; !d.After(end); d = d.AddDate(0, 0, 7) { 294 b.WriteString(`<div class="month-week-row">`) 295 for i := 0; i < 7; i++ { 296 day := d.AddDate(0, 0, i) 297 ds := day.Format("2006-01-02") 298 b.WriteString(renderCalendarTile(ds, day.Day(), dayUnits(ds), day.Month() != first.Month())) 299 } 300 b.WriteString(`</div>`) 301 } 302 return b.String() 303 } 304 305 // Modal 306 307 func renderModal(date string) string { 308 dl := findDayLog(date) 309 t, _ := time.Parse("2006-01-02", date) 310 311 // Logged drinks table 312 var loggedHTML strings.Builder 313 if dl != nil && len(dl.Drinks) > 0 { 314 loggedHTML.WriteString(`<table class="drink-table"> 315 <tr><th>Drink</th><th>×</th><th>Units</th><th></th></tr>`) 316 for _, drink := range dl.Drinks { 317 key := drink.Key() 318 loggedHTML.WriteString(fmt.Sprintf(` 319 <tr> 320 <td>%s</td> 321 <td>%d</td> 322 <td>%.2f</td> 323 <td class="actions"> 324 <button class="btn-sm btn-minus" onclick="adjustDrink('%s','%s',-1)">−</button> 325 <button class="btn-sm btn-plus" onclick="adjustDrink('%s','%s', 1)">+</button> 326 <button class="btn-sm btn-del" onclick="removeDrink('%s','%s')">✕</button> 327 </td> 328 </tr>`, 329 drink.Label(), drink.Count, drink.Units(), 330 date, key, 331 date, key, 332 date, key, 333 )) 334 } 335 loggedHTML.WriteString(`</table>`) 336 } else { 337 loggedHTML.WriteString(`<p class="no-drinks">No drinks logged.</p>`) 338 } 339 340 // Volume options 341 var volOpts strings.Builder 342 for _, v := range volumes { 343 volOpts.WriteString(fmt.Sprintf(`<option value="%d">%dml</option>`, v, v)) 344 } 345 346 totalU := 0.0 347 if dl != nil { 348 totalU = totalUnits(*dl) 349 } 350 351 // Use concatenation — never fmt.Sprintf — so that % signs inside 352 // loggedHTML (e.g. "5.0%" in drink labels) can't corrupt the output. 353 return `<div class="modal-overlay" id="day-modal" onclick="closeModal(event)">` + 354 `<div class="modal-box">` + 355 `<h2>` + t.Format("Monday 2 January 2006") + `</h2>` + 356 `<p class="total-units">Total: <strong>` + formatUnits(totalU) + ` units</strong></p>` + 357 `<div id="logged-drinks">` + loggedHTML.String() + `</div>` + 358 `<div class="add-drink-form">` + 359 `<h3>Add a drink</h3>` + 360 `<div class="add-drink-row">` + 361 `<div class="add-field"><label>Volume</label><select id="drink-volume">` + volOpts.String() + `</select></div>` + 362 `<div class="add-field"><label>ABV %</label>` + 363 `<input id="drink-abv" type="number" min="0.1" max="99" step="0.1" value="5.0" placeholder="e.g. 13.5"></div>` + 364 `<button class="btn-add" onclick="addDrink('` + date + `')">Add</button>` + 365 `</div>` + 366 `<p class="abv-preview" id="abv-preview"></p>` + 367 `</div>` + 368 `<button class="btn-close" onclick="document.getElementById('day-modal').remove()">Close</button>` + 369 `</div></div>` 370 } 371 372 // HTTP 373 374 func handleIndex(w http.ResponseWriter, r *http.Request) { 375 if r.URL.Path != "/" { 376 http.NotFound(w, r) 377 return 378 } 379 tmpl, err := staticFiles.ReadFile("static/index.html") 380 if err != nil { 381 http.Error(w, "template not found", 500) 382 return 383 } 384 page := string(tmpl) 385 page = strings.ReplaceAll(page, "{{SUMMARY}}", renderSummary()) 386 page = strings.ReplaceAll(page, "{{DAYS_TILES}}", renderDaysRow(0)) 387 page = strings.ReplaceAll(page, "{{MONTH_LABEL}}", monthLabel(0)) 388 page = strings.ReplaceAll(page, "{{MONTH_GRID}}", renderMonthGrid(0)) 389 w.Header().Set("Content-Type", "text/html; charset=utf-8") 390 fmt.Fprint(w, page) 391 } 392 393 394 func handleSummary(w http.ResponseWriter, r *http.Request) { 395 w.Header().Set("Content-Type", "text/html; charset=utf-8") 396 fmt.Fprint(w, renderSummary()) 397 } 398 399 func handleTilesDays(w http.ResponseWriter, r *http.Request) { 400 offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) 401 w.Header().Set("Content-Type", "text/html; charset=utf-8") 402 fmt.Fprint(w, renderDaysRow(offset)) 403 } 404 405 func handleTilesMonth(w http.ResponseWriter, r *http.Request) { 406 offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) 407 resp := struct { 408 Label string `json:"label"` 409 Grid string `json:"grid"` 410 NextDisabled bool `json:"next_disabled"` 411 }{ 412 Label: monthLabel(offset), 413 Grid: renderMonthGrid(offset), 414 NextDisabled: offset >= 0, 415 } 416 w.Header().Set("Content-Type", "application/json") 417 json.NewEncoder(w).Encode(resp) 418 } 419 420 func handleModal(w http.ResponseWriter, r *http.Request) { 421 date := r.URL.Query().Get("date") 422 if date == "" { 423 http.Error(w, "missing date", 400) 424 return 425 } 426 w.Header().Set("Content-Type", "text/html; charset=utf-8") 427 fmt.Fprint(w, renderModal(date)) 428 } 429 430 // parseKey splits a key like "330@5.00" into (330, 5.0). 431 func parseKey(key string) (int, float64, bool) { 432 parts := strings.SplitN(key, "@", 2) 433 if len(parts) != 2 { 434 return 0, 0, false 435 } 436 ml, err1 := strconv.Atoi(parts[0]) 437 abv, err2 := strconv.ParseFloat(parts[1], 64) 438 return ml, abv, err1 == nil && err2 == nil 439 } 440 441 func handleAddDrink(w http.ResponseWriter, r *http.Request) { 442 r.ParseForm() 443 date := r.FormValue("date") 444 ml, err1 := strconv.Atoi(r.FormValue("volume_ml")) 445 abv, err2 := strconv.ParseFloat(r.FormValue("abv"), 64) 446 if err1 != nil || err2 != nil || ml <= 0 || abv <= 0 { 447 http.Error(w, "invalid volume or abv", 400) 448 return 449 } 450 451 day := ensureDayLog(date) 452 // Match on volume+abv — increment count if already present 453 for i := range day.Drinks { 454 if day.Drinks[i].VolumeMl == ml && day.Drinks[i].ABV == abv { 455 day.Drinks[i].Count++ 456 saveDB() 457 w.WriteHeader(200) 458 return 459 } 460 } 461 day.Drinks = append(day.Drinks, LoggedDrink{VolumeMl: ml, ABV: abv, Count: 1}) 462 saveDB() 463 w.WriteHeader(200) 464 } 465 466 func handleRemoveDrink(w http.ResponseWriter, r *http.Request) { 467 r.ParseForm() 468 date := r.FormValue("date") 469 ml, abv, ok := parseKey(r.FormValue("key")) 470 if !ok { 471 http.Error(w, "invalid key", 400) 472 return 473 } 474 475 day := findDayLog(date) 476 if day == nil { 477 w.WriteHeader(200) 478 return 479 } 480 kept := day.Drinks[:0] 481 for _, d := range day.Drinks { 482 if d.VolumeMl != ml || d.ABV != abv { 483 kept = append(kept, d) 484 } 485 } 486 day.Drinks = kept 487 saveDB() 488 w.WriteHeader(200) 489 } 490 491 func handleAdjustDrink(w http.ResponseWriter, r *http.Request) { 492 r.ParseForm() 493 date := r.FormValue("date") 494 ml, abv, ok := parseKey(r.FormValue("key")) 495 delta, _ := strconv.Atoi(r.FormValue("delta")) 496 if !ok { 497 http.Error(w, "invalid key", 400) 498 return 499 } 500 501 day := findDayLog(date) 502 if day == nil { 503 w.WriteHeader(200) 504 return 505 } 506 var kept []LoggedDrink 507 for _, d := range day.Drinks { 508 if d.VolumeMl == ml && d.ABV == abv { 509 d.Count += delta 510 if d.Count > 0 { 511 kept = append(kept, d) 512 } 513 } else { 514 kept = append(kept, d) 515 } 516 } 517 day.Drinks = kept 518 saveDB() 519 w.WriteHeader(200) 520 } 521 522 // Entry point 523 524 func main() { 525 filePath := flag.String("f", "units.json", "Path to the JSON database file") 526 port := flag.Int("p", 8080, "Port to listen on") 527 flag.Parse() 528 529 dbPath = *filePath 530 if err := loadDB(); err != nil { 531 log.Fatalf("Failed to load database: %v", err) 532 } 533 sortDayLogs() 534 535 mux := http.NewServeMux() 536 mux.Handle("/static/", http.FileServer(http.FS(staticFiles))) 537 mux.HandleFunc("/", handleIndex) 538 mux.HandleFunc("/summary", handleSummary) 539 mux.HandleFunc("/tiles/days", handleTilesDays) 540 mux.HandleFunc("/tiles/month", handleTilesMonth) 541 mux.HandleFunc("/modal", handleModal) 542 mux.HandleFunc("/drink/add", handleAddDrink) 543 mux.HandleFunc("/drink/remove", handleRemoveDrink) 544 mux.HandleFunc("/drink/adjust", handleAdjustDrink) 545 546 addr := fmt.Sprintf(":%d", *port) 547 log.Printf("Unit Tracker running at http://localhost%s", addr) 548 log.Printf("Database: %s", dbPath) 549 if err := http.ListenAndServe(addr, mux); err != nil { 550 log.Fatalf("Server error: %v", err) 551 } 552 }