limoncello

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

commit 11140b15743e778902d84ad85fb655ae7bcd1f85
parent f1d214af9616b5ad008a8b5caa71910d36193aa4
Author: Peter <peter@minskio.co.uk>
Date:   Mon,  5 Oct 2026 09:24:16 +0100

Replace 5 day summary with week/month summary

Diffstat:
Mmain.go | 139++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mstatic/app.js | 35++++++++++++++---------------------
Mstatic/index.html | 19++++++++++---------
Mstatic/style.css | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 194 insertions(+), 55 deletions(-)

diff --git a/main.go b/main.go @@ -202,32 +202,122 @@ func formatUnits(u float64) string { 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" +// Week/Month period summaries + +func ordinal(n int) string { + if n%100 >= 11 && n%100 <= 13 { + return fmt.Sprintf("%dth", n) } - unitsSpan := "" - if units > 0 { - unitsSpan = fmt.Sprintf(`<span class="units">%s u</span>`, formatUnits(units)) + switch n % 10 { + case 1: + return fmt.Sprintf("%dst", n) + case 2: + return fmt.Sprintf("%dnd", n) + case 3: + return fmt.Sprintf("%drd", n) } - 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, - ) + return fmt.Sprintf("%dth", n) } -// Views +func startOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} -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) +// weekStart returns the Monday of the week containing t. +func weekStart(t time.Time) time.Time { + t = startOfDay(t) + wd := int(t.Weekday()) + if wd == 0 { + wd = 7 + } + return t.AddDate(0, 0, -(wd - 1)) +} + +// earliestLoggedDay returns the earliest date with at least one drink, or +// today if nothing has been logged. +func earliestLoggedDay(today time.Time) time.Time { + earliest := today + for _, dl := range db.DayLogs { + if len(dl.Drinks) == 0 { + continue + } + t, err := time.ParseInLocation("2006-01-02", dl.Date, today.Location()) + if err == nil && t.Before(earliest) { + earliest = t + } + } + return earliest +} + +// periodStats totals drinks/units and drink-free days over range +func periodStats(start, end, today time.Time) (free, days, drinks int, units float64) { + todayStr := today.Format("2006-01-02") + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + days++ ds := d.Format("2006-01-02") - b.WriteString(renderTile(ds, d.Format("Mon 2"), dayUnits(ds))) + n := 0 + if dl := findDayLog(ds); dl != nil { + for _, dr := range dl.Drinks { + n += dr.Count + units += dr.Units() + } + } + drinks += n + if n == 0 && ds < todayStr { + free++ + } + } + return +} + +func renderPeriodRow(label string, current bool, free, days, drinks int, units float64) string { + cls := "period-row" + if current { + cls += " current" + } + return `<div class="` + cls + `">` + + `<span class="period-label">` + label + `</span>` + + `<span class="period-stat"><strong>` + strconv.Itoa(free) + `/` + strconv.Itoa(days) + `</strong><small>drink-free</small></span>` + + `<span class="period-stat"><strong>` + strconv.Itoa(drinks) + `</strong><small>drinks</small></span>` + + `<span class="period-stat"><strong>` + formatUnits(units) + `</strong><small>units</small></span>` + + `</div>` +} + +func renderPeriods(mode string) string { + today := startOfDay(time.Now()) + earliest := earliestLoggedDay(today) + thisYear := today.Year() + var b strings.Builder + + if mode == "months" { + cur := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()) + stop := time.Date(earliest.Year(), earliest.Month(), 1, 0, 0, 0, 0, today.Location()) + i := 0 + for m := cur; i < 600 && (i < 4 || !m.Before(stop)); m = m.AddDate(0, -1, 0) { + end := m.AddDate(0, 1, -1) + free, days, drinks, units := periodStats(m, end, today) + label := m.Format("January") + if m.Year() != thisYear { + label = m.Format("January 2006") + } + b.WriteString(renderPeriodRow(label, i == 0, free, days, drinks, units)) + i++ + } + return b.String() + } + + cur := weekStart(today) + stop := weekStart(earliest) + i := 0 + for w := cur; i < 2600 && (i < 4 || !w.Before(stop)); w = w.AddDate(0, 0, -7) { + end := w.AddDate(0, 0, 6) + free, days, drinks, units := periodStats(w, end, today) + label := ordinal(w.Day()) + " " + w.Format("January") + if w.Year() != thisYear { + label += " " + w.Format("2006") + } + b.WriteString(renderPeriodRow(label, i == 0, free, days, drinks, units)) + i++ } return b.String() } @@ -383,7 +473,7 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { } page := string(tmpl) page = strings.ReplaceAll(page, "{{SUMMARY}}", renderSummary()) - page = strings.ReplaceAll(page, "{{DAYS_TILES}}", renderDaysRow(0)) + page = strings.ReplaceAll(page, "{{PERIODS}}", renderPeriods("weeks")) page = strings.ReplaceAll(page, "{{MONTH_LABEL}}", monthLabel(0)) page = strings.ReplaceAll(page, "{{MONTH_GRID}}", renderMonthGrid(0)) w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -396,10 +486,9 @@ func handleSummary(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, renderSummary()) } -func handleTilesDays(w http.ResponseWriter, r *http.Request) { - offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) +func handlePeriods(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderDaysRow(offset)) + fmt.Fprint(w, renderPeriods(r.URL.Query().Get("mode"))) } func handleTilesMonth(w http.ResponseWriter, r *http.Request) { @@ -536,7 +625,7 @@ func main() { mux.Handle("/static/", http.FileServer(http.FS(staticFiles))) mux.HandleFunc("/", handleIndex) mux.HandleFunc("/summary", handleSummary) - mux.HandleFunc("/tiles/days", handleTilesDays) + mux.HandleFunc("/summary/periods", handlePeriods) mux.HandleFunc("/tiles/month", handleTilesMonth) mux.HandleFunc("/modal", handleModal) mux.HandleFunc("/drink/add", handleAddDrink) diff --git a/static/app.js b/static/app.js @@ -1,17 +1,23 @@ 'use strict'; -let daysOffset = 0; +// Period summary (weeks / months) +let periodMode = 'weeks'; // Month (calendar) view let monthOffset = 0; // 0 = current month, -1 = previous month, 1 = next month, ... -// Labels +// Period summary -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; +async function loadPeriods() { + await fetchHTML('/summary/periods?mode=' + periodMode, 'period-list'); +} + +async function setPeriodMode(mode) { + periodMode = mode; + document.getElementById('mode-weeks').classList.toggle('active', mode === 'weeks'); + document.getElementById('mode-months').classList.toggle('active', mode === 'months'); + await loadPeriods(); + document.getElementById('period-list').scrollTop = 0; } // Tiles @@ -24,22 +30,12 @@ async function fetchHTML(url, containerId) { async function refreshAllTiles() { await Promise.all([ - fetchHTML('/tiles/days?offset=' + daysOffset, 'days-row'), + loadPeriods(), loadMonth(), fetchHTML('/summary', 'summary'), ]); } -// Navigation - -async function shiftDays(dir) { - const next = daysOffset + dir; - if (next < 0) return; - daysOffset = next; - updateDaysLabel(); - await fetchHTML('/tiles/days?offset=' + daysOffset, 'days-row'); -} - // Month (calendar) // Fetches the full calendar grid + label for the currently selected @@ -135,6 +131,3 @@ async function adjustDrink(date, key, delta) { refreshModal(date); refreshAllTiles(); } - -// Init -updateDaysLabel(); diff --git a/static/index.html b/static/index.html @@ -21,17 +21,18 @@ <div class="legend-item"><div class="legend-dot" style="background:#0a0a0a;border:1px solid #333"></div>30+</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> +<details open> + <summary> + Summary + </summary> + <div class="details-inner"> + <div class="period-toggle"> + <button class="btn-nav active" id="mode-weeks" onclick="setPeriodMode('weeks')">Weeks</button> + <button class="btn-nav" id="mode-months" onclick="setPeriodMode('months')">Months</button> </div> + <div class="period-list" id="period-list">{{PERIODS}}</div> </div> - <div class="tiles-row" id="days-row">{{DAYS_TILES}}</div> -</div> +</details> <details open> <summary> diff --git a/static/style.css b/static/style.css @@ -134,6 +134,62 @@ 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; } +/* Period summary (weeks / months) */ + +.period-toggle { + display: flex; + gap: 6px; + margin-bottom: 0.75rem; +} + +.period-toggle .btn-nav { flex: 1; padding: 6px 10px; } +.period-toggle .btn-nav.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.period-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 320px; + overflow-y: auto; + padding-right: 4px; +} + +.period-list::-webkit-scrollbar { width: 4px; } +.period-list::-webkit-scrollbar-track { background: transparent; } +.period-list::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; } + +.period-row { + display: grid; + grid-template-columns: minmax(90px, 1.4fr) repeat(3, minmax(0, 1fr)); + align-items: center; + gap: 8px; + background: var(--surface2); + border-radius: 8px; + padding: 8px 10px; +} + +.period-row.current { outline: 1px solid var(--accent); } + +.period-label { font-size: 0.85rem; font-weight: 600; } + +.period-stat { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.2; +} +.period-stat strong { font-size: 0.95rem; color: var(--accent); } +.period-stat small { + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); +} + /* Month view (calendar grid) */ .month-nav {