limoncello

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

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 }