commit 7f609f8ce63e8ab8c3c8e78609ca16e35c4e79bb
parent 8b1412f750acd2d91554c2f4d03b0ffbf0c5cc15
Author: breadcat <breadcat@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:39:28 +0100
Scrollable month view
Diffstat:
| M | main.go | | | 92 | +++++++++++++++++++++++++++++++++---------------------------------------------- |
| M | static/app.js | | | 90 | +++++++++++++++++++++++++++++++++++++++++++------------------------------------ |
| M | static/index.html | | | 26 | +++----------------------- |
| M | static/style.css | | | 49 | ++++++++++++++++++++++++++++++------------------- |
4 files changed, 120 insertions(+), 137 deletions(-)
diff --git a/main.go b/main.go
@@ -248,31 +248,6 @@ func renderWeekRow(weekOffset int) string {
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())
@@ -284,9 +259,31 @@ func weekLabel(offset int) string {
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")
+// renderWeekBlock renders a single labeled week row (7 tiles) for use in the
+// scrollable month view. weekOffset 0 = current week, 1 = last week, etc.
+func renderWeekBlock(weekOffset int) string {
+ return `<div class="week-block" data-week-offset="` + strconv.Itoa(weekOffset) + `">` +
+ `<div class="week-block-label">` + weekLabel(weekOffset) + `</div>` +
+ `<div class="month-week-row">` + renderWeekRow(weekOffset) + `</div>` +
+ `</div>`
+}
+
+// renderMonthWeeks renders `count` consecutive week-blocks, for offsets
+// [start, start+count-1], ordered top-to-bottom from oldest to newest so
+// that the most recent (smallest offset) week always ends up last/at the
+// bottom of the scrollable container.
+func renderMonthWeeks(start, count int) string {
+ if start < 0 {
+ start = 0
+ }
+ if count <= 0 {
+ count = 1
+ }
+ var b strings.Builder
+ for off := start + count - 1; off >= start; off-- {
+ b.WriteString(renderWeekBlock(off))
+ }
+ return b.String()
}
// Modal
@@ -371,10 +368,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, "{{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))
+ page = strings.ReplaceAll(page, "{{MONTH_WEEKS}}", renderMonthWeeks(0, monthBatchSize))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, page)
}
@@ -391,26 +385,19 @@ func handleTilesDays(w http.ResponseWriter, r *http.Request) {
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))
-}
+// monthBatchSize is how many week-rows are rendered per scroll batch in the
+// month view (both the initial server-rendered load and each subsequent
+// lazy-loaded chunk as the user scrolls up towards older weeks).
+const monthBatchSize = 6
-func handleTilesMonth(w http.ResponseWriter, r *http.Request) {
- offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+func handleTilesMonthWeeks(w http.ResponseWriter, r *http.Request) {
+ start, _ := strconv.Atoi(r.URL.Query().Get("start"))
+ count, err := strconv.Atoi(r.URL.Query().Get("count"))
+ if err != nil || count <= 0 {
+ count = monthBatchSize
+ }
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))
+ fmt.Fprint(w, renderMonthWeeks(start, count))
}
func handleModal(w http.ResponseWriter, r *http.Request) {
@@ -533,10 +520,7 @@ func main() {
mux.HandleFunc("/", handleIndex)
mux.HandleFunc("/summary", handleSummary)
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("/tiles/monthweeks", handleTilesMonthWeeks)
mux.HandleFunc("/modal", handleModal)
mux.HandleFunc("/drink/add", handleAddDrink)
mux.HandleFunc("/drink/remove", handleRemoveDrink)
diff --git a/static/app.js b/static/app.js
@@ -1,8 +1,12 @@
'use strict';
let daysOffset = 0;
-let weekOffset = 0;
-let monthOffset = 0;
+
+// Month (scrollable weeks) view
+const MONTH_BATCH = 6; // must match monthBatchSize in main.go
+let monthWeeksLoaded = MONTH_BATCH; // weeks already present server-side on load
+let monthScrollReady = false; // becomes true once the panel is first opened
+let monthLoadingMore = false;
// Labels
@@ -13,24 +17,6 @@ function updateDaysLabel() {
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) {
@@ -41,9 +27,8 @@ async function fetchHTML(url, containerId) {
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'),
+ fetchHTML('/tiles/days?offset=' + daysOffset, 'days-row'),
+ refreshMonthScroll(),
]);
}
@@ -57,24 +42,49 @@ async function shiftDays(dir) {
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();
+// Month (scrollable weeks)
+
+// Called once, the first time the "Month view" <details> panel is opened.
+// Waits until the panel is actually visible (so scrollHeight is meaningful),
+// then jumps the scroll position to the bottom so the current week is the
+// last row in view, and wires up infinite-scroll-upward loading.
+function handleMonthDetailsToggle(details) {
+ if (!details.open || monthScrollReady) return;
+ monthScrollReady = true;
+ const container = document.getElementById('month-scroll');
+ if (!container) return;
+ container.scrollTop = container.scrollHeight;
+ container.addEventListener('scroll', onMonthScroll);
+}
+
+async function onMonthScroll(e) {
+ const container = e.target;
+ if (container.scrollTop > 60 || monthLoadingMore) return;
+ monthLoadingMore = true;
+ const prevHeight = container.scrollHeight;
+ const html = await (await fetch(
+ '/tiles/monthweeks?start=' + monthWeeksLoaded + '&count=' + MONTH_BATCH
+ )).text();
+ if (html.trim()) {
+ container.insertAdjacentHTML('afterbegin', html);
+ monthWeeksLoaded += MONTH_BATCH;
+ // Keep the same rows in view instead of jumping after prepending.
+ container.scrollTop = container.scrollHeight - prevHeight + container.scrollTop;
+ }
+ monthLoadingMore = false;
}
-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();
+// Re-fetches the same number of weeks already loaded (e.g. after a drink is
+// added/removed) and swaps them in, preserving the current scroll position.
+async function refreshMonthScroll() {
+ const container = document.getElementById('month-scroll');
+ if (!container) return;
+ const prevScrollTop = container.scrollTop;
+ const html = await (await fetch(
+ '/tiles/monthweeks?start=0&count=' + monthWeeksLoaded
+ )).text();
+ container.innerHTML = html;
+ container.scrollTop = prevScrollTop;
}
// Days
@@ -158,5 +168,3 @@ async function adjustDrink(date, key, delta) {
// Init
updateDaysLabel();
-updateWeekLabel();
-updateMonthLabel();
diff --git a/static/index.html b/static/index.html
@@ -33,33 +33,13 @@
<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>
+<details ontoggle="handleMonthDetailsToggle(this)" open>
<summary>
Month view
- <span class="panel-sublabel" id="month-label">{{MONTH_LABEL}}</span>
+ <span class="panel-sublabel">Scroll up for earlier weeks</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 class="month-scroll" id="month-scroll">{{MONTH_WEEKS}}</div>
</div>
</details>
diff --git a/static/style.css b/static/style.css
@@ -142,33 +142,44 @@ details > .details-inner { padding: 0.75rem 1rem 1rem; overflow-x: auto; }
margin-right: 1rem;
}
-/* Calendar */
+/* Month view (scrollable weeks) */
-.cal-grid {
- display: grid;
- grid-template-columns: repeat(7, var(--cal-tile));
- gap: 6px;
- --cal-tile: clamp(38px, calc((100vw - 2rem - 48px) / 7), 100px);
+.month-scroll {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-height: 380px;
+ overflow-y: auto;
+ padding-right: 4px;
+ scroll-behavior: auto;
+ touch-action: pan-y;
}
-.cal-header {
- text-align: center;
- font-size: clamp(0.55rem, 1.3vw, 0.68rem);
- text-transform: uppercase;
- letter-spacing: 0.06em;
+.month-scroll::-webkit-scrollbar { width: 5px; }
+.month-scroll::-webkit-scrollbar-track { background: transparent; }
+.month-scroll::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
+
+.week-block-label {
+ font-size: 0.7rem;
color: var(--text-dim);
- padding-bottom: 4px;
+ margin-bottom: 5px;
}
-.cal-grid .tile {
- width: var(--cal-tile);
- height: var(--cal-tile);
- font-size: clamp(0.5rem, 1.3vw, 0.72rem);
+/* Week rows inside the month scroller use a fluid 7-column grid (instead of
+ the fixed viewport-based tile size used elsewhere) so they always fit
+ exactly within the panel's width — including the width taken up by the
+ vertical scrollbar — with no per-row horizontal scrollbar. */
+.month-week-row {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 6px;
}
-.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); }
+.month-week-row .tile {
+ width: 100%;
+ height: auto;
+ aspect-ratio: 1 / 1;
+}
/* Modal */