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:
| M | main.go | | | 139 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- |
| M | static/app.js | | | 35 | ++++++++++++++--------------------- |
| M | static/index.html | | | 19 | ++++++++++--------- |
| M | static/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 {