limoncello

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

commit 7f609f8ce63e8ab8c3c8e78609ca16e35c4e79bb
parent 8b1412f750acd2d91554c2f4d03b0ffbf0c5cc15
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Thu, 23 Jul 2026 15:39:28 +0100

Scrollable month view

Diffstat:
Mmain.go | 92+++++++++++++++++++++++++++++++++----------------------------------------------
Mstatic/app.js | 90+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mstatic/index.html | 26+++-----------------------
Mstatic/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 */