limoncello

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

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 }