limoncello

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | LICENSE

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 }