Repository navigation
Expand file tree
/
Copy pathplot.html
More file actions
197 lines (186 loc) · 6.96 KB
/
Copy pathplot.html
File metadata and controls
197 lines (186 loc) · 6.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aeris</title>
<style>
html, body { margin: 0; }
body {
min-height: 100vh;
background: var(--tg-theme-bg-color, #fff);
color: var(--tg-theme-text-color, #000);
font-family: -apple-system, system-ui, sans-serif;
}
h1 {
font-size: 1.1rem; font-weight: 600;
margin: calc(16px + var(--tg-safe-area-inset-top, 0px)
+ var(--tg-content-safe-area-inset-top, 0px)) 16px 16px;
}
.chart { height: 65vh; padding: 0 12px 16px; }
#status {
margin: 12px 16px; font-size: 0.8rem; white-space: pre-wrap;
color: var(--tg-theme-hint-color, #999);
}
.error { color: #e53935 !important; }
</style>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<h1 id="title">Expenses</h1>
<div class="chart"><canvas id="chart"></canvas></div>
<pre id="status"></pre>
<script>
const status = document.getElementById("status");
function log(msg, isError) {
status.textContent = msg;
status.className = isError ? "error" : "";
}
window.addEventListener("error", (e) =>
log("JS error: " + (e.message || e.error), true));
(async function start() {
try {
const tg = window.Telegram && window.Telegram.WebApp;
if (tg) {
tg.ready();
tg.expand();
// Go edge-to-edge so it feels like an app screen, not a web viewer.
// Falls back to the expanded sheet on clients without fullscreen.
try { if (tg.requestFullscreen) tg.requestFullscreen(); } catch (e) {}
// Match Telegram's own header and background to the page so the webview
// blends into the chat instead of looking like a separate window.
if (tg.setHeaderColor) tg.setHeaderColor("bg_color");
if (tg.setBackgroundColor) tg.setBackgroundColor("bg_color");
}
// Telegram appends its own params to the fragment, so read our named ones
// (m=..., w=...) instead of the whole hash. Only the month count and the
// wallet travel in the URL; the figures come from the API, authenticated
// with the signed session Telegram hands the page.
const params = new URLSearchParams(location.hash.slice(1));
const initData = (tg && tg.initData) || params.get("tgWebAppData") || "";
const months = params.get("m") || "2";
const wallet = params.get("w");
log("Loading...");
const query = `months=${months}` + (wallet ? `&wallet=${wallet}` : "");
const res = await fetch(`${location.origin}/api/chart?${query}`,
{ headers: { "X-Telegram-Init-Data": initData } });
if (res.status === 401)
throw new Error("Not authorised. Open this page from the bot.");
if (!res.ok) throw new Error("Request failed (" + res.status + ")");
const data = await res.json();
log("");
document.getElementById("title").textContent = data.title || "Expenses";
if (typeof Chart === "undefined")
throw new Error("Chart.js did not load (CDN blocked?)");
const red = "#e53935";
const gray = "#888888";
const themeText = (tg && tg.themeParams && tg.themeParams.text_color) || "#000";
const hexToRgba = (hex, a) => {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
};
const points = (s) =>
(s.labels || []).map((d, i) => ({ x: d, y: (s.values || [])[i] }));
const series = data.series || [];
const current = series[0];
const past = series.slice(1); // index 0 = most recent previous … oldest last
const datasets = [];
// Past months as faded context, drawn oldest first so more recent months
// sit on top. Recency sets the shade: the most recent previous month is the
// strongest gray, older months fade toward the surface. Lines only, no
// fill, to keep the overlay readable.
for (let i = past.length - 1; i >= 0; i--) {
const t = past.length > 1 ? i / (past.length - 1) : 0; // 0 recent … 1 old
datasets.push({
label: past[i].label,
data: points(past[i]),
borderColor: hexToRgba(gray, 0.9 - 0.5 * t),
borderWidth: 2,
pointRadius: 0,
pointHitRadius: 0,
tension: 0.25,
fill: false,
});
}
// The current month is the hero: bold red with a light fill, drawn on top.
if (current) {
datasets.push({
label: current.label,
data: points(current),
borderColor: red,
backgroundColor: red + "33",
fill: true,
tension: 0.25,
borderWidth: 4,
pointRadius: 2,
pointHoverRadius: 5,
pointHitRadius: 14,
});
}
// A dashed reference line at the current month's total income.
if (data.income) {
datasets.push({
label: "Income",
isIncome: true,
data: [{ x: 0.5, y: data.income }, { x: 31.5, y: data.income }],
borderColor: themeText,
borderWidth: 2,
borderDash: [6, 6],
pointRadius: 0,
pointHitRadius: 0,
fill: false,
});
}
new Chart(document.getElementById("chart"), {
type: "line",
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: {
display: true,
position: "bottom",
labels: {
color: themeText,
usePointStyle: true,
boxWidth: 8,
boxHeight: 8,
},
},
tooltip: {
// Drop the income reference line from the tooltip. When it is the
// only thing under the cursor the item list is empty, so guard the
// title against that instead of reading items[0] blindly.
filter: (item) => !item.dataset.isIncome,
callbacks: {
title: (items) => (items.length ? "Day " + items[0].parsed.x : ""),
label: (item) =>
item.dataset.label + ": " + data.symbol + item.parsed.y.toLocaleString(),
},
},
},
scales: {
x: {
type: "linear",
min: 0.5,
max: 31.5,
title: { display: true, text: "Day of month" },
ticks: {
includeBounds: false,
callback: (v) => (Number.isInteger(v) ? v : ""),
},
},
y: {},
},
},
});
} catch (err) {
log("Error: " + err.message, true);
}
})();
</script>
</body>
</html>