limoncello

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

commit d6e79aa4dd5342c340b00a1ee320e5b8236278b2
parent faf78998b36b1cf79dce6d7afbdbd7a043bae71c
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Thu,  2 Jul 2026 14:58:04 +0100

Initial commit

Diffstat:
Ago.mod | 3+++
Amain.go | 493+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/app.js | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/index.html | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/style.css | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1035 insertions(+), 0 deletions(-)

diff --git a/go.mod b/go.mod @@ -0,0 +1,3 @@ +module limoncello + +go 1.22.2 diff --git a/main.go b/main.go @@ -0,0 +1,493 @@ +package main + +import ( + "embed" + "encoding/json" + "flag" + "fmt" + "log" + "math" + "net/http" + "os" + "sort" + "strconv" + "strings" + "time" +) + +//go:embed static +var staticFiles embed.FS + +var volumes = []int{25, 125, 330, 440, 500, 568, 660, 750} + +func calcUnits(ml int, abv float64) float64 { + return float64(ml) * abv / 1000.0 +} + +// Data structures + +// LoggedDrink stores enough to recalculate units at any time. +type LoggedDrink struct { + VolumeMl int `json:"volume_ml"` + ABV float64 `json:"abv"` + Count int `json:"count"` +} + +func (d LoggedDrink) Units() float64 { return calcUnits(d.VolumeMl, d.ABV) * float64(d.Count) } +func (d LoggedDrink) UnitEach() float64 { return calcUnits(d.VolumeMl, d.ABV) } +func (d LoggedDrink) Key() string { return fmt.Sprintf("%d@%.2f", d.VolumeMl, d.ABV) } +func (d LoggedDrink) Label() string { return fmt.Sprintf("%dml @ %.1f%%", d.VolumeMl, d.ABV) } + +type DayLog struct { + Date string `json:"date"` // YYYY-MM-DD + Drinks []LoggedDrink `json:"drinks"` +} + +// Database now only holds day logs — no drink type catalogue. +type Database struct { + DayLogs []DayLog `json:"day_logs"` +} + +var ( + dbPath string + db Database +) + +// Persistence + +func loadDB() error { + data, err := os.ReadFile(dbPath) + if err != nil { + if os.IsNotExist(err) { + db = Database{DayLogs: []DayLog{}} + return saveDB() + } + return err + } + return json.Unmarshal(data, &db) +} + +func saveDB() error { + data, err := json.MarshalIndent(db, "", " ") + if err != nil { + return err + } + return os.WriteFile(dbPath, data, 0644) +} + +// Day log helpers + +func totalUnits(day DayLog) float64 { + var t float64 + for _, d := range day.Drinks { + t += d.Units() + } + return t +} + +func findDayLog(date string) *DayLog { + for i := range db.DayLogs { + if db.DayLogs[i].Date == date { + return &db.DayLogs[i] + } + } + return nil +} + +func ensureDayLog(date string) *DayLog { + for i := range db.DayLogs { + if db.DayLogs[i].Date == date { + return &db.DayLogs[i] + } + } + db.DayLogs = append(db.DayLogs, DayLog{Date: date}) + return &db.DayLogs[len(db.DayLogs)-1] +} + +func dayUnits(date string) float64 { + dl := findDayLog(date) + if dl == nil { + return 0 + } + return totalUnits(*dl) +} + +func sortDayLogs() { + sort.Slice(db.DayLogs, func(i, j int) bool { + return db.DayLogs[i].Date < db.DayLogs[j].Date + }) +} + +// Tile rendering + +func dateColorClass(units float64, date string) string { + today := time.Now().Format("2006-01-02") + if date < today && units == 0 { + return "empty-past" + } + switch { + case units == 0: + return "zero" + case units < 2: + return "blue" + case units < 4: + return "green" + case units < 8: + return "yellow" + case units < 14: + return "orange" + case units < 20: + return "red" + case units < 30: + return "purple" + default: + return "black" + } +} + +func formatUnits(u float64) string { + if u == math.Trunc(u) { + return fmt.Sprintf("%.0f", u) + } + return fmt.Sprintf("%.1f", u) +} + +func renderTile(date, label string, units float64) string { + cls := dateColorClass(units, date) + todayCls := "" + if date == time.Now().Format("2006-01-02") { + todayCls = " today" + } + unitsSpan := "" + if units > 0 { + unitsSpan = fmt.Sprintf(`<span class="units">%s u</span>`, formatUnits(units)) + } + return fmt.Sprintf( + `<div class="tile %s%s" onclick="openDay('%s')" title="%s"><span class="date-label">%s</span>%s</div>`, + cls, todayCls, date, date, label, unitsSpan, + ) +} + +// Views + +func renderDaysRow(offset int) string { + today, _ := time.Parse("2006-01-02", time.Now().Format("2006-01-02")) + var b strings.Builder + start := today.AddDate(0, 0, -offset-2) + for i := 0; i < 5; i++ { + d := start.AddDate(0, 0, i) + ds := d.Format("2006-01-02") + b.WriteString(renderTile(ds, d.Format("Mon 2"), dayUnits(ds))) + } + return b.String() +} + +func renderWeekRow(weekOffset int) string { + now := time.Now() + wd := int(now.Weekday()) + if wd == 0 { + wd = 7 + } + monday := now.AddDate(0, 0, -(wd-1)-(weekOffset*7)) + var b strings.Builder + for i := 0; i < 7; i++ { + d := monday.AddDate(0, 0, i) + ds := d.Format("2006-01-02") + b.WriteString(renderTile(ds, d.Format("Mon 2"), dayUnits(ds))) + } + return b.String() +} + +func renderMonthGrid(monthOffset int) string { + now := time.Now() + first := time.Date(now.Year(), now.Month()-time.Month(monthOffset), 1, 0, 0, 0, 0, time.Local) + last := first.AddDate(0, 1, -1) + + var b strings.Builder + b.WriteString(`<div class="cal-grid">`) + for _, h := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} { + b.WriteString(fmt.Sprintf(`<div class="cal-header">%s</div>`, h)) + } + startWd := int(first.Weekday()) + if startWd == 0 { + startWd = 7 + } + for i := 1; i < startWd; i++ { + b.WriteString(`<div class="cal-empty"></div>`) + } + for d := first; !d.After(last); d = d.AddDate(0, 0, 1) { + ds := d.Format("2006-01-02") + b.WriteString(renderTile(ds, d.Format("2"), dayUnits(ds))) + } + b.WriteString(`</div>`) + return b.String() +} + +func weekLabel(offset int) string { + now := time.Now() + wd := int(now.Weekday()) + if wd == 0 { + wd = 7 + } + monday := now.AddDate(0, 0, -(wd-1)-(offset*7)) + sunday := monday.AddDate(0, 0, 6) + return monday.Format("2 Jan") + " – " + sunday.Format("2 Jan") +} + +func monthLabel(offset int) string { + now := time.Now() + return time.Date(now.Year(), now.Month()-time.Month(offset), 1, 0, 0, 0, 0, time.Local).Format("January 2006") +} + +// Modal + +func renderModal(date string) string { + dl := findDayLog(date) + t, _ := time.Parse("2006-01-02", date) + + // Logged drinks table + var loggedHTML strings.Builder + if dl != nil && len(dl.Drinks) > 0 { + loggedHTML.WriteString(`<table class="drink-table"> +<tr><th>Drink</th><th>×</th><th>Units</th><th></th></tr>`) + for _, drink := range dl.Drinks { + key := drink.Key() + loggedHTML.WriteString(fmt.Sprintf(` +<tr> + <td>%s</td> + <td>%d</td> + <td>%.2f</td> + <td class="actions"> + <button class="btn-sm btn-minus" onclick="adjustDrink('%s','%s',-1)">−</button> + <button class="btn-sm btn-plus" onclick="adjustDrink('%s','%s', 1)">+</button> + <button class="btn-sm btn-del" onclick="removeDrink('%s','%s')">✕</button> + </td> +</tr>`, + drink.Label(), drink.Count, drink.Units(), + date, key, + date, key, + date, key, + )) + } + loggedHTML.WriteString(`</table>`) + } else { + loggedHTML.WriteString(`<p class="no-drinks">No drinks logged.</p>`) + } + + // Volume options + var volOpts strings.Builder + for _, v := range volumes { + volOpts.WriteString(fmt.Sprintf(`<option value="%d">%dml</option>`, v, v)) + } + + totalU := 0.0 + if dl != nil { + totalU = totalUnits(*dl) + } + + // Use concatenation — never fmt.Sprintf — so that % signs inside + // loggedHTML (e.g. "5.0%" in drink labels) can't corrupt the output. + return `<div class="modal-overlay" id="day-modal" onclick="closeModal(event)">` + + `<div class="modal-box">` + + `<h2>` + t.Format("Monday 2 January 2006") + `</h2>` + + `<p class="total-units">Total: <strong>` + formatUnits(totalU) + ` units</strong></p>` + + `<div id="logged-drinks">` + loggedHTML.String() + `</div>` + + `<div class="add-drink-form">` + + `<h3>Add a drink</h3>` + + `<div class="add-drink-row">` + + `<div class="add-field"><label>Volume</label><select id="drink-volume">` + volOpts.String() + `</select></div>` + + `<div class="add-field"><label>ABV %</label>` + + `<input id="drink-abv" type="number" min="0.1" max="99" step="0.1" value="5.0" placeholder="e.g. 13.5"></div>` + + `<button class="btn-add" onclick="addDrink('` + date + `')">Add</button>` + + `</div>` + + `<p class="abv-preview" id="abv-preview"></p>` + + `</div>` + + `<button class="btn-close" onclick="document.getElementById('day-modal').remove()">Close</button>` + + `</div></div>` +} + +// HTTP + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + tmpl, err := staticFiles.ReadFile("static/index.html") + if err != nil { + http.Error(w, "template not found", 500) + return + } + page := string(tmpl) + page = strings.ReplaceAll(page, "{{DAYS_TILES}}", renderDaysRow(0)) + page = strings.ReplaceAll(page, "{{WEEK_TILES}}", renderWeekRow(0)) + page = strings.ReplaceAll(page, "{{MONTH_GRID}}", renderMonthGrid(0)) + page = strings.ReplaceAll(page, "{{WEEK_LABEL}}", weekLabel(0)) + page = strings.ReplaceAll(page, "{{MONTH_LABEL}}", monthLabel(0)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, page) +} + +func handleTilesDays(w http.ResponseWriter, r *http.Request) { + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderDaysRow(offset)) +} + +func handleTilesWeek(w http.ResponseWriter, r *http.Request) { + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderWeekRow(offset)) +} + +func handleTilesMonth(w http.ResponseWriter, r *http.Request) { + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderMonthGrid(offset)) +} + +func handleLabelWeek(w http.ResponseWriter, r *http.Request) { + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + fmt.Fprint(w, weekLabel(offset)) +} + +func handleLabelMonth(w http.ResponseWriter, r *http.Request) { + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + fmt.Fprint(w, monthLabel(offset)) +} + +func handleModal(w http.ResponseWriter, r *http.Request) { + date := r.URL.Query().Get("date") + if date == "" { + http.Error(w, "missing date", 400) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderModal(date)) +} + +// parseKey splits a key like "330@5.00" into (330, 5.0). +func parseKey(key string) (int, float64, bool) { + parts := strings.SplitN(key, "@", 2) + if len(parts) != 2 { + return 0, 0, false + } + ml, err1 := strconv.Atoi(parts[0]) + abv, err2 := strconv.ParseFloat(parts[1], 64) + return ml, abv, err1 == nil && err2 == nil +} + +func handleAddDrink(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + date := r.FormValue("date") + ml, err1 := strconv.Atoi(r.FormValue("volume_ml")) + abv, err2 := strconv.ParseFloat(r.FormValue("abv"), 64) + if err1 != nil || err2 != nil || ml <= 0 || abv <= 0 { + http.Error(w, "invalid volume or abv", 400) + return + } + + day := ensureDayLog(date) + // Match on volume+abv — increment count if already present + for i := range day.Drinks { + if day.Drinks[i].VolumeMl == ml && day.Drinks[i].ABV == abv { + day.Drinks[i].Count++ + saveDB() + w.WriteHeader(200) + return + } + } + day.Drinks = append(day.Drinks, LoggedDrink{VolumeMl: ml, ABV: abv, Count: 1}) + saveDB() + w.WriteHeader(200) +} + +func handleRemoveDrink(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + date := r.FormValue("date") + ml, abv, ok := parseKey(r.FormValue("key")) + if !ok { + http.Error(w, "invalid key", 400) + return + } + + day := findDayLog(date) + if day == nil { + w.WriteHeader(200) + return + } + kept := day.Drinks[:0] + for _, d := range day.Drinks { + if d.VolumeMl != ml || d.ABV != abv { + kept = append(kept, d) + } + } + day.Drinks = kept + saveDB() + w.WriteHeader(200) +} + +func handleAdjustDrink(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + date := r.FormValue("date") + ml, abv, ok := parseKey(r.FormValue("key")) + delta, _ := strconv.Atoi(r.FormValue("delta")) + if !ok { + http.Error(w, "invalid key", 400) + return + } + + day := findDayLog(date) + if day == nil { + w.WriteHeader(200) + return + } + var kept []LoggedDrink + for _, d := range day.Drinks { + if d.VolumeMl == ml && d.ABV == abv { + d.Count += delta + if d.Count > 0 { + kept = append(kept, d) + } + } else { + kept = append(kept, d) + } + } + day.Drinks = kept + saveDB() + w.WriteHeader(200) +} + +// Entry point + +func main() { + filePath := flag.String("f", "units.json", "Path to the JSON database file") + port := flag.Int("p", 8080, "Port to listen on") + flag.Parse() + + dbPath = *filePath + if err := loadDB(); err != nil { + log.Fatalf("Failed to load database: %v", err) + } + sortDayLogs() + + mux := http.NewServeMux() + mux.Handle("/static/", http.FileServer(http.FS(staticFiles))) + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/tiles/days", handleTilesDays) + mux.HandleFunc("/tiles/week", handleTilesWeek) + mux.HandleFunc("/tiles/month", handleTilesMonth) + mux.HandleFunc("/label/week", handleLabelWeek) + mux.HandleFunc("/label/month", handleLabelMonth) + mux.HandleFunc("/modal", handleModal) + mux.HandleFunc("/drink/add", handleAddDrink) + mux.HandleFunc("/drink/remove", handleRemoveDrink) + mux.HandleFunc("/drink/adjust", handleAdjustDrink) + + addr := fmt.Sprintf(":%d", *port) + log.Printf("Unit Tracker running at http://localhost%s", addr) + log.Printf("Database: %s", dbPath) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("Server error: %v", err) + } +} diff --git a/static/app.js b/static/app.js @@ -0,0 +1,162 @@ +'use strict'; + +let daysOffset = 0; +let weekOffset = 0; +let monthOffset = 0; + +// Labels + +function updateDaysLabel() { + const el = document.getElementById('days-offset-label'); + const fwd = document.getElementById('days-forward-btn'); + el.textContent = daysOffset === 0 ? 'Today ±2' : daysOffset + 'd back'; + fwd.disabled = daysOffset <= 0; +} + +function updateWeekLabel() { + const el = document.getElementById('week-offset-label'); + const fwd = document.getElementById('week-forward-btn'); + if (weekOffset === 0) el.textContent = 'This week'; + else if (weekOffset === 1) el.textContent = 'Last week'; + else el.textContent = weekOffset + ' weeks ago'; + fwd.disabled = weekOffset <= 0; +} + +function updateMonthLabel() { + const el = document.getElementById('month-offset-label'); + const fwd = document.getElementById('month-forward-btn'); + if (monthOffset === 0) el.textContent = 'This month'; + else if (monthOffset === 1) el.textContent = 'Last month'; + else el.textContent = monthOffset + ' months ago'; + fwd.disabled = monthOffset <= 0; +} + +// Tiles + +async function fetchHTML(url, containerId) { + const res = await fetch(url); + const html = await res.text(); + document.getElementById(containerId).innerHTML = html; +} + +async function refreshAllTiles() { + await Promise.all([ + fetchHTML('/tiles/days?offset=' + daysOffset, 'days-row'), + fetchHTML('/tiles/week?offset=' + weekOffset, 'week-row'), + fetchHTML('/tiles/month?offset=' + monthOffset, 'month-grid'), + ]); +} + +// Navigation + +async function shiftDays(dir) { + const next = daysOffset + dir; + if (next < 0) return; + daysOffset = next; + updateDaysLabel(); + await fetchHTML('/tiles/days?offset=' + daysOffset, 'days-row'); +} + +async function shiftWeek(dir) { + const next = weekOffset + dir; + if (next < 0) return; + weekOffset = next; + updateWeekLabel(); + await fetchHTML('/tiles/week?offset=' + weekOffset, 'week-row'); + document.getElementById('week-label').textContent = + await (await fetch('/label/week?offset=' + weekOffset)).text(); +} + +async function shiftMonth(dir) { + const next = monthOffset + dir; + if (next < 0) return; + monthOffset = next; + updateMonthLabel(); + await fetchHTML('/tiles/month?offset=' + monthOffset, 'month-grid'); + document.getElementById('month-label').textContent = + await (await fetch('/label/month?offset=' + monthOffset)).text(); +} + +// Days + +function openDay(date) { + fetch('/modal?date=' + date) + .then(r => r.text()) + .then(html => { + document.body.insertAdjacentHTML('beforeend', html); + updatePreview(); + document.getElementById('drink-volume').addEventListener('change', updatePreview); + document.getElementById('drink-abv').addEventListener('input', updatePreview); + }); +} + +function closeModal(e) { + if (e.target.id === 'day-modal') { + document.getElementById('day-modal').remove(); + } +} + +function refreshModal(date) { + const modal = document.getElementById('day-modal'); + if (!modal) return; + fetch('/modal?date=' + date) + .then(r => r.text()) + .then(html => { + modal.remove(); + document.body.insertAdjacentHTML('beforeend', html); + updatePreview(); + document.getElementById('drink-volume').addEventListener('change', updatePreview); + document.getElementById('drink-abv').addEventListener('input', updatePreview); + }); +} + +function updatePreview() { + const volEl = document.getElementById('drink-volume'); + const abvEl = document.getElementById('drink-abv'); + const pre = document.getElementById('abv-preview'); + if (!volEl || !abvEl || !pre) return; + const ml = parseInt(volEl.value, 10); + const abv = parseFloat(abvEl.value); + if (ml > 0 && abv > 0) { + const u = (ml * abv / 1000).toFixed(2); + pre.textContent = `= ${u} unit${u === '1.00' ? '' : 's'}`; + } else { + pre.textContent = ''; + } +} + +// Drinks + +function post(url, params) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params).toString(), + }); +} + +async function addDrink(date) { + const ml = document.getElementById('drink-volume').value; + const abv = document.getElementById('drink-abv').value; + if (!ml || !abv || parseFloat(abv) <= 0) return; + await post('/drink/add', { date, volume_ml: ml, abv }); + refreshModal(date); + refreshAllTiles(); +} + +async function removeDrink(date, key) { + await post('/drink/remove', { date, key }); + refreshModal(date); + refreshAllTiles(); +} + +async function adjustDrink(date, key, delta) { + await post('/drink/adjust', { date, key, delta }); + refreshModal(date); + refreshAllTiles(); +} + +// Init +updateDaysLabel(); +updateWeekLabel(); +updateMonthLabel(); diff --git a/static/index.html b/static/index.html @@ -0,0 +1,70 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Unit Tracker</title> + <link rel="stylesheet" href="/static/style.css"> +</head> +<body> + +<div class="legend"> + <div class="legend-item"><div class="legend-dot" style="background:#1e1e2e;border:1px solid #333"></div>0</div> + <div class="legend-item"><div class="legend-dot" style="background:#1a4a7a"></div>&lt;2</div> + <div class="legend-item"><div class="legend-dot" style="background:#1a5c2a"></div>&lt;4</div> + <div class="legend-item"><div class="legend-dot" style="background:#5c4a00"></div>&lt;8</div> + <div class="legend-item"><div class="legend-dot" style="background:#6b2e00"></div>&lt;14</div> + <div class="legend-item"><div class="legend-dot" style="background:#6b0a0a"></div>&lt;20</div> + <div class="legend-item"><div class="legend-dot" style="background:#3d0a6b"></div>&lt;30</div> + <div class="legend-item"><div class="legend-dot" style="background:#0a0a0a;border:1px solid #333"></div>30+</div> + <div class="legend-item"> + <div class="legend-dot" style="background:transparent;border:1.5px solid #555;box-shadow:0 0 5px rgba(150,150,255,0.4)"></div> + Past/empty + </div> +</div> + +<div class="section"> + <div class="section-label"> + <span>Recent days</span> + <div class="controls"> + <button class="btn-nav" onclick="shiftDays(1)">◀ Earlier</button> + <span class="offset-display" id="days-offset-label">Today ±2</span> + <button class="btn-nav" id="days-forward-btn" onclick="shiftDays(-1)">Later ▶</button> + </div> + </div> + <div class="tiles-row" id="days-row">{{DAYS_TILES}}</div> +</div> + +<details> + <summary> + Week view + <span class="panel-sublabel" id="week-label">{{WEEK_LABEL}}</span> + </summary> + <div class="details-inner"> + <div class="controls" style="margin-bottom:0.75rem"> + <button class="btn-nav" onclick="shiftWeek(1)">◀ Prev week</button> + <span class="offset-display" id="week-offset-label">This week</span> + <button class="btn-nav" id="week-forward-btn" onclick="shiftWeek(-1)">Next week ▶</button> + </div> + <div class="tiles-row" id="week-row">{{WEEK_TILES}}</div> + </div> +</details> + +<details> + <summary> + Month view + <span class="panel-sublabel" id="month-label">{{MONTH_LABEL}}</span> + </summary> + <div class="details-inner"> + <div class="controls" style="margin-bottom:0.75rem"> + <button class="btn-nav" onclick="shiftMonth(1)">◀ Prev month</button> + <span class="offset-display" id="month-offset-label">This month</span> + <button class="btn-nav" id="month-forward-btn" onclick="shiftMonth(-1)">Next month ▶</button> + </div> + <div id="month-grid">{{MONTH_GRID}}</div> + </div> +</details> + +<script src="/static/app.js"></script> +</body> +</html> diff --git a/static/style.css b/static/style.css @@ -0,0 +1,307 @@ +:root { + --bg: #0f0f13; + --surface: #1a1a24; + --surface2: #22222f; + --text: #e8e8f0; + --text-dim: #888899; + --accent: #6c8ebf; + --radius: 10px; + /* Fluid tile */ + --tile-size: clamp(52px, calc((100vw - 2rem - 40px) / 5), 100px); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, sans-serif; + min-height: 100vh; + padding: 1.5rem 1rem 3rem; +} + +h2 { font-size: 1.1rem; margin-bottom: 0.5rem; } + +/* Tiles */ + +.tile { + width: var(--tile-size); + height: var(--tile-size); + border-radius: var(--radius); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + user-select: none; + transition: transform 0.15s, box-shadow 0.15s; + font-size: clamp(0.6rem, 1.5vw, 0.78rem); + font-weight: 600; + position: relative; + flex-shrink: 0; +} + +.tile:hover { transform: scale(1.07); } +.date-label { font-size: clamp(0.55rem, 1.4vw, 0.72rem); opacity: 0.85; } +.units { font-size: clamp(0.75rem, 2vw, 1.05rem); font-weight: 700; margin-top: 3px; } +.tile.today { outline: 2px solid white; outline-offset: 2px; } + +/* Colour classes */ +.tile.zero { background: #1e1e2e; color: #aaa; } +.tile.empty-past { background: transparent; color: #666; border: 1.5px solid #555; box-shadow: 0 0 8px 1px rgba(150,150,255,0.18); } +.tile.blue { background: #1a4a7a; color: #9fd3ff; } +.tile.green { background: #1a5c2a; color: #8eff9e; } +.tile.yellow { background: #5c4a00; color: #ffe066; } +.tile.orange { background: #6b2e00; color: #ffb347; } +.tile.red { background: #6b0a0a; color: #ff8080; } +.tile.purple { background: #3d0a6b; color: #d18fff; } +.tile.black { background: #0a0a0a; color: #666; border: 1px solid #333; } + +/* Layouts */ + +.section { margin-bottom: 1.5rem; } + +.section-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.tiles-row { + display: flex; + gap: 10px; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 4px; +} + +.tiles-row::-webkit-scrollbar { height: 4px; } +.tiles-row::-webkit-scrollbar-track { background: transparent; } +.tiles-row::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; } + +/* Navigation */ + +.controls { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } + +.btn-nav { + background: var(--surface2); + border: 1px solid #333; + color: var(--text); + border-radius: 6px; + padding: 3px 10px; + cursor: pointer; + font-size: 0.8rem; + transition: background 0.15s; + white-space: nowrap; +} +.btn-nav:hover { background: #333; } +.btn-nav:disabled { opacity: 0.4; cursor: default; } + +.offset-display { + font-size: 0.75rem; + color: var(--text-dim); + min-width: 60px; + text-align: center; +} + +/* Detail Summary */ + +details { + background: var(--surface); + border-radius: var(--radius); + margin-bottom: 1rem; +} + +details > summary { + padding: 0.75rem 1rem; + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + list-style: none; + display: flex; + justify-content: space-between; + align-items: center; + color: var(--accent); +} + +details > summary::after { content: "▸"; transition: transform 0.2s; } +details[open] > summary::after { transform: rotate(90deg); } +details > .details-inner { padding: 0.75rem 1rem 1rem; overflow-x: auto; } + +.panel-sublabel { + font-size: 0.75rem; + color: #666; + font-weight: 400; + margin-left: auto; + margin-right: 1rem; +} + +/* Calendar */ + +.cal-grid { + display: grid; + grid-template-columns: repeat(7, var(--cal-tile)); + gap: 6px; + --cal-tile: clamp(38px, calc((100vw - 2rem - 48px) / 7), 100px); +} + +.cal-header { + text-align: center; + font-size: clamp(0.55rem, 1.3vw, 0.68rem); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + padding-bottom: 4px; +} + +.cal-grid .tile { + width: var(--cal-tile); + height: var(--cal-tile); + font-size: clamp(0.5rem, 1.3vw, 0.72rem); +} + +.cal-grid .tile .date-label { font-size: clamp(0.5rem, 1.2vw, 0.65rem); } +.cal-grid .tile .units { font-size: clamp(0.6rem, 1.5vw, 0.85rem); } +.cal-empty { width: var(--cal-tile); } + +/* Modal */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 1rem; +} + +.modal-box { + background: var(--surface); + border-radius: 14px; + padding: 1.5rem; + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 40px rgba(0,0,0,0.6); +} + +.modal-box h2 { margin-bottom: 0.3rem; font-size: 1.1rem; } +.total-units { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem; } +.total-units strong { color: var(--accent); } + +.drink-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-bottom: 1rem; } +.drink-table th, +.drink-table td { padding: 6px 8px; text-align: left; border-bottom: 1px solid #2a2a3a; } +.drink-table th { color: var(--text-dim); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; } + +.actions { display: flex; gap: 5px; } + +.btn-sm { + border: none; + border-radius: 5px; + padding: 4px 9px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 700; + line-height: 1; +} +.btn-minus { background: #2a2a3a; color: #aaa; } +.btn-plus { background: #1a4a2a; color: #8eff9e; } +.btn-del { background: #4a1a1a; color: #ff8080; } +.btn-sm:hover { opacity: 0.85; } + +/* Add drink form */ +.add-drink-form { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #2a2a3a; +} + +.add-drink-form h3 { font-size: 0.85rem; color: var(--text-dim); margin-bottom: 0.6rem; } + +.add-drink-row { + display: flex; + gap: 8px; + align-items: flex-end; + flex-wrap: wrap; +} + +.add-field { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 90px; +} + +.add-field label { + font-size: 0.72rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.add-field select, +.add-field input { + background: var(--surface2); + color: var(--text); + border: 1px solid #333; + border-radius: 6px; + padding: 6px 10px; + font-size: 0.9rem; + width: 100%; +} + +.add-field input[type=number] { -moz-appearance: textfield; } +.add-field input::-webkit-inner-spin-button { display: none; } + +.abv-preview { + margin-top: 0.5rem; + font-size: 0.8rem; + color: var(--accent); + min-height: 1.2em; +} + +.btn-add { + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + align-self: flex-end; +} +.btn-add:hover { opacity: 0.9; } + +.btn-close { + margin-top: 1rem; + width: 100%; + background: #2a2a3a; + color: var(--text-dim); + border: none; + border-radius: 6px; + padding: 8px; + cursor: pointer; + font-size: 0.85rem; +} +.btn-close:hover { background: #333; } + +.no-drinks { color: var(--text-dim); font-size: 0.85rem; margin-bottom: 0.5rem; } + +/* Legend */ + +.legend { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1.5rem; } +.legend-item { display: flex; align-items: center; gap: 5px; font-size: 0.72rem; color: var(--text-dim); } +.legend-dot { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }