commit d6e79aa4dd5342c340b00a1ee320e5b8236278b2
parent faf78998b36b1cf79dce6d7afbdbd7a043bae71c
Author: breadcat <breadcat@users.noreply.github.com>
Date: Thu, 2 Jul 2026 14:58:04 +0100
Initial commit
Diffstat:
| A | go.mod | | | 3 | +++ |
| A | main.go | | | 493 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | static/app.js | | | 162 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | static/index.html | | | 70 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | static/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><2</div>
+ <div class="legend-item"><div class="legend-dot" style="background:#1a5c2a"></div><4</div>
+ <div class="legend-item"><div class="legend-dot" style="background:#5c4a00"></div><8</div>
+ <div class="legend-item"><div class="legend-dot" style="background:#6b2e00"></div><14</div>
+ <div class="legend-item"><div class="legend-dot" style="background:#6b0a0a"></div><20</div>
+ <div class="legend-item"><div class="legend-dot" style="background:#3d0a6b"></div><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; }