Improvements to fitness page
- Data now comes from a calendar - Hovering in charts shows day info - Optical enhancements to chart rendering - Allow setting/showing goal weight - Allow toggeling between absolute and zoomed in weight axis
This commit is contained in:
@@ -73,7 +73,7 @@ Bookmarkable shortcuts: `<diary>/today/`, `<diary>/this-month/`, `<diary>/this-y
|
|||||||
|
|
||||||
## Fitness
|
## Fitness
|
||||||
|
|
||||||
With `type = fitness`, a folder shows weight charts (daily and weekly mean, with your goal) below its content. Copy the [Waistline](https://github.com/davidhealey/waistline) app's export into the folder as `waistline_export.json`. Each new export replaces the old file.
|
With `type = fitness`, a folder shows weight charts (daily and weekly mean) below its content. The weights come from the calendars with the `weight` role (see [Calendars](#calendars)). Add `weight-goal = 85` to the folder's `.page-settings` to draw a goal line.
|
||||||
|
|
||||||
## Calendars
|
## Calendars
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,74 @@
|
|||||||
// Fitness dashboard range dropdowns: changing one reloads the page with that
|
// Fitness dashboard range dropdowns and "absolute" checkboxes: changing one
|
||||||
// chart's query parameter updated. Plain GET navigation — each range is a
|
// reloads the page with that chart's query parameter updated. Plain GET
|
||||||
// distinct, bookmarkable view, so no postReplace/history handling is needed.
|
// navigation — each view is distinct and bookmarkable, so no
|
||||||
|
// postReplace/history handling is needed.
|
||||||
document.addEventListener('change', function (e) {
|
document.addEventListener('change', function (e) {
|
||||||
var sel = e.target.closest('[data-fitness-range]');
|
|
||||||
if (!sel) return;
|
|
||||||
var url = new URL(window.location.href);
|
var url = new URL(window.location.href);
|
||||||
|
var sel = e.target.closest('[data-fitness-range]');
|
||||||
|
var abs = e.target.closest('[data-fitness-absolute]');
|
||||||
|
if (sel) {
|
||||||
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
|
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
|
||||||
|
} else if (abs) {
|
||||||
|
if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1');
|
||||||
|
else url.searchParams.delete(abs.dataset.fitnessAbsolute);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hover tooltip: the pointer snaps to the data point nearest in time. Dots
|
||||||
|
// are sorted by x, so a binary search finds it; the tooltip sits above the
|
||||||
|
// point and is clamped to the chart panel.
|
||||||
|
function fitnessNearest(dots, x) {
|
||||||
|
var lo = 0, hi = dots.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
var mid = (lo + hi) >> 1;
|
||||||
|
if (dots[mid].cx.baseVal.value < x) lo = mid + 1; else hi = mid;
|
||||||
|
}
|
||||||
|
if (lo > 0 && x - dots[lo - 1].cx.baseVal.value < dots[lo].cx.baseVal.value - x) lo--;
|
||||||
|
return dots[lo];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitnessHide(svg) {
|
||||||
|
var chart = svg.closest('.fitness-chart');
|
||||||
|
svg.classList.remove('is-hover');
|
||||||
|
chart.querySelector('.fitness-tip').hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', function (e) {
|
||||||
|
var svg = e.target.closest && e.target.closest('.fitness-svg');
|
||||||
|
document.querySelectorAll('.fitness-svg').forEach(function (other) {
|
||||||
|
if (other !== svg) fitnessHide(other);
|
||||||
|
});
|
||||||
|
if (!svg) return;
|
||||||
|
var dots = svg.querySelectorAll('.chart-dot');
|
||||||
|
if (!dots.length) return;
|
||||||
|
var ctm = svg.getScreenCTM();
|
||||||
|
if (!ctm) return;
|
||||||
|
var pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
|
||||||
|
var dot = fitnessNearest(dots, pt.x);
|
||||||
|
var cx = dot.cx.baseVal.value, cy = dot.cy.baseVal.value;
|
||||||
|
|
||||||
|
var cursor = svg.querySelector('.chart-cursor');
|
||||||
|
cursor.setAttribute('x1', cx);
|
||||||
|
cursor.setAttribute('x2', cx);
|
||||||
|
var focus = svg.querySelector('.chart-focus');
|
||||||
|
focus.setAttribute('cx', cx);
|
||||||
|
focus.setAttribute('cy', cy);
|
||||||
|
svg.classList.add('is-hover');
|
||||||
|
|
||||||
|
var chart = svg.closest('.fitness-chart');
|
||||||
|
var tip = chart.querySelector('.fitness-tip');
|
||||||
|
tip.firstElementChild.textContent = dot.dataset.date;
|
||||||
|
tip.lastElementChild.textContent = dot.dataset.value;
|
||||||
|
tip.hidden = false;
|
||||||
|
var box = chart.getBoundingClientRect();
|
||||||
|
var at = new DOMPoint(cx, cy).matrixTransform(ctm);
|
||||||
|
var left = at.x - box.left - tip.offsetWidth / 2;
|
||||||
|
left = Math.max(0, Math.min(left, box.width - tip.offsetWidth));
|
||||||
|
var top = at.y - box.top - tip.offsetHeight - 10;
|
||||||
|
if (top < 0) top = at.y - box.top + 10;
|
||||||
|
tip.style.left = left + 'px';
|
||||||
|
tip.style.top = top + 'px';
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,19 +2,23 @@
|
|||||||
<section class="fitness-chart panel">
|
<section class="fitness-chart panel">
|
||||||
<div class="fitness-chart-header row space-between">
|
<div class="fitness-chart-header row space-between">
|
||||||
<span class="caption">{{.Title}}</span>
|
<span class="caption">{{.Title}}</span>
|
||||||
|
<div class="row fitness-controls">
|
||||||
|
<label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
|
||||||
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
|
||||||
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{if .Empty}}
|
{{if .Empty}}
|
||||||
<p class="fitness-empty is-empty">No data in this range.</p>
|
<p class="fitness-empty is-empty">No data in this range.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
|
<svg class="fitness-svg{{if .Dense}} is-dense{{end}}" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
|
||||||
{{range .YTicks}}
|
{{range .YTicks}}
|
||||||
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
|
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
|
||||||
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
|
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{range .XTicks}}
|
{{range .XTicks}}
|
||||||
|
<line class="chart-grid" x1="{{.Pos}}" y1="{{$.PlotY}}" x2="{{.Pos}}" y2="{{$.PlotB}}"/>
|
||||||
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
|
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
|
||||||
{{end}}
|
{{end}}
|
||||||
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
|
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
|
||||||
@@ -23,13 +27,16 @@
|
|||||||
<polyline class="chart-line" points="{{.}}"/>
|
<polyline class="chart-line" points="{{.}}"/>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{range .Dots}}
|
{{range .Dots}}
|
||||||
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
|
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5" data-date="{{.Date}}" data-value="{{.Value}}"/>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Goal}}
|
{{if .Goal}}
|
||||||
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
|
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
|
||||||
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
|
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<line class="chart-cursor" x1="0" y1="{{.PlotY}}" x2="0" y2="{{.PlotB}}"/>
|
||||||
|
<circle class="chart-focus" r="4"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<div class="fitness-tip" hidden><strong></strong><span></span></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+21
-3
@@ -837,8 +837,10 @@ aside.sidebar:empty { display: none; }
|
|||||||
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
||||||
colors and strokes are applied here via classes so the inline SVG follows
|
colors and strokes are applied here via classes so the inline SVG follows
|
||||||
the theme palette. */
|
the theme palette. */
|
||||||
.fitness-chart { padding: var(--space-3); }
|
.fitness-chart { position: relative; padding: var(--space-3); }
|
||||||
.fitness-chart-header { margin-bottom: var(--space-2); }
|
.fitness-chart-header { margin-bottom: var(--space-2); }
|
||||||
|
.fitness-controls { gap: var(--space-3); }
|
||||||
|
.fitness-absolute { display: flex; align-items: center; gap: var(--space-1); font-size: var(--font-sm); color: var(--text-muted); }
|
||||||
.fitness-range { width: auto; font-size: var(--font-sm); }
|
.fitness-range { width: auto; font-size: var(--font-sm); }
|
||||||
.fitness-empty {
|
.fitness-empty {
|
||||||
border: var(--border-dashed);
|
border: var(--border-dashed);
|
||||||
@@ -852,8 +854,24 @@ aside.sidebar:empty { display: none; }
|
|||||||
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
|
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
|
||||||
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
||||||
.fitness-svg .chart-dot { fill: var(--link); }
|
.fitness-svg .chart-dot { fill: var(--link); }
|
||||||
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
|
.fitness-svg.is-dense .chart-dot { display: none; }
|
||||||
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
|
.fitness-svg:not(.is-hover) :is(.chart-cursor, .chart-focus) { display: none; }
|
||||||
|
.fitness-svg .chart-cursor { stroke: var(--text-muted); stroke-dasharray: 2 2; }
|
||||||
|
.fitness-svg .chart-focus { fill: var(--link-hover); stroke: var(--bg); stroke-width: 1.5; }
|
||||||
|
.fitness-svg .chart-goal { stroke: var(--danger); stroke-dasharray: 4 3; }
|
||||||
|
.fitness-svg .chart-goal-label { fill: var(--danger-hover); font-size: var(--font-xs); }
|
||||||
|
.fitness-tip {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--bg);
|
||||||
|
border: var(--border);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.fitness-tip[hidden] { display: none; }
|
||||||
|
|
||||||
/* === Responsive === */
|
/* === Responsive === */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
|
|||||||
+15
-4
@@ -646,6 +646,20 @@ func calendarDayLines(from, to time.Time) map[string]calDayLines {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for key, w := range calendarWeights(s, from, to) {
|
||||||
|
lines := out[key]
|
||||||
|
lines.Weight = strings.ReplaceAll(w, ".", ",") + " kg"
|
||||||
|
out[key] = lines
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarWeights reads the weight role for [from, to): one entry per day,
|
||||||
|
// keyed by "YYYY-MM-DD", holding the kg value with a '.' decimal separator.
|
||||||
|
// Events whose title is not a weight are skipped; several entries on one day:
|
||||||
|
// the last edited one wins.
|
||||||
|
func calendarWeights(s calendarSettings, from, to time.Time) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
stamps := map[string]time.Time{}
|
stamps := map[string]time.Time{}
|
||||||
for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) {
|
for _, ev := range calendarOccurrences(calendarsDir, s.Weight, from, to) {
|
||||||
m := weightTitle.FindStringSubmatch(ev.Summary)
|
m := weightTitle.FindStringSubmatch(ev.Summary)
|
||||||
@@ -653,14 +667,11 @@ func calendarDayLines(from, to time.Time) map[string]calDayLines {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := ev.Start.Format("2006-01-02")
|
key := ev.Start.Format("2006-01-02")
|
||||||
// Several entries on one day: the last edited one wins.
|
|
||||||
if prev, ok := stamps[key]; ok && ev.Stamp.Before(prev) {
|
if prev, ok := stamps[key]; ok && ev.Stamp.Before(prev) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
stamps[key] = ev.Stamp
|
stamps[key] = ev.Stamp
|
||||||
lines := out[key]
|
out[key] = strings.ReplaceAll(m[1], ",", ".")
|
||||||
lines.Weight = strings.ReplaceAll(m[1], ".", ",") + " kg"
|
|
||||||
out[key] = lines
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-164
@@ -2,14 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"net/url"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,10 +18,6 @@ func init() {
|
|||||||
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
|
pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// waistlineExportFile is the exact filename the user copies into the folder.
|
|
||||||
// The single file is always the latest export — no glob, no multi-file merge.
|
|
||||||
const waistlineExportFile = "waistline_export.json"
|
|
||||||
|
|
||||||
type fitnessHandler struct{}
|
type fitnessHandler struct{}
|
||||||
|
|
||||||
// redirect: the fitness dashboard has no virtual URLs; everything renders
|
// redirect: the fitness dashboard has no virtual URLs; everything renders
|
||||||
@@ -34,16 +28,15 @@ func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request)
|
|||||||
|
|
||||||
// handle renders the dashboard for folders whose .page-settings declares
|
// handle renders the dashboard for folders whose .page-settings declares
|
||||||
// type = fitness. Markdown content and the folder listing stay visible so
|
// type = fitness. Markdown content and the folder listing stay visible so
|
||||||
// the user can verify an uploaded export arrived.
|
// the folder can still hold notes.
|
||||||
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
|
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
|
||||||
s := readPageSettings(fsPath)
|
s := readPageSettings(fsPath)
|
||||||
if s == nil || s.Type != "fitness" {
|
if s == nil || s.Type != "fitness" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
|
q := r.URL.Query()
|
||||||
weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
|
|
||||||
return &specialPage{
|
return &specialPage{
|
||||||
Content: renderFitnessDashboard(fsPath, weightSel, weeklySel),
|
Content: renderFitnessDashboard(fsPath, chartViewFrom(q, "weight", "3m"), chartViewFrom(q, "weekly", "1y")),
|
||||||
SuppressTOC: true,
|
SuppressTOC: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +65,20 @@ func validFitnessRange(v, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fitnessView is one chart's view state from the query string: ?<param>=<range>
|
||||||
|
// and ?<param>-absolute=1 to start the y axis at 0.
|
||||||
|
type fitnessView struct {
|
||||||
|
Range string
|
||||||
|
Absolute bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func chartViewFrom(q url.Values, param, fallback string) fitnessView {
|
||||||
|
return fitnessView{
|
||||||
|
Range: validFitnessRange(q.Get(param), fallback),
|
||||||
|
Absolute: q.Get(param+"-absolute") == "1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func rangeMonths(v string) int {
|
func rangeMonths(v string) int {
|
||||||
for _, r := range fitnessRanges {
|
for _, r := range fitnessRanges {
|
||||||
if r.Value == v {
|
if r.Value == v {
|
||||||
@@ -81,95 +88,6 @@ func rangeMonths(v string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Waistline export parsing ===
|
|
||||||
|
|
||||||
// wlNum is a number in a Waistline export: values appear as JSON numbers,
|
|
||||||
// numeric strings, null, or are absent. Unparsable values read as not-ok
|
|
||||||
// instead of failing the whole export parse.
|
|
||||||
type wlNum struct {
|
|
||||||
val float64
|
|
||||||
ok bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *wlNum) UnmarshalJSON(b []byte) error {
|
|
||||||
s := strings.Trim(string(b), `"`)
|
|
||||||
if s == "" || s == "null" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseFloat(s, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
n.val = v
|
|
||||||
n.ok = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calorie tracking was removed pending a rethink of the (undocumented)
|
|
||||||
// per-item formula; only the weight series is read from the export.
|
|
||||||
type wlExport struct {
|
|
||||||
Diary []wlDiaryEntry `json:"diary"`
|
|
||||||
Settings json.RawMessage `json:"settings"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type wlDiaryEntry struct {
|
|
||||||
DateTime string `json:"dateTime"`
|
|
||||||
Stats struct {
|
|
||||||
Weight wlNum `json:"weight"`
|
|
||||||
} `json:"stats"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportDate extracts the calendar day from a Waistline dateTime. The values
|
|
||||||
// are UTC-midnight timestamps; taking the first 10 characters avoids time
|
|
||||||
// zone conversions shifting the day.
|
|
||||||
func exportDate(s string) (time.Time, bool) {
|
|
||||||
if len(s) < 10 {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
t, err := time.Parse("2006-01-02", s[:10])
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
return t, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
|
|
||||||
// weekday slot of the shared goal. Best-effort: any missing or unparsable
|
|
||||||
// level means "no goal line", never an error.
|
|
||||||
func goalValue(settings json.RawMessage, key string) (float64, bool) {
|
|
||||||
var s struct {
|
|
||||||
Goals map[string]json.RawMessage `json:"goals"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(settings, &s) != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
var g struct {
|
|
||||||
GoalList []struct {
|
|
||||||
Goal []wlNum `json:"goal"`
|
|
||||||
} `json:"goal-list"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(s.Goals[key], &g) != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
n := g.GoalList[0].Goal[0]
|
|
||||||
return n.val, n.ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportWeightUnit(settings json.RawMessage) string {
|
|
||||||
var s struct {
|
|
||||||
Units struct {
|
|
||||||
Weight string `json:"weight"`
|
|
||||||
} `json:"units"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
|
|
||||||
return s.Units.Weight
|
|
||||||
}
|
|
||||||
return "kg"
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Series extraction ===
|
// === Series extraction ===
|
||||||
|
|
||||||
type weightPoint struct {
|
type weightPoint struct {
|
||||||
@@ -178,26 +96,34 @@ type weightPoint struct {
|
|||||||
days int // >0: weekly mean over this many measured days
|
days int // >0: weekly mean over this many measured days
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractWeights computes the weight series from the export: one point per
|
// fitnessWeights reads the whole weight calendar up to today: one point per
|
||||||
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
|
// day, sorted by date. Dates are UTC midnights to match the chart's today.
|
||||||
// one wins.
|
func fitnessWeights(today time.Time) []weightPoint {
|
||||||
func extractWeights(ex *wlExport) []weightPoint {
|
byDay := calendarWeights(readCalendarSettings(calendarsDir), time.Time{}, today.AddDate(0, 0, 1))
|
||||||
byDate := map[time.Time]float64{}
|
weights := make([]weightPoint, 0, len(byDay))
|
||||||
for _, e := range ex.Diary {
|
for key, w := range byDay {
|
||||||
day, ok := exportDate(e.DateTime)
|
d, err := time.Parse("2006-01-02", key)
|
||||||
if !ok || !e.Stats.Weight.ok {
|
v, err2 := strconv.ParseFloat(w, 64)
|
||||||
|
if err != nil || err2 != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
byDate[day] = e.Stats.Weight.val
|
|
||||||
}
|
|
||||||
weights := make([]weightPoint, 0, len(byDate))
|
|
||||||
for d, v := range byDate {
|
|
||||||
weights = append(weights, weightPoint{date: d, value: v})
|
weights = append(weights, weightPoint{date: d, value: v})
|
||||||
}
|
}
|
||||||
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
|
||||||
return weights
|
return weights
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// weightGoal reads the optional weight-goal key from the fitness folder's
|
||||||
|
// .page-settings.
|
||||||
|
func weightGoal(fsPath string) (float64, bool) {
|
||||||
|
v, ok := readSettingsFile(fsPath)["weight-goal"]
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
g, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v), ",", "."), 64)
|
||||||
|
return g, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func mondayOf(t time.Time) time.Time {
|
func mondayOf(t time.Time) time.Time {
|
||||||
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
|
return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
|
||||||
}
|
}
|
||||||
@@ -280,8 +206,9 @@ type fitnessTickVM struct {
|
|||||||
Pos, Label, Anchor string
|
Pos, Label, Anchor string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fitnessDotVM is one data point; Date and Value feed the hover tooltip.
|
||||||
type fitnessDotVM struct {
|
type fitnessDotVM struct {
|
||||||
X, Y, Title string
|
X, Y, Date, Value string
|
||||||
}
|
}
|
||||||
|
|
||||||
type fitnessGoalVM struct {
|
type fitnessGoalVM struct {
|
||||||
@@ -292,6 +219,7 @@ type fitnessChartVM struct {
|
|||||||
Title string
|
Title string
|
||||||
Param string
|
Param string
|
||||||
Options []fitnessOptVM
|
Options []fitnessOptVM
|
||||||
|
Absolute bool
|
||||||
Empty bool
|
Empty bool
|
||||||
|
|
||||||
ViewW, ViewH string
|
ViewW, ViewH string
|
||||||
@@ -301,6 +229,7 @@ type fitnessChartVM struct {
|
|||||||
Goal *fitnessGoalVM
|
Goal *fitnessGoalVM
|
||||||
Lines []string // polyline points attributes
|
Lines []string // polyline points attributes
|
||||||
Dots []fitnessDotVM
|
Dots []fitnessDotVM
|
||||||
|
Dense bool // too many points to draw markers
|
||||||
}
|
}
|
||||||
|
|
||||||
type fitnessDashVM struct {
|
type fitnessDashVM struct {
|
||||||
@@ -308,13 +237,13 @@ type fitnessDashVM struct {
|
|||||||
Charts []fitnessChartVM
|
Charts []fitnessChartVM
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChartVM(title, param, sel string) fitnessChartVM {
|
func newChartVM(title, param string, view fitnessView) fitnessChartVM {
|
||||||
opts := make([]fitnessOptVM, len(fitnessRanges))
|
opts := make([]fitnessOptVM, len(fitnessRanges))
|
||||||
for i, r := range fitnessRanges {
|
for i, r := range fitnessRanges {
|
||||||
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
|
opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == view.Range}
|
||||||
}
|
}
|
||||||
return fitnessChartVM{
|
return fitnessChartVM{
|
||||||
Title: title, Param: param, Options: opts,
|
Title: title, Param: param, Options: opts, Absolute: view.Absolute,
|
||||||
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
|
ViewW: svgNum(chartW), ViewH: svgNum(chartH),
|
||||||
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
|
PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
|
||||||
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
|
PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
|
||||||
@@ -323,7 +252,7 @@ func newChartVM(title, param, sel string) fitnessChartVM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// yTickVMs places gridlines at fixed multiples of step across the y domain
|
// yTickVMs places gridlines at fixed multiples of step across the y domain
|
||||||
// (always 5 kg guides on weight charts, regardless of range).
|
// (5 kg guides on weight charts, 20 kg when the axis starts at 0).
|
||||||
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
|
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
|
||||||
var out []fitnessTickVM
|
var out []fitnessTickVM
|
||||||
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
|
for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
|
||||||
@@ -346,12 +275,12 @@ func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
|
|||||||
// buildWeightChart renders a weight line chart. It serves both the per-day
|
// buildWeightChart renders a weight line chart. It serves both the per-day
|
||||||
// series and the weekly-mean series (points carrying days > 0); the line is
|
// series and the weekly-mean series (points carrying days > 0); the line is
|
||||||
// drawn continuous across days/weeks without a measurement.
|
// drawn continuous across days/weeks without a measurement.
|
||||||
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
|
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, view fitnessView, param, title, unit string, today time.Time) fitnessChartVM {
|
||||||
vm := newChartVM(title, param, sel)
|
vm := newChartVM(title, param, view)
|
||||||
|
|
||||||
points := all
|
points := all
|
||||||
var x0, x1 time.Time
|
var x0, x1 time.Time
|
||||||
if m := rangeMonths(sel); m > 0 {
|
if m := rangeMonths(view.Range); m > 0 {
|
||||||
x0 = today.AddDate(0, -m, 0)
|
x0 = today.AddDate(0, -m, 0)
|
||||||
points = filterWeights(all, x0)
|
points = filterWeights(all, x0)
|
||||||
if len(points) == 0 {
|
if len(points) == 0 {
|
||||||
@@ -391,21 +320,31 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
|
|||||||
pad = 1
|
pad = 1
|
||||||
}
|
}
|
||||||
sc := chartScale{x0, x1, lo - pad, hi + pad}
|
sc := chartScale{x0, x1, lo - pad, hi + pad}
|
||||||
|
yStep := 5.0
|
||||||
|
if view.Absolute {
|
||||||
|
sc.y0 = 0
|
||||||
|
sc.y1 = hi + hi*0.05
|
||||||
|
yStep = 20
|
||||||
|
}
|
||||||
|
|
||||||
vm.YTicks = yTickVMs(sc, 5)
|
vm.YTicks = yTickVMs(sc, yStep)
|
||||||
vm.XTicks = timeXTicks(sc, 4)
|
vm.XTicks = timeXTicks(sc)
|
||||||
|
|
||||||
// One continuous polyline through every point in range — days without a
|
// One continuous polyline through every point in range — days without a
|
||||||
// measurement do not break the line. Point markers carry the hover
|
// measurement do not break the line. Every point also gets a marker that
|
||||||
// <title>; on dense ranges they are dropped and the bare line stays
|
// feeds the hover tooltip; on dense ranges the markers are hidden and the
|
||||||
// legible. A single point in range renders as a dot.
|
// bare line stays legible. A single point in range renders as a dot.
|
||||||
dot := func(p weightPoint) fitnessDotVM {
|
dot := func(p weightPoint) fitnessDotVM {
|
||||||
label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
|
d := fitnessDotVM{
|
||||||
if p.days > 0 {
|
X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)),
|
||||||
label = fmt.Sprintf("Week of %s: %s %s (%d days)",
|
Date: p.date.Format("Mon 2006-01-02"),
|
||||||
p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
|
Value: svgNum(p.value) + " " + unit,
|
||||||
}
|
}
|
||||||
return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
|
if p.days > 0 {
|
||||||
|
d.Date = "Week of " + p.date.Format("2006-01-02")
|
||||||
|
d.Value += fmt.Sprintf(" · %d days", p.days)
|
||||||
|
}
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
if len(points) >= 2 {
|
if len(points) >= 2 {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -419,11 +358,10 @@ func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param,
|
|||||||
}
|
}
|
||||||
vm.Lines = append(vm.Lines, b.String())
|
vm.Lines = append(vm.Lines, b.String())
|
||||||
}
|
}
|
||||||
if len(points) <= 100 {
|
|
||||||
for _, p := range points {
|
for _, p := range points {
|
||||||
vm.Dots = append(vm.Dots, dot(p))
|
vm.Dots = append(vm.Dots, dot(p))
|
||||||
}
|
}
|
||||||
}
|
vm.Dense = len(points) > 100
|
||||||
|
|
||||||
if hasGoal {
|
if hasGoal {
|
||||||
vm.setGoal(sc, goal, "goal "+svgNum(goal))
|
vm.setGoal(sc, goal, "goal "+svgNum(goal))
|
||||||
@@ -441,24 +379,91 @@ func filterWeights(points []weightPoint, from time.Time) []weightPoint {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeXTicks places n+1 evenly spaced date labels across a continuous time
|
// xTickStep is a calendar-aligned spacing for date labels.
|
||||||
// axis; the last label is end-anchored so it stays inside the viewBox.
|
type xTickStep struct {
|
||||||
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
days, months int
|
||||||
span := sc.x1.Sub(sc.x0)
|
label func(t time.Time, first bool) string
|
||||||
var out []fitnessTickVM
|
|
||||||
prev := ""
|
|
||||||
for i := 0; i <= n; i++ {
|
|
||||||
t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
|
|
||||||
label := t.Format("2006-01-02")
|
|
||||||
if label == prev {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
prev = label
|
|
||||||
|
var xTickSteps = []xTickStep{
|
||||||
|
{days: 1, label: dayLabel},
|
||||||
|
{days: 7, label: dayLabel},
|
||||||
|
{days: 14, label: dayLabel},
|
||||||
|
{months: 1, label: monthLabel},
|
||||||
|
{months: 3, label: monthLabel},
|
||||||
|
{months: 6, label: monthLabel},
|
||||||
|
{months: 12, label: yearLabel},
|
||||||
|
{months: 24, label: yearLabel},
|
||||||
|
{months: 60, label: yearLabel},
|
||||||
|
}
|
||||||
|
|
||||||
|
func dayLabel(t time.Time, _ bool) string { return t.Format("2 Jan") }
|
||||||
|
|
||||||
|
// monthLabel names the year only where it changes, so a year-long axis
|
||||||
|
// reads "Oct 2025, Jan 2026, Apr, Jul".
|
||||||
|
func monthLabel(t time.Time, first bool) string {
|
||||||
|
if first || t.Month() == time.January {
|
||||||
|
return t.Format("Jan 2006")
|
||||||
|
}
|
||||||
|
return t.Format("Jan")
|
||||||
|
}
|
||||||
|
|
||||||
|
func yearLabel(t time.Time, _ bool) string { return t.Format("2006") }
|
||||||
|
|
||||||
|
// maxXTicks bounds the label count so labels never collide on the axis.
|
||||||
|
const maxXTicks = 5
|
||||||
|
|
||||||
|
// timeXTicks labels the time axis at calendar boundaries — days, Mondays,
|
||||||
|
// month starts or new years, whichever is the finest step that stays within
|
||||||
|
// maxXTicks. Labels near the plot edges are anchored inward so they stay
|
||||||
|
// inside the viewBox.
|
||||||
|
func timeXTicks(sc chartScale) []fitnessTickVM {
|
||||||
|
var ticks []time.Time
|
||||||
|
var step xTickStep
|
||||||
|
for _, step = range xTickSteps {
|
||||||
|
ticks = alignedTicks(sc.x0, sc.x1, step)
|
||||||
|
if len(ticks) <= maxXTicks {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]fitnessTickVM, 0, len(ticks))
|
||||||
|
for i, t := range ticks {
|
||||||
|
x := sc.x(t)
|
||||||
anchor := "middle"
|
anchor := "middle"
|
||||||
if i == n {
|
switch {
|
||||||
|
case x-chartLeft < 24:
|
||||||
|
anchor = "start"
|
||||||
|
case chartW-chartRight-x < 24:
|
||||||
anchor = "end"
|
anchor = "end"
|
||||||
}
|
}
|
||||||
out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
|
out = append(out, fitnessTickVM{Pos: svgNum(x), Label: step.label(t, i == 0), Anchor: anchor})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignedTicks lists every boundary of step within [from, to]. Week steps
|
||||||
|
// start on Mondays; month and year steps on multiples of the step (Jan/Apr/
|
||||||
|
// Jul/Oct for quarters, even years for two-year steps).
|
||||||
|
func alignedTicks(from, to time.Time, step xTickStep) []time.Time {
|
||||||
|
var t time.Time
|
||||||
|
if step.days > 0 {
|
||||||
|
t = time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
if step.days > 1 {
|
||||||
|
t = mondayOf(t)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
months := from.Year()*12 + int(from.Month()) - 1
|
||||||
|
months -= months % step.months
|
||||||
|
t = time.Date(months/12, time.Month(months%12+1), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
var out []time.Time
|
||||||
|
for ; !t.After(to); t = t.AddDate(0, step.months, step.days) {
|
||||||
|
if !t.Before(from) {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if len(out) > maxXTicks {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -467,8 +472,8 @@ func timeXTicks(sc chartScale, n int) []fitnessTickVM {
|
|||||||
|
|
||||||
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))
|
||||||
|
|
||||||
func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
|
func renderFitnessDashboard(fsPath string, weightView, weeklyView fitnessView) template.HTML {
|
||||||
data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
|
data := buildFitnessDash(fsPath, weightView, weeklyView, time.Now())
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
if err := fitnessTmpl.Execute(&buf, data); err != nil {
|
||||||
log.Printf("fitness template: %v", err)
|
log.Printf("fitness template: %v", err)
|
||||||
@@ -477,28 +482,23 @@ func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
|
|||||||
return template.HTML(buf.String())
|
return template.HTML(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFitnessDash reads and parses the export per request — no caching, no
|
// buildFitnessDash reads the weight calendar per request (the calendar store
|
||||||
// indexes. A read or parse failure (including a truncated mid-upload file)
|
// caches parsed files). A missing calendar setup becomes an inline notice;
|
||||||
// becomes an inline notice; the page itself always renders.
|
// the page itself always renders.
|
||||||
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
|
func buildFitnessDash(fsPath string, weightView, weeklyView fitnessView, now time.Time) fitnessDashVM {
|
||||||
raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
|
if calendarsDir == "" || len(readCalendarSettings(calendarsDir).Weight) == 0 {
|
||||||
if err != nil {
|
return fitnessDashVM{Notice: "No weight calendar configured — set weight = <calendar> in the calendars folder's .page-settings."}
|
||||||
return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
|
|
||||||
}
|
|
||||||
var ex wlExport
|
|
||||||
if err := json.Unmarshal(raw, &ex); err != nil {
|
|
||||||
return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
weights := extractWeights(&ex)
|
weights := fitnessWeights(today)
|
||||||
wGoal, hasWGoal := goalValue(ex.Settings, "weight")
|
wGoal, hasWGoal := weightGoal(fsPath)
|
||||||
unit := exportWeightUnit(ex.Settings)
|
const unit = "kg"
|
||||||
|
|
||||||
return fitnessDashVM{Charts: []fitnessChartVM{
|
return fitnessDashVM{Charts: []fitnessChartVM{
|
||||||
buildWeightChart(weights, wGoal, hasWGoal, weightSel,
|
buildWeightChart(weights, wGoal, hasWGoal, weightView,
|
||||||
"weight", "Weight ("+unit+")", unit, today),
|
"weight", "Weight ("+unit+")", unit, today),
|
||||||
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
|
buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklyView,
|
||||||
"weekly", "Weekly average weight ("+unit+")", unit, today),
|
"weekly", "Weekly average weight ("+unit+")", unit, today),
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFitnessWeights(t *testing.T) {
|
||||||
|
withBerlin(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
prev := calendarsDir
|
||||||
|
calendarsDir = dir
|
||||||
|
t.Cleanup(func() { calendarsDir = prev })
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".page-settings"),
|
||||||
|
[]byte("type = calendars\nweight = Gewicht\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeCal(t, dir, "Gewicht",
|
||||||
|
`UID:1
|
||||||
|
SUMMARY:96 kg
|
||||||
|
DTSTART;VALUE=DATE:20261001`,
|
||||||
|
`UID:2
|
||||||
|
SUMMARY:95,6kg
|
||||||
|
DTSTART;VALUE=DATE:20261003`,
|
||||||
|
`UID:3
|
||||||
|
SUMMARY:not a weight
|
||||||
|
DTSTART;VALUE=DATE:20261004`,
|
||||||
|
`UID:4
|
||||||
|
SUMMARY:90 kg
|
||||||
|
DTSTART;VALUE=DATE:20261010`,
|
||||||
|
)
|
||||||
|
|
||||||
|
today := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||||
|
got := fitnessWeights(today)
|
||||||
|
want := []weightPoint{
|
||||||
|
{date: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), value: 96},
|
||||||
|
{date: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), value: 95.6},
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if !got[i].date.Equal(want[i].date) || got[i].value != want[i].value {
|
||||||
|
t.Fatalf("point %d: got %+v, want %+v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeXTicks(t *testing.T) {
|
||||||
|
d := func(y int, m time.Month, day int) time.Time { return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) }
|
||||||
|
for _, c := range []struct {
|
||||||
|
from, to time.Time
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{d(2022, 11, 28), d(2026, 9, 21), "2023 2024 2025 2026"},
|
||||||
|
{d(2025, 9, 27), d(2026, 9, 27), "Oct 2025|Jan 2026|Apr|Jul"},
|
||||||
|
{d(2026, 6, 27), d(2026, 9, 27), "Jul 2026|Aug|Sep"},
|
||||||
|
{d(2026, 8, 27), d(2026, 9, 27), "31 Aug|7 Sep|14 Sep|21 Sep"},
|
||||||
|
} {
|
||||||
|
var got []string
|
||||||
|
for _, tk := range timeXTicks(chartScale{x0: c.from, x1: c.to}) {
|
||||||
|
got = append(got, tk.Label)
|
||||||
|
}
|
||||||
|
sep := " "
|
||||||
|
if strings.Contains(c.want, "|") {
|
||||||
|
sep = "|"
|
||||||
|
}
|
||||||
|
if g := strings.Join(got, sep); g != c.want {
|
||||||
|
t.Errorf("%s..%s: got %q, want %q", c.from.Format("2006-01-02"), c.to.Format("2006-01-02"), g, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user