matebot 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- matebot/__init__.py +3 -0
- matebot/bags.py +77 -0
- matebot/cli.py +293 -0
- matebot/commands.py +272 -0
- matebot/config.py +91 -0
- matebot/conversation.py +263 -0
- matebot/digest.py +80 -0
- matebot/hints.py +53 -0
- matebot/machine.py +195 -0
- matebot/messengers/__init__.py +28 -0
- matebot/messengers/base.py +58 -0
- matebot/messengers/discord.py +88 -0
- matebot/messengers/telegram.py +118 -0
- matebot/plot.py +100 -0
- matebot/sitegen.py +119 -0
- matebot/slog.py +225 -0
- matebot/state.py +42 -0
- matebot/sync.py +163 -0
- matebot/watcher.py +137 -0
- matebot/web/app.js +395 -0
- matebot/web/index.html +43 -0
- matebot/web/style.css +166 -0
- matebot/web/vendor/chart.umd.js +20 -0
- matebot/web/vendor/chartjs-plugin-annotation.min.js +7 -0
- matebot-0.2.0.dist-info/METADATA +232 -0
- matebot-0.2.0.dist-info/RECORD +29 -0
- matebot-0.2.0.dist-info/WHEEL +4 -0
- matebot-0.2.0.dist-info/entry_points.txt +2 -0
- matebot-0.2.0.dist-info/licenses/LICENSE +21 -0
matebot/web/app.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/* Shot Journal viewer — self-contained, no dependencies. */
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const $ = (sel, el = document) => el.querySelector(sel);
|
|
5
|
+
const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
6
|
+
|
|
7
|
+
let INDEX = null;
|
|
8
|
+
|
|
9
|
+
const fmtDate = ts => (ts > 1e9 ? new Date(ts * 1000).toLocaleString(undefined, {
|
|
10
|
+
year: "2-digit", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
|
|
11
|
+
}) : "—");
|
|
12
|
+
const stars = n => n
|
|
13
|
+
? `<span class="stars">${"★".repeat(n)}<span class="off">${"★".repeat(5 - n)}</span></span>`
|
|
14
|
+
: "";
|
|
15
|
+
|
|
16
|
+
async function boot() {
|
|
17
|
+
INDEX = await (await fetch("index.json")).json();
|
|
18
|
+
$("#title").textContent = INDEX.title || "Shot Journal";
|
|
19
|
+
document.title = INDEX.title || "Shot Journal";
|
|
20
|
+
$("#search").addEventListener("input", renderList);
|
|
21
|
+
window.addEventListener("hashchange", route);
|
|
22
|
+
renderList();
|
|
23
|
+
route();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderList() {
|
|
27
|
+
const q = $("#search").value.toLowerCase();
|
|
28
|
+
const rows = INDEX.shots
|
|
29
|
+
.filter(s => !q || `${s.bean} ${s.profile}`.toLowerCase().includes(q))
|
|
30
|
+
.map(s => `<tr data-id="${s.id}">
|
|
31
|
+
<td class="num">#${parseInt(s.id, 10)}</td>
|
|
32
|
+
<td>${fmtDate(s.ts)}</td>
|
|
33
|
+
<td>${esc(s.profile)}</td>
|
|
34
|
+
<td>${esc(s.bean)}</td>
|
|
35
|
+
<td class="num">${s.ratio ? "1:" + s.ratio : ""}</td>
|
|
36
|
+
<td class="num">${s.duration_s ? s.duration_s.toFixed(0) + "s" : ""}</td>
|
|
37
|
+
<td class="num">${s.peak_bar ? s.peak_bar.toFixed(1) + " bar" : ""}</td>
|
|
38
|
+
<td>${stars(s.rating)}</td>
|
|
39
|
+
</tr>`)
|
|
40
|
+
.join("");
|
|
41
|
+
$("#shot-table tbody").innerHTML = rows || `<tr><td colspan="8">No shots match.</td></tr>`;
|
|
42
|
+
for (const tr of document.querySelectorAll("#shot-table tbody tr[data-id]")) {
|
|
43
|
+
tr.addEventListener("click", () => { location.hash = tr.dataset.id; });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function esc(s) {
|
|
48
|
+
return String(s ?? "").replace(/[&<>"']/g, c => ({
|
|
49
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
50
|
+
})[c]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function route() {
|
|
54
|
+
const hash = location.hash.slice(1);
|
|
55
|
+
if (!hash) {
|
|
56
|
+
$("#detail-view").hidden = true;
|
|
57
|
+
$("#list-view").hidden = false;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const [id, compareId] = hash.split("+");
|
|
61
|
+
const shot = await (await fetch(`shots/${id}.json`)).json();
|
|
62
|
+
let compare = null;
|
|
63
|
+
if (compareId) {
|
|
64
|
+
try { compare = await (await fetch(`shots/${compareId}.json`)).json(); } catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
$("#list-view").hidden = true;
|
|
67
|
+
$("#detail-view").hidden = false; // unhide before rendering: charts measure their container
|
|
68
|
+
renderDetail(id, shot, compareId, compare);
|
|
69
|
+
window.scrollTo(0, 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderDetail(id, shot, compareId, compare) {
|
|
73
|
+
const h = shot.header, n = shot.notes || {};
|
|
74
|
+
$("#shot-title").textContent = `Shot #${parseInt(id, 10)} — ${h.profile}`;
|
|
75
|
+
const bits = [fmtDate(h.ts), `${h.duration_s.toFixed(0)}s`];
|
|
76
|
+
if (h.final_g) bits.push(`<b>${h.final_g.toFixed(1)} g</b> in the cup`);
|
|
77
|
+
if (n.ratio) bits.push(`<b>1:${esc(n.ratio)}</b>`);
|
|
78
|
+
if (n.rating) bits.push(stars(n.rating));
|
|
79
|
+
$("#shot-meta").innerHTML = bits.join(" · ");
|
|
80
|
+
|
|
81
|
+
// compare selector
|
|
82
|
+
const picker = $("#compare");
|
|
83
|
+
picker.innerHTML = `<option value="">compare with…</option>` + INDEX.shots
|
|
84
|
+
.filter(s => s.id !== id)
|
|
85
|
+
.map(s => `<option value="${s.id}" ${s.id === compareId ? "selected" : ""}>#${parseInt(s.id, 10)} · ${esc(s.profile)}${s.bean ? " · " + esc(s.bean) : ""}${s.rating ? " · " + "★".repeat(s.rating) : ""}</option>`)
|
|
86
|
+
.join("");
|
|
87
|
+
picker.onchange = () => { location.hash = picker.value ? `${id}+${picker.value}` : id; };
|
|
88
|
+
|
|
89
|
+
const charts = $("#charts");
|
|
90
|
+
charts.innerHTML = "";
|
|
91
|
+
const t = shot.series.t;
|
|
92
|
+
const S = (key) => shot.series[key] && shot.series[key].some(v => v !== 0) ? shot.series[key] : null;
|
|
93
|
+
|
|
94
|
+
let overlay = null;
|
|
95
|
+
if (compare) {
|
|
96
|
+
const cs = compare.series;
|
|
97
|
+
const O = k => cs[k] && cs[k].some(v => v !== 0) ? cs[k] : null;
|
|
98
|
+
overlay = { label: `#${parseInt(compareId, 10)}`, t: cs.t, S: O };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
combinedChart(charts, t, h.phases, S, overlay);
|
|
102
|
+
|
|
103
|
+
const dl = [];
|
|
104
|
+
const noteFields = [["Bean", n.beanType], ["Grind", n.grindSetting],
|
|
105
|
+
["Dose in", n.doseIn && n.doseIn + " g"], ["Dose out", n.doseOut && n.doseOut + " g"],
|
|
106
|
+
["Balance", n.balanceTaste]];
|
|
107
|
+
for (const [k, v] of noteFields) if (v) dl.push(`<dt>${k}</dt><dd>${esc(v)}</dd>`);
|
|
108
|
+
$("#shot-notes").innerHTML = dl.length || n.notes
|
|
109
|
+
? `<h3>Shot notes</h3>${dl.length ? `<dl>${dl.join("")}</dl>` : ""}` +
|
|
110
|
+
(n.notes ? `<div class="freetext">“${esc(n.notes)}”</div>` : "")
|
|
111
|
+
: "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ---- Shot chart, matching the GaggiMate web UI ----
|
|
115
|
+
* Visual design recreated from scratch to match the machine's own shot chart
|
|
116
|
+
* (GaggiMate's UI code is CC BY-NC-SA and was not copied; colors, axes and
|
|
117
|
+
* annotation styling are reimplemented from observation). Rendered with
|
|
118
|
+
* Chart.js + chartjs-plugin-annotation (both MIT, vendored locally).
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
const GM = {
|
|
122
|
+
temp: "#F0561D", targetTemp: "#731F00",
|
|
123
|
+
press: "#0066CC", flow: "#63993D", puck: "#204D00",
|
|
124
|
+
weight: "#8B5CF6", weightFlow: "#4b2e8d",
|
|
125
|
+
phase: "#6B7280",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function rgba(hex, alpha) {
|
|
129
|
+
const v = parseInt(hex.slice(1, 7), 16);
|
|
130
|
+
return `rgba(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255},${alpha})`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let CHART = null;
|
|
134
|
+
|
|
135
|
+
function combinedChart(parent, t, phases, S, overlay) {
|
|
136
|
+
if (!t || t.length < 2 || typeof Chart === "undefined") return;
|
|
137
|
+
Chart.register(window["chartjs-plugin-annotation"]);
|
|
138
|
+
|
|
139
|
+
const card = document.createElement("div");
|
|
140
|
+
card.className = "chart-card chartjs-card";
|
|
141
|
+
card.innerHTML = `<div class="chart-holder"><canvas></canvas></div>`;
|
|
142
|
+
parent.appendChild(card);
|
|
143
|
+
|
|
144
|
+
const pts = (data, tt = t) => data.map((y, i) => ({ x: tt[i], y }));
|
|
145
|
+
const ds = (label, data, color, opts = {}) => ({
|
|
146
|
+
label, data: pts(data), borderColor: color, backgroundColor: rgba(color, 0.06),
|
|
147
|
+
pointStyle: false, borderWidth: 3, ...opts,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const datasets = [
|
|
151
|
+
S("ct") && ds("Current Temperature", S("ct"), GM.temp, { yAxisID: "y" }),
|
|
152
|
+
S("tt") && ds("Target Temperature", S("tt"), GM.targetTemp,
|
|
153
|
+
{ yAxisID: "y", borderDash: [6, 6], fill: true }),
|
|
154
|
+
S("cp") && ds("Current Pressure", S("cp"), GM.press, { yAxisID: "y1" }),
|
|
155
|
+
S("tp") && ds("Target Pressure", S("tp"), GM.press,
|
|
156
|
+
{ yAxisID: "y1", borderDash: [6, 6], fill: true }),
|
|
157
|
+
S("fl") && ds("Current Pump Flow", S("fl"), GM.flow, { yAxisID: "y1" }),
|
|
158
|
+
S("pf") && ds("Current Puck Flow", S("pf"), GM.puck, { yAxisID: "y1" }),
|
|
159
|
+
S("tf") && ds("Target Pump Flow", S("tf"), GM.flow, { yAxisID: "y1", borderDash: [6, 6] }),
|
|
160
|
+
S("v") && ds("Weight", S("v"), GM.weight, { yAxisID: "y2" }),
|
|
161
|
+
S("vf") && ds("Weight Flow", S("vf"), GM.weightFlow, { yAxisID: "y1" }),
|
|
162
|
+
].filter(Boolean);
|
|
163
|
+
|
|
164
|
+
// comparison shot: same series, ghosted (dimmed + thinner), out of the legend
|
|
165
|
+
if (overlay) {
|
|
166
|
+
const O = overlay.S;
|
|
167
|
+
const ghost = (label, data, color, extra = {}) => data && {
|
|
168
|
+
label: `${overlay.label} ${label}`, data: pts(data, overlay.t),
|
|
169
|
+
borderColor: rgba(color, 0.42), pointStyle: false, borderWidth: 2,
|
|
170
|
+
_ghost: true, ...extra,
|
|
171
|
+
};
|
|
172
|
+
datasets.push(...[
|
|
173
|
+
ghost("Temp", O("ct"), GM.temp, { yAxisID: "y" }),
|
|
174
|
+
ghost("Pressure", O("cp"), GM.press, { yAxisID: "y1" }),
|
|
175
|
+
ghost("Pump Flow", O("fl"), GM.flow, { yAxisID: "y1" }),
|
|
176
|
+
ghost("Weight", O("v") || O("ev"), GM.weight, { yAxisID: "y2" }),
|
|
177
|
+
].filter(Boolean));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// y (temperature) range with the machine's padding rule
|
|
181
|
+
const temps = datasets.filter(d => d.yAxisID === "y").flatMap(d => d.data.map(p => p.y));
|
|
182
|
+
const tMin = Math.floor(Math.min(...temps)), tMax = Math.ceil(Math.max(...temps));
|
|
183
|
+
const pad = tMax - tMin > 10 ? 2 : 5;
|
|
184
|
+
|
|
185
|
+
const hasWeight = datasets.some(d => d.yAxisID === "y2");
|
|
186
|
+
const xMax = Math.max(t[t.length - 1], overlay ? overlay.t[overlay.t.length - 1] : 0);
|
|
187
|
+
|
|
188
|
+
const annotations = {};
|
|
189
|
+
(phases || []).forEach((p, i) => {
|
|
190
|
+
if (p.t >= xMax) return;
|
|
191
|
+
annotations[`phase_${i}`] = {
|
|
192
|
+
type: "line", xMin: p.t, xMax: p.t,
|
|
193
|
+
borderColor: GM.phase, borderWidth: 1,
|
|
194
|
+
label: {
|
|
195
|
+
display: true, content: p.name, rotation: -90, position: "end",
|
|
196
|
+
xAdjust: i === 0 ? -5 : -10, padding: { x: 6, y: 0 },
|
|
197
|
+
color: "rgb(255,255,255)", backgroundColor: "rgba(22,33,50,0.75)",
|
|
198
|
+
textAlign: "start", font: { size: 11, weight: 500 }, clip: false,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const ink2 = css("--ink-2"), grid = css("--grid");
|
|
204
|
+
CHART?.destroy();
|
|
205
|
+
CHART = new Chart(card.querySelector("canvas"), {
|
|
206
|
+
type: "line",
|
|
207
|
+
data: { datasets },
|
|
208
|
+
options: {
|
|
209
|
+
responsive: true, maintainAspectRatio: false, parsing: false,
|
|
210
|
+
spanGaps: true, animation: false, normalized: true,
|
|
211
|
+
interaction: { mode: "index", intersect: false },
|
|
212
|
+
scales: {
|
|
213
|
+
x: {
|
|
214
|
+
type: "linear", min: t[0], max: xMax,
|
|
215
|
+
title: { display: true, text: "Time (s)", color: ink2 },
|
|
216
|
+
ticks: { color: ink2, font: { size: 12 } }, grid: { color: grid },
|
|
217
|
+
},
|
|
218
|
+
y: {
|
|
219
|
+
min: Math.max(tMin - pad, 0), max: tMax + pad,
|
|
220
|
+
ticks: { color: ink2, callback: v => `${v.toFixed()} °C` },
|
|
221
|
+
grid: { color: grid },
|
|
222
|
+
},
|
|
223
|
+
y1: {
|
|
224
|
+
position: "right", min: 0, max: 16,
|
|
225
|
+
ticks: { color: ink2, callback: v => `${v.toFixed()} bar / g/s` },
|
|
226
|
+
grid: { drawOnChartArea: false },
|
|
227
|
+
},
|
|
228
|
+
...(hasWeight ? {
|
|
229
|
+
y2: {
|
|
230
|
+
position: "right", min: 0, offset: true,
|
|
231
|
+
// fixed max so the axis doesn't rescale during playback
|
|
232
|
+
max: Math.ceil(Math.max(...datasets.filter(d => d.yAxisID === "y2")
|
|
233
|
+
.flatMap(d => d.data.map(p => p.y))) * 1.05),
|
|
234
|
+
ticks: { color: ink2, callback: v => `${v.toFixed()} g` },
|
|
235
|
+
grid: { drawOnChartArea: false },
|
|
236
|
+
},
|
|
237
|
+
} : {}),
|
|
238
|
+
},
|
|
239
|
+
plugins: {
|
|
240
|
+
tooltip: { filter: item => !CHART?.data.datasets[item.datasetIndex]?._ghost },
|
|
241
|
+
annotation: { annotations },
|
|
242
|
+
legend: {
|
|
243
|
+
position: "top",
|
|
244
|
+
labels: {
|
|
245
|
+
usePointStyle: true, pointStyle: "line", pointStyleWidth: 20,
|
|
246
|
+
padding: 8, color: ink2,
|
|
247
|
+
filter: item => !CHART?.data.datasets[item.datasetIndex]?._ghost,
|
|
248
|
+
generateLabels: chart => {
|
|
249
|
+
const labels = Chart.defaults.plugins.legend.labels.generateLabels(chart)
|
|
250
|
+
.filter(l => !chart.data.datasets[l.datasetIndex]._ghost);
|
|
251
|
+
for (const l of labels) {
|
|
252
|
+
l.lineWidth = 3;
|
|
253
|
+
l.lineDash = chart.data.datasets[l.datasetIndex].borderDash || [];
|
|
254
|
+
}
|
|
255
|
+
return labels;
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
attachPlayer(card, t, xMax, S, overlay);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ---- Shot playback: replay the shot in real time ---- */
|
|
267
|
+
|
|
268
|
+
const ICON_PLAY = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>`;
|
|
269
|
+
const ICON_PAUSE = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>`;
|
|
270
|
+
const ICON_EXPAND = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>`;
|
|
271
|
+
|
|
272
|
+
function attachPlayer(card, t, xMax, S, overlay) {
|
|
273
|
+
const controls = document.createElement("div");
|
|
274
|
+
controls.className = "player";
|
|
275
|
+
controls.innerHTML = `
|
|
276
|
+
<button class="pbtn" id="play" title="Replay the shot" aria-label="Play">${ICON_PLAY}</button>
|
|
277
|
+
<input type="range" id="scrub" min="0" max="${xMax}" step="0.05" value="${xMax}">
|
|
278
|
+
<span class="ptime" id="ptime">${xMax.toFixed(1)}s</span>
|
|
279
|
+
<select id="speed" title="Playback speed">
|
|
280
|
+
<option value="1">1×</option><option value="2">2×</option>
|
|
281
|
+
<option value="4">4×</option><option value="8">8×</option>
|
|
282
|
+
</select>
|
|
283
|
+
<button class="pbtn" id="theater" title="Full screen" aria-label="Full screen">${ICON_EXPAND}</button>`;
|
|
284
|
+
card.prepend(controls);
|
|
285
|
+
|
|
286
|
+
const tiles = document.createElement("div");
|
|
287
|
+
tiles.className = "readouts";
|
|
288
|
+
const TILES = [
|
|
289
|
+
["cp", "Pressure", "bar", GM.press], ["fl", "Flow", "g/s", GM.flow],
|
|
290
|
+
["ct", "Temp", "°C", GM.temp], [S("v") ? "v" : "ev", "Weight", "g", GM.weight],
|
|
291
|
+
].filter(([k]) => S(k));
|
|
292
|
+
tiles.innerHTML = TILES.map(([k, label, unit, color]) =>
|
|
293
|
+
`<div class="tile"><div class="tlabel">${label}</div>
|
|
294
|
+
<div class="tval" id="tile-${k}" style="color:${color}">–<span class="tunit"> ${unit}</span></div></div>`
|
|
295
|
+
).join("");
|
|
296
|
+
controls.after(tiles);
|
|
297
|
+
|
|
298
|
+
const full = CHART.data.datasets.map(d => d.data);
|
|
299
|
+
const times = CHART.data.datasets.map(d => d.data.map(p => p.x));
|
|
300
|
+
CHART.options.plugins.annotation.annotations.playhead = {
|
|
301
|
+
type: "line", xMin: xMax, xMax: xMax, borderColor: "rgba(148,163,184,0.9)",
|
|
302
|
+
borderWidth: 1.5, display: false,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const idxAt = time => {
|
|
306
|
+
let i = t.length - 1;
|
|
307
|
+
while (i > 0 && t[i] > time) i--;
|
|
308
|
+
return i;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
function renderAt(time, playing) {
|
|
312
|
+
CHART.data.datasets.forEach((d, di) => {
|
|
313
|
+
let n = times[di].length;
|
|
314
|
+
if (time < xMax) {
|
|
315
|
+
n = 0;
|
|
316
|
+
while (n < times[di].length && times[di][n] <= time) n++;
|
|
317
|
+
}
|
|
318
|
+
d.data = full[di].slice(0, Math.max(n, 1));
|
|
319
|
+
});
|
|
320
|
+
const ph = CHART.options.plugins.annotation.annotations.playhead;
|
|
321
|
+
ph.display = playing || time < xMax;
|
|
322
|
+
ph.xMin = ph.xMax = time;
|
|
323
|
+
CHART.update("none");
|
|
324
|
+
const i = idxAt(time);
|
|
325
|
+
for (const [k] of TILES) {
|
|
326
|
+
const el = $(`#tile-${k}`);
|
|
327
|
+
const unit = el.querySelector(".tunit").outerHTML;
|
|
328
|
+
el.innerHTML = (S(k)[Math.min(i, S(k).length - 1)] ?? 0).toFixed(1) + unit;
|
|
329
|
+
}
|
|
330
|
+
$("#ptime").textContent = `${Math.min(time, xMax).toFixed(1)}s`;
|
|
331
|
+
$("#scrub").value = Math.min(time, xMax);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let raf = null, playT = xMax, lastTs = null;
|
|
335
|
+
const playBtn = $("#play");
|
|
336
|
+
|
|
337
|
+
function stop() {
|
|
338
|
+
if (raf) cancelAnimationFrame(raf);
|
|
339
|
+
raf = null; lastTs = null;
|
|
340
|
+
playBtn.innerHTML = ICON_PLAY;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function tick(ts) {
|
|
344
|
+
if (lastTs != null) {
|
|
345
|
+
playT += ((ts - lastTs) / 1000) * Number($("#speed").value);
|
|
346
|
+
if (playT >= xMax) {
|
|
347
|
+
playT = xMax;
|
|
348
|
+
renderAt(playT, false);
|
|
349
|
+
stop();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
renderAt(playT, true);
|
|
353
|
+
}
|
|
354
|
+
lastTs = ts;
|
|
355
|
+
raf = requestAnimationFrame(tick);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
playBtn.addEventListener("click", () => {
|
|
359
|
+
if (raf) { stop(); return; }
|
|
360
|
+
if (playT >= xMax) playT = 0;
|
|
361
|
+
playBtn.innerHTML = ICON_PAUSE;
|
|
362
|
+
raf = requestAnimationFrame(tick);
|
|
363
|
+
});
|
|
364
|
+
$("#scrub").addEventListener("input", () => {
|
|
365
|
+
stop();
|
|
366
|
+
playT = Number($("#scrub").value);
|
|
367
|
+
renderAt(playT, true);
|
|
368
|
+
});
|
|
369
|
+
$("#theater").addEventListener("click", () => {
|
|
370
|
+
card.classList.toggle("theater");
|
|
371
|
+
document.body.classList.toggle("theater-open");
|
|
372
|
+
CHART.resize();
|
|
373
|
+
});
|
|
374
|
+
document.addEventListener("keydown", e => {
|
|
375
|
+
if (e.key === "Escape" && card.classList.contains("theater")) {
|
|
376
|
+
card.classList.remove("theater");
|
|
377
|
+
document.body.classList.remove("theater-open");
|
|
378
|
+
CHART.resize();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
renderAt(xMax, false);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function niceTicks(min, max, count) {
|
|
385
|
+
const span = max - min || 1;
|
|
386
|
+
const step = [1, 2, 2.5, 5, 10].map(s => s * 10 ** Math.floor(Math.log10(span / count)))
|
|
387
|
+
.find(s => span / s <= count + 1) || span;
|
|
388
|
+
const ticks = [];
|
|
389
|
+
for (let v = Math.ceil(min / step) * step; v <= max; v += step) {
|
|
390
|
+
ticks.push(+v.toFixed(6));
|
|
391
|
+
}
|
|
392
|
+
return ticks;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
boot();
|
matebot/web/index.html
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Shot Journal</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☕</text></svg>">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header>
|
|
12
|
+
<h1 id="title">Shot Journal</h1>
|
|
13
|
+
<p class="sub">Logged by <a href="https://github.com/AlexNly/matebot">matebot</a>, straight from a GaggiMate.</p>
|
|
14
|
+
</header>
|
|
15
|
+
<main>
|
|
16
|
+
<section id="list-view">
|
|
17
|
+
<div class="filters">
|
|
18
|
+
<input id="search" type="search" placeholder="Filter by bean or profile…" aria-label="Filter shots">
|
|
19
|
+
</div>
|
|
20
|
+
<table id="shot-table">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr><th>Shot</th><th>Date</th><th>Profile</th><th>Bean</th><th class="num">Ratio</th><th class="num">Time</th><th class="num">Peak</th><th>Rating</th></tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody></tbody>
|
|
25
|
+
</table>
|
|
26
|
+
</section>
|
|
27
|
+
<section id="detail-view" hidden>
|
|
28
|
+
<a href="#" id="back">← all shots</a>
|
|
29
|
+
<h2 id="shot-title"></h2>
|
|
30
|
+
<div id="shot-meta" class="meta"></div>
|
|
31
|
+
<div class="compare-row"><select id="compare" aria-label="Compare with another shot"></select></div>
|
|
32
|
+
<div id="charts"></div>
|
|
33
|
+
<div id="shot-notes" class="notes"></div>
|
|
34
|
+
</section>
|
|
35
|
+
</main>
|
|
36
|
+
<footer>
|
|
37
|
+
<p>Generated by <a href="https://github.com/AlexNly/matebot">matebot</a> · data lives in this repo, no trackers, no cloud.</p>
|
|
38
|
+
</footer>
|
|
39
|
+
<script src="vendor/chart.umd.js"></script>
|
|
40
|
+
<script src="vendor/chartjs-plugin-annotation.min.js"></script>
|
|
41
|
+
<script src="app.js"></script>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
matebot/web/style.css
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--surface: #fcfcfb;
|
|
3
|
+
--page: #f9f9f7;
|
|
4
|
+
--ink: #0b0b0b;
|
|
5
|
+
--ink-2: #52514e;
|
|
6
|
+
--muted: #898781;
|
|
7
|
+
--grid: #e1e0d9;
|
|
8
|
+
--axis: #c3c2b7;
|
|
9
|
+
--border: rgba(11, 11, 11, 0.1);
|
|
10
|
+
--s1: #2a78d6; /* blue */
|
|
11
|
+
--s2: #1baf7a; /* aqua */
|
|
12
|
+
--s3: #eda100; /* yellow */
|
|
13
|
+
--star: #eda100;
|
|
14
|
+
/* GaggiMate-style series colors (combined shot chart) */
|
|
15
|
+
--c-temp: #e64a19;
|
|
16
|
+
--c-press: #1e88e5;
|
|
17
|
+
--c-flow: #43a047;
|
|
18
|
+
--c-puck: #1b5e20;
|
|
19
|
+
--c-weight: #8e24aa;
|
|
20
|
+
--c-wflow: #7c6bd6;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root {
|
|
24
|
+
--surface: #1a1a19;
|
|
25
|
+
--page: #0d0d0d;
|
|
26
|
+
--ink: #ffffff;
|
|
27
|
+
--ink-2: #c3c2b7;
|
|
28
|
+
--muted: #898781;
|
|
29
|
+
--grid: #2c2c2a;
|
|
30
|
+
--axis: #383835;
|
|
31
|
+
--border: rgba(255, 255, 255, 0.1);
|
|
32
|
+
--s1: #3987e5;
|
|
33
|
+
--s2: #199e70;
|
|
34
|
+
--s3: #c98500;
|
|
35
|
+
--star: #c98500;
|
|
36
|
+
--c-temp: #ff7043;
|
|
37
|
+
--c-press: #42a5f5;
|
|
38
|
+
--c-flow: #c0ca33;
|
|
39
|
+
--c-puck: #388e3c;
|
|
40
|
+
--c-weight: #ba68c8;
|
|
41
|
+
--c-wflow: #9b8cf0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
body {
|
|
47
|
+
margin: 0;
|
|
48
|
+
background: var(--page);
|
|
49
|
+
color: var(--ink);
|
|
50
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
51
|
+
}
|
|
52
|
+
a { color: var(--s1); text-decoration: none; }
|
|
53
|
+
a:hover { text-decoration: underline; }
|
|
54
|
+
|
|
55
|
+
header, main, footer { max-width: 900px; margin: 0 auto; padding: 0 16px; }
|
|
56
|
+
header { padding-top: 28px; }
|
|
57
|
+
h1 { margin: 0; font-size: 26px; }
|
|
58
|
+
.sub { margin: 4px 0 20px; color: var(--ink-2); }
|
|
59
|
+
footer { padding: 24px 16px 40px; color: var(--muted); font-size: 13px; }
|
|
60
|
+
|
|
61
|
+
.filters { margin-bottom: 12px; }
|
|
62
|
+
#search {
|
|
63
|
+
width: 100%; max-width: 340px; padding: 8px 12px;
|
|
64
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
65
|
+
background: var(--surface); color: var(--ink); font: inherit;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
table { width: 100%; border-collapse: collapse; background: var(--surface);
|
|
69
|
+
border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
70
|
+
th, td { padding: 8px 10px; text-align: left; }
|
|
71
|
+
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase;
|
|
72
|
+
letter-spacing: 0.04em; border-bottom: 1px solid var(--grid); }
|
|
73
|
+
td { border-bottom: 1px solid var(--grid); }
|
|
74
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
75
|
+
tbody tr { cursor: pointer; }
|
|
76
|
+
tbody tr:hover { background: color-mix(in oklab, var(--s1) 7%, var(--surface)); }
|
|
77
|
+
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
78
|
+
td.num { color: var(--ink-2); }
|
|
79
|
+
.stars { color: var(--star); letter-spacing: 1px; white-space: nowrap; }
|
|
80
|
+
.stars .off { color: var(--grid); }
|
|
81
|
+
|
|
82
|
+
#back { display: inline-block; margin: 4px 0 8px; color: var(--ink-2); }
|
|
83
|
+
h2 { margin: 4px 0 2px; font-size: 21px; }
|
|
84
|
+
.meta { color: var(--ink-2); margin-bottom: 16px; }
|
|
85
|
+
.meta b { color: var(--ink); font-weight: 600; }
|
|
86
|
+
|
|
87
|
+
.chartjs-card .chart-holder { position: relative; height: 460px; }
|
|
88
|
+
@media (max-width: 640px) { .chartjs-card .chart-holder { height: 360px; } }
|
|
89
|
+
|
|
90
|
+
.compare-row { margin: -6px 0 12px; }
|
|
91
|
+
#compare {
|
|
92
|
+
padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px;
|
|
93
|
+
background: var(--surface); color: var(--ink-2); font: inherit; max-width: 100%;
|
|
94
|
+
}
|
|
95
|
+
.legend .overlay-note { color: var(--muted); font-style: italic; }
|
|
96
|
+
|
|
97
|
+
.chart-card { background: var(--surface); border: 1px solid var(--border);
|
|
98
|
+
border-radius: 10px; padding: 14px 14px 6px; margin-bottom: 14px; position: relative; }
|
|
99
|
+
.chart-card h3 { margin: 0 0 2px; font-size: 14px; }
|
|
100
|
+
.legend { display: flex; flex-wrap: wrap; gap: 12px; margin: 2px 0 6px;
|
|
101
|
+
color: var(--ink-2); font-size: 12px; }
|
|
102
|
+
.legend .chip { display: inline-block; width: 10px; height: 10px; border-radius: 3px;
|
|
103
|
+
margin-right: 5px; vertical-align: -1px; }
|
|
104
|
+
.legend .dash { background: none !important; border-top: 2px dashed currentColor;
|
|
105
|
+
height: 0; width: 12px; vertical-align: 2px; }
|
|
106
|
+
svg text { font: 11px system-ui, sans-serif; fill: var(--muted); }
|
|
107
|
+
svg .phase-label { font-size: 10px; fill: var(--ink-2); }
|
|
108
|
+
svg .axis-title { font-size: 9px; }
|
|
109
|
+
|
|
110
|
+
.tooltip {
|
|
111
|
+
position: absolute; pointer-events: none; background: var(--surface);
|
|
112
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px;
|
|
113
|
+
font-size: 12px; color: var(--ink-2); box-shadow: 0 2px 10px rgba(0,0,0,0.18);
|
|
114
|
+
white-space: nowrap; z-index: 2; display: none;
|
|
115
|
+
}
|
|
116
|
+
.tooltip b { color: var(--ink); font-variant-numeric: tabular-nums; }
|
|
117
|
+
|
|
118
|
+
.notes { background: var(--surface); border: 1px solid var(--border);
|
|
119
|
+
border-radius: 10px; padding: 14px 16px; }
|
|
120
|
+
.notes h3 { margin: 0 0 8px; font-size: 14px; }
|
|
121
|
+
.notes dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 0; }
|
|
122
|
+
.notes dt { color: var(--muted); }
|
|
123
|
+
.notes dd { margin: 0; }
|
|
124
|
+
.notes .freetext { margin-top: 10px; border-top: 1px solid var(--grid); padding-top: 10px;
|
|
125
|
+
color: var(--ink-2); font-style: italic; }
|
|
126
|
+
|
|
127
|
+
@media (max-width: 640px) {
|
|
128
|
+
th:nth-child(2), td:nth-child(2), th:nth-child(7), td:nth-child(7) { display: none; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ---- shot player ---- */
|
|
132
|
+
.player { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
|
133
|
+
.pbtn {
|
|
134
|
+
min-width: 42px; height: 42px; border-radius: 10px; border: 1px solid var(--border);
|
|
135
|
+
background: var(--page); color: var(--ink); font-size: 17px; cursor: pointer;
|
|
136
|
+
}
|
|
137
|
+
.pbtn:hover { background: color-mix(in oklab, var(--s1) 10%, var(--page)); }
|
|
138
|
+
#scrub { flex: 1; accent-color: var(--s1); min-height: 42px; }
|
|
139
|
+
.ptime { font-variant-numeric: tabular-nums; color: var(--ink-2); min-width: 52px; }
|
|
140
|
+
#speed {
|
|
141
|
+
height: 42px; border-radius: 10px; border: 1px solid var(--border);
|
|
142
|
+
background: var(--page); color: var(--ink); font: inherit; padding: 0 6px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.readouts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 10px; }
|
|
146
|
+
.tile {
|
|
147
|
+
background: var(--page); border: 1px solid var(--border); border-radius: 10px;
|
|
148
|
+
padding: 6px 10px; text-align: center;
|
|
149
|
+
}
|
|
150
|
+
.tlabel { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
151
|
+
.tval { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
152
|
+
.tunit { font-size: 12px; font-weight: 400; color: var(--muted); }
|
|
153
|
+
|
|
154
|
+
/* theater (pseudo-fullscreen, works on phones) */
|
|
155
|
+
.chart-card.theater {
|
|
156
|
+
position: fixed; inset: 0; z-index: 50; margin: 0; border-radius: 0; border: none;
|
|
157
|
+
overflow-y: auto; padding: max(10px, env(safe-area-inset-top)) 12px 12px;
|
|
158
|
+
}
|
|
159
|
+
.chart-card.theater .chart-holder { height: calc(100vh - 190px); }
|
|
160
|
+
body.theater-open { overflow: hidden; }
|
|
161
|
+
|
|
162
|
+
@media (max-width: 640px) {
|
|
163
|
+
.readouts { grid-template-columns: repeat(2, 1fr); }
|
|
164
|
+
.ptime { display: none; }
|
|
165
|
+
.chart-card.theater .chart-holder { height: calc(100vh - 230px); }
|
|
166
|
+
}
|