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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
//! Graph viewport: the visible time window and how it pans, zooms, and jumps.
//!
//! The window is defined by a [`Span`] (its width) and an anchor for its right
//! edge. When `end` is `None` the window follows "now" (live mode); once the
//! user pans or jumps into history it holds a fixed `Some(epoch_ms)` end.
use chrono::{Local, NaiveDate, TimeZone};
const MS_PER_MIN: i64 = 60_000;
const MS_PER_DAY: i64 = 24 * 60 * MS_PER_MIN;
/// Selectable widths for the visible time window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Span {
H1,
H3,
H6,
H12,
H24,
}
impl Span {
/// Ordered widest-to-narrowest for cycling.
const ORDER: [Span; 5] = [Span::H1, Span::H3, Span::H6, Span::H12, Span::H24];
pub fn minutes(self) -> i64 {
match self {
Span::H1 => 60,
Span::H3 => 180,
Span::H6 => 360,
Span::H12 => 720,
Span::H24 => 1440,
}
}
pub fn label(self) -> &'static str {
match self {
Span::H1 => "1h",
Span::H3 => "3h",
Span::H6 => "6h",
Span::H12 => "12h",
Span::H24 => "24h",
}
}
/// Widen the window (fewer detail, more history on screen).
pub fn wider(self) -> Self {
let i = Self::ORDER.iter().position(|&s| s == self).unwrap_or(0);
Self::ORDER[(i + 1).min(Self::ORDER.len() - 1)]
}
/// Narrow the window (more detail).
pub fn narrower(self) -> Self {
let i = Self::ORDER.iter().position(|&s| s == self).unwrap_or(0);
Self::ORDER[i.saturating_sub(1)]
}
/// How many entries to request to comfortably fill this span, assuming
/// readings as frequent as one per minute, with slack. Nightscout caps the
/// count server-side, so over-asking is safe.
pub fn fetch_count(self) -> usize {
(self.minutes() as usize + 60) * 2
}
}
/// The current viewport over the entry history.
#[derive(Debug, Clone, Copy)]
pub struct View {
pub span: Span,
/// Right edge of the window in epoch ms, or `None` to follow "now".
pub end: Option<i64>,
}
impl Default for View {
fn default() -> Self {
Self {
span: Span::H3,
end: None,
}
}
}
impl View {
/// True when the window is anchored to real time.
pub fn is_live(self) -> bool {
self.end.is_none()
}
/// Concrete `(start_ms, end_ms)` for the window given the current time.
pub fn bounds(self, now_ms: i64) -> (i64, i64) {
let end = self.end.unwrap_or(now_ms);
(end - self.span.minutes() * MS_PER_MIN, end)
}
/// Half a span; the step used when panning.
fn step(self) -> i64 {
self.span.minutes() * MS_PER_MIN / 2
}
/// Pan toward older data.
pub fn pan_back(&mut self, now_ms: i64) {
self.shift_back(now_ms, self.step());
}
/// Pan toward newer data; snapping back to live once it reaches now.
pub fn pan_forward(&mut self, now_ms: i64) {
self.shift_forward(now_ms, self.step());
}
/// Pan a whole window at a time — the keyboard equivalent of dragging the
/// overview strip, so crossing a day doesn't take a dozen keypresses.
pub fn page_back(&mut self, now_ms: i64) {
self.shift_back(now_ms, self.span.minutes() * MS_PER_MIN);
}
/// Pan a whole window toward newer data, snapping to live at the edge.
pub fn page_forward(&mut self, now_ms: i64) {
self.shift_forward(now_ms, self.span.minutes() * MS_PER_MIN);
}
fn shift_back(&mut self, now_ms: i64, by: i64) {
let end = self.end.unwrap_or(now_ms);
self.end = Some(end - by);
}
fn shift_forward(&mut self, now_ms: i64, by: i64) {
if let Some(end) = self.end {
let next = end + by;
self.end = if next >= now_ms { None } else { Some(next) };
}
}
/// Step the window a whole day back or forward, keeping the same
/// time-of-day. Checking "how was last night?" or walking back through a
/// week shouldn't mean typing a date each time.
///
/// From the live edge, going back a day anchors on now first, so the window
/// covers the same clock hours as the one you were just looking at.
pub fn shift_day(&mut self, days: i64, now_ms: i64) {
let by = days.abs() * MS_PER_DAY;
if days < 0 {
self.shift_back(now_ms, by);
} else {
// Already live: there's no tomorrow to go to.
self.shift_forward(now_ms, by);
}
}
/// Jump to the oldest edge of a window `span_ms` wide ending now — the
/// keyboard equivalent of clicking the far-left of the overview strip.
pub fn jump_to_oldest(&mut self, now_ms: i64, span_ms: i64) {
let end = now_ms - span_ms + self.span.minutes() * MS_PER_MIN;
self.end = if end >= now_ms { None } else { Some(end) };
}
/// Snap the window back to the live edge.
pub fn follow(&mut self) {
self.end = None;
}
pub fn zoom_out(&mut self) {
self.span = self.span.wider();
}
pub fn zoom_in(&mut self) {
self.span = self.span.narrower();
}
/// Jump to a calendar day: show that whole day at 24h zoom. Clamped to now
/// so a today/future date lands back in live mode.
pub fn jump_to(&mut self, date: NaiveDate, now_ms: i64) {
self.span = Span::H24;
// Right edge at local midnight following the chosen day.
let next_midnight = date
.succ_opt()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.and_then(|dt| Local.from_local_datetime(&dt).single())
.map(|dt| dt.timestamp_millis());
self.end = match next_midnight {
Some(ms) if ms < now_ms => Some(ms),
_ => None,
};
}
}
/// Parse a `YYYY-MM-DD` string into a date.
pub fn parse_date(s: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_700_000_000_000; // fixed epoch ms
const HOUR: i64 = 3_600_000;
#[test]
fn live_window_ends_at_now() {
let v = View::default();
let (start, end) = v.bounds(NOW);
assert_eq!(end, NOW);
assert_eq!(start, NOW - 3 * HOUR); // default span is 3h
}
#[test]
fn pan_back_anchors_and_shifts_by_half_span() {
let mut v = View::default();
v.pan_back(NOW);
assert!(!v.is_live());
// 3h span -> 1.5h step
assert_eq!(v.end, Some(NOW - 90 * 60_000));
}
#[test]
fn pan_forward_snaps_back_to_live() {
let mut v = View::default();
v.pan_back(NOW); // now at NOW - 1.5h
v.pan_forward(NOW); // +1.5h reaches NOW -> live
assert!(v.is_live());
}
#[test]
fn zoom_cycles_and_clamps() {
let mut v = View::default(); // H3
v.zoom_in(); // H1
assert_eq!(v.span, Span::H1);
v.zoom_in(); // clamp at narrowest
assert_eq!(v.span, Span::H1);
v.zoom_out(); // H3
v.zoom_out();
v.zoom_out();
v.zoom_out(); // H24
v.zoom_out(); // clamp at widest
assert_eq!(v.span, Span::H24);
}
#[test]
fn jump_to_past_day_uses_24h_and_anchors() {
let mut v = View::default();
let date = parse_date("2000-01-01").unwrap();
v.jump_to(date, NOW);
assert_eq!(v.span, Span::H24);
assert!(!v.is_live());
let (start, end) = v.bounds(NOW);
assert_eq!(end - start, 24 * HOUR);
}
#[test]
fn jump_to_future_day_falls_back_to_live() {
let mut v = View::default();
let date = parse_date("2099-01-01").unwrap();
v.jump_to(date, NOW);
assert!(v.is_live());
}
#[test]
fn parse_date_rejects_garbage() {
assert!(parse_date("not-a-date").is_none());
assert!(parse_date("2026-13-40").is_none());
assert!(parse_date("2026-07-16").is_some());
}
#[test]
fn paging_steps_a_whole_window() {
let mut v = View::default(); // 3h
v.page_back(NOW);
assert_eq!(v.end, Some(NOW - 180 * 60_000));
v.page_forward(NOW);
assert!(v.is_live()); // back at the edge
}
#[test]
fn jump_to_oldest_lands_inside_the_overview() {
let mut v = View::default(); // 3h window
let span = 24 * 60 * 60_000; // 24h overview
v.jump_to_oldest(NOW, span);
// The window sits at the far edge of the overview, not beyond it.
let (start, end) = v.bounds(NOW);
assert_eq!(start, NOW - span);
assert!(end < NOW);
// An overview no wider than the window is already live.
let mut v = View::default();
v.jump_to_oldest(NOW, 60 * 60_000);
assert!(v.is_live());
}
#[test]
fn day_steps_keep_the_time_of_day() {
let mut v = View::default(); // 3h window, live
v.shift_day(-1, NOW);
// Same clock hours as the live window, 24h earlier.
let (start, end) = v.bounds(NOW);
assert_eq!(end, NOW - 24 * 60 * 60_000);
assert_eq!(end - start, 3 * 60 * 60_000);
// Two days back, then two forward, is where we started.
v.shift_day(-1, NOW);
v.shift_day(1, NOW);
v.shift_day(1, NOW);
assert!(v.is_live());
}
#[test]
fn there_is_no_tomorrow() {
let mut v = View::default();
// Going forward from live stays live rather than into the future.
v.shift_day(1, NOW);
assert!(v.is_live());
// And a step forward that would overshoot now snaps to live.
v.shift_day(-1, NOW);
v.pan_forward(NOW); // part-way back
v.shift_day(1, NOW);
assert!(v.is_live());
}
}