feat: add charts to habbits 29da025a
Steve · 2026-08-23 23:23 5 file(s) · +301 −4
apps/habbits/app.go +12 −0
87 87
	Error   string
88 88
	Habit   habitRow
89 89
	Days    []habitDay
90 +
	// ChartType selects the client-side renderer: "numeric" (int/float line
91 +
	// chart), "bool" (streak heatmap), or "" (no chart, e.g. string habits).
92 +
	ChartType string
93 +
	// ChartPoints is the JSON-encoded []chartPoint for the chart, chronological.
94 +
	ChartPoints string
95 +
}
96 +
97 +
// chartPoint is one plotted value: T is a unix timestamp (seconds), V is the
98 +
// record's value as a float (1/0 for bool).
99 +
type chartPoint struct {
100 +
	T int64   `json:"t"`
101 +
	V float64 `json:"v"`
90 102
}
apps/habbits/handlers_web.go +50 −4
1 1
package main
2 2
3 3
import (
4 +
	"encoding/json"
4 5
	"net/http"
6 +
	"strconv"
5 7
	"strings"
6 8
	"time"
7 9
57 59
		days[i].Records = append(days[i].Records, row)
58 60
	}
59 61
	return days
62 +
}
63 +
64 +
// buildChartData turns records (as returned by listRecordsForHabit, newest
65 +
// first) into chart type + JSON points for the habit detail chart. Numeric
66 +
// habits (int/float) get a line chart; bool habits get a streak heatmap;
67 +
// string habits have no natural numeric chart and are skipped.
68 +
func buildChartData(valueType string, records []Record) (chartType, pointsJSON string) {
69 +
	switch valueType {
70 +
	case "int", "float":
71 +
		chartType = "numeric"
72 +
	case "bool":
73 +
		chartType = "bool"
74 +
	default:
75 +
		return "", ""
76 +
	}
77 +
78 +
	points := make([]chartPoint, 0, len(records))
79 +
	for i := len(records) - 1; i >= 0; i-- {
80 +
		r := records[i]
81 +
		var v float64
82 +
		if chartType == "bool" {
83 +
			if r.Value == "true" {
84 +
				v = 1
85 +
			}
86 +
		} else {
87 +
			f, err := strconv.ParseFloat(r.Value, 64)
88 +
			if err != nil {
89 +
				continue
90 +
			}
91 +
			v = f
92 +
		}
93 +
		points = append(points, chartPoint{T: r.RecordedAt, V: v})
94 +
	}
95 +
	if len(points) == 0 {
96 +
		return "", ""
97 +
	}
98 +
	b, err := json.Marshal(points)
99 +
	if err != nil {
100 +
		return "", ""
101 +
	}
102 +
	return chartType, string(b)
60 103
}
61 104
62 105
// parseRecordedAt converts a datetime-local form value into unix seconds. An
203 246
	if err != nil {
204 247
		a.Log.Error("list habit records", "err", err)
205 248
	}
249 +
	chartType, chartPoints := buildChartData(habit.ValueType, records)
206 250
	web.Render(a.Templates, w, "habit.html", habitPageData{
207 -
		Success: r.URL.Query().Get("success"),
208 -
		Error:   r.URL.Query().Get("error"),
209 -
		Habit:   habitToRow(*habit, len(records)),
210 -
		Days:    groupByDay(records),
251 +
		Success:     r.URL.Query().Get("success"),
252 +
		Error:       r.URL.Query().Get("error"),
253 +
		Habit:       habitToRow(*habit, len(records)),
254 +
		Days:        groupByDay(records),
255 +
		ChartType:   chartType,
256 +
		ChartPoints: chartPoints,
211 257
	}, a.Log)
212 258
}
213 259
apps/habbits/static/chart.js (added) +177 −0
1 +
// Dependency-free chart rendering for the habit detail page. Reads JSON
2 +
// points off #habit-chart's data attributes and draws an inline SVG: a line
3 +
// chart for numeric (int/float) habits, a day-by-day streak heatmap for bool
4 +
// habits.
5 +
(function () {
6 +
  var SVG_NS = "http://www.w3.org/2000/svg";
7 +
8 +
  function svgEl(tag, attrs) {
9 +
    var el = document.createElementNS(SVG_NS, tag);
10 +
    for (var k in attrs) el.setAttribute(k, attrs[k]);
11 +
    return el;
12 +
  }
13 +
14 +
  function pad2(n) {
15 +
    return n < 10 ? "0" + n : "" + n;
16 +
  }
17 +
18 +
  function dayKey(date) {
19 +
    return date.getFullYear() + "-" + pad2(date.getMonth() + 1) + "-" + pad2(date.getDate());
20 +
  }
21 +
22 +
  function renderLine(el, points, unit) {
23 +
    var width = Math.max(el.clientWidth || 0, 280);
24 +
    var height = 180;
25 +
    var padding = { top: 16, right: 16, bottom: 24, left: 44 };
26 +
27 +
    var vals = points.map(function (p) {
28 +
      return p.v;
29 +
    });
30 +
    var min = Math.min.apply(null, vals);
31 +
    var max = Math.max.apply(null, vals);
32 +
    if (min === max) {
33 +
      min -= 1;
34 +
      max += 1;
35 +
    }
36 +
    var pad = (max - min) * 0.1;
37 +
    min -= pad;
38 +
    max += pad;
39 +
40 +
    var t0 = points[0].t;
41 +
    var t1 = points[points.length - 1].t;
42 +
    if (t0 === t1) t1 = t0 + 1;
43 +
44 +
    var innerW = width - padding.left - padding.right;
45 +
    var innerH = height - padding.top - padding.bottom;
46 +
47 +
    function x(t) {
48 +
      return padding.left + ((t - t0) / (t1 - t0)) * innerW;
49 +
    }
50 +
    function y(v) {
51 +
      return padding.top + innerH - ((v - min) / (max - min)) * innerH;
52 +
    }
53 +
54 +
    var svg = svgEl("svg", {
55 +
      width: width,
56 +
      height: height,
57 +
      viewBox: "0 0 " + width + " " + height,
58 +
      class: "chart-svg",
59 +
    });
60 +
61 +
    [min + pad, (min + max) / 2, max - pad].forEach(function (v) {
62 +
      var ly = y(v).toFixed(1);
63 +
      svg.appendChild(
64 +
        svgEl("line", {
65 +
          x1: padding.left,
66 +
          x2: width - padding.right,
67 +
          y1: ly,
68 +
          y2: ly,
69 +
          class: "chart-gridline",
70 +
        }),
71 +
      );
72 +
      var label = svgEl("text", {
73 +
        x: padding.left - 6,
74 +
        y: ly,
75 +
        class: "chart-axis-label",
76 +
        "text-anchor": "end",
77 +
        "dominant-baseline": "middle",
78 +
      });
79 +
      label.textContent = Number(v.toFixed(2));
80 +
      svg.appendChild(label);
81 +
    });
82 +
83 +
    var d = points
84 +
      .map(function (p, i) {
85 +
        return (i === 0 ? "M" : "L") + x(p.t).toFixed(1) + "," + y(p.v).toFixed(1);
86 +
      })
87 +
      .join(" ");
88 +
    svg.appendChild(svgEl("path", { d: d, class: "chart-line" }));
89 +
90 +
    points.forEach(function (p) {
91 +
      var cx = x(p.t).toFixed(1);
92 +
      var cy = y(p.v).toFixed(1);
93 +
      var dot = svgEl("circle", { cx: cx, cy: cy, r: 3, class: "chart-dot" });
94 +
      var title = svgEl("title", {});
95 +
      title.textContent = new Date(p.t * 1000).toLocaleString() + ": " + p.v + (unit ? " " + unit : "");
96 +
      dot.appendChild(title);
97 +
      svg.appendChild(dot);
98 +
    });
99 +
100 +
    el.appendChild(svg);
101 +
  }
102 +
103 +
  function renderHeatmap(el, points) {
104 +
    var days = {};
105 +
    points.forEach(function (p) {
106 +
      var key = dayKey(new Date(p.t * 1000));
107 +
      var truthy = p.v === 1;
108 +
      if (!days[key] || truthy) days[key] = truthy ? 2 : 1;
109 +
    });
110 +
111 +
    var keys = Object.keys(days).sort();
112 +
    var start = new Date(keys[0] + "T00:00:00");
113 +
    start.setDate(start.getDate() - start.getDay());
114 +
    var today = new Date();
115 +
    today.setHours(0, 0, 0, 0);
116 +
117 +
    var totalDays = Math.round((today - start) / 86400000) + 1;
118 +
    var weeks = Math.ceil(totalDays / 7);
119 +
    var cell = 11;
120 +
    var gap = 3;
121 +
    var step = cell + gap;
122 +
    var width = weeks * step;
123 +
    var height = 7 * step;
124 +
125 +
    var svg = svgEl("svg", {
126 +
      width: width,
127 +
      height: height,
128 +
      viewBox: "0 0 " + width + " " + height,
129 +
      class: "chart-svg",
130 +
    });
131 +
132 +
    for (var i = 0; i < totalDays; i++) {
133 +
      var d = new Date(start);
134 +
      d.setDate(d.getDate() + i);
135 +
      var key = dayKey(d);
136 +
      var status = days[key] || 0;
137 +
      var week = Math.floor(i / 7);
138 +
      var dow = i % 7;
139 +
140 +
      var rect = svgEl("rect", {
141 +
        x: week * step,
142 +
        y: dow * step,
143 +
        width: cell,
144 +
        height: cell,
145 +
        rx: 2,
146 +
        class: "chart-cell chart-cell-" + status,
147 +
      });
148 +
      var title = svgEl("title", {});
149 +
      title.textContent = key + ": " + (status === 2 ? "true" : status === 1 ? "false" : "no record");
150 +
      rect.appendChild(title);
151 +
      svg.appendChild(rect);
152 +
    }
153 +
154 +
    el.appendChild(svg);
155 +
  }
156 +
157 +
  function init() {
158 +
    var el = document.getElementById("habit-chart");
159 +
    if (!el) return;
160 +
161 +
    var points;
162 +
    try {
163 +
      points = JSON.parse(el.dataset.points || "[]");
164 +
    } catch (e) {
165 +
      return;
166 +
    }
167 +
    if (!points.length) return;
168 +
169 +
    if (el.dataset.type === "bool") {
170 +
      renderHeatmap(el, points);
171 +
    } else {
172 +
      renderLine(el, points, el.dataset.unit || "");
173 +
    }
174 +
  }
175 +
176 +
  init();
177 +
})();
apps/habbits/static/styles.css +48 −0
57 57
  opacity: 0.7;
58 58
  margin-bottom: 0.15rem;
59 59
}
60 +
61 +
/* Habit detail chart: SVG rendered client-side by chart.js. */
62 +
.chart {
63 +
  width: 100%;
64 +
  overflow-x: auto;
65 +
}
66 +
67 +
.chart-svg {
68 +
  display: block;
69 +
  max-width: 100%;
70 +
}
71 +
72 +
.chart-gridline {
73 +
  stroke: #333;
74 +
  stroke-width: 1;
75 +
}
76 +
77 +
.chart-axis-label {
78 +
  fill: #888;
79 +
  font-size: 10px;
80 +
}
81 +
82 +
.chart-line {
83 +
  fill: none;
84 +
  stroke: #ffffff;
85 +
  stroke-width: 1.5;
86 +
  opacity: 0.8;
87 +
}
88 +
89 +
.chart-dot {
90 +
  fill: #121113;
91 +
  stroke: #ffffff;
92 +
  stroke-width: 1.5;
93 +
}
94 +
95 +
.chart-cell-0 {
96 +
  fill: #1e1c1f;
97 +
  stroke: #333;
98 +
  stroke-width: 1;
99 +
}
100 +
101 +
.chart-cell-1 {
102 +
  fill: #555;
103 +
}
104 +
105 +
.chart-cell-2 {
106 +
  fill: #ffffff;
107 +
}
apps/habbits/templates/habit.html +14 −0
32 32
      {{if .Habit.Description}}<p class="admin-list-meta">{{.Habit.Description}}</p>{{end}}
33 33
    </section>
34 34
35 +
    {{if .ChartType}}
36 +
    <section class="section">
37 +
      <h2>{{if eq .ChartType "bool"}}Streak{{else}}Trend{{end}}</h2>
38 +
      <div
39 +
        id="habit-chart"
40 +
        class="chart"
41 +
        data-type="{{.ChartType}}"
42 +
        data-unit="{{.Habit.Unit}}"
43 +
        data-points="{{.ChartPoints}}"
44 +
      ></div>
45 +
    </section>
46 +
    {{end}}
47 +
35 48
    <section class="section">
36 49
      <h2>New record</h2>
37 50
      <form class="form" method="POST" action="/records">
122 135
        });
123 136
      })();
124 137
    </script>
138 +
    <script src="/static/chart.js"></script>
125 139
  </body>
126 140
</html>