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
//! 1.2.18+ R.3 — reading-time computation for the
//! status-bar chip (and the R.4 reader-pace preview).
//!
//! Converts word counts to read-aloud / silent-reading
//! durations at a configurable words-per-minute, and
//! computes a per-book breakdown: total length + the
//! time remaining from the open paragraph to the book's
//! end.
//!
//! Pure + cheap — `compute` walks one book's paragraph
//! subtree (O(n) via the I.1.5 children index) and sums
//! the cached `Node::word_count` per paragraph, so it's
//! safe to call on the status-bar render path.
use uuid::Uuid;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
/// Per-book reading-time breakdown.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BookReadingTime {
/// Whole-book read time in seconds.
pub total_secs: u64,
/// Read time from the open paragraph (inclusive) to
/// the book's end, in seconds.
pub remaining_secs: u64,
/// Total words in the book (for diagnostics / the
/// preview).
pub total_words: u64,
}
/// Convert a word count to seconds at `wpm`. `wpm == 0`
/// is treated as "no estimate" → 0.
pub(crate) fn reading_secs(words: u64, wpm: u32) -> u64 {
if wpm == 0 {
return 0;
}
words.saturating_mul(60) / wpm as u64
}
/// Compute the reading-time breakdown for the book
/// containing `open_paragraph_id`. Returns `None` when
/// the id isn't in the hierarchy or isn't inside a book.
pub(crate) fn compute(
h: &Hierarchy,
open_paragraph_id: Uuid,
wpm: u32,
) -> Option<BookReadingTime> {
let open = h.get(open_paragraph_id)?;
let book = root_book_of(h, open)?;
// Paragraph word counts in display (pre-order) order.
let para_words: Vec<(Uuid, u64)> = h
.collect_subtree(book.id)
.into_iter()
.filter_map(|id| h.get(id))
.filter(|n| n.kind == NodeKind::Paragraph)
.map(|n| (n.id, n.word_count))
.collect();
let total_words: u64 = para_words.iter().map(|(_, w)| w).sum();
// Remaining = from the open paragraph to the end.
// When the open node isn't a paragraph in this book
// (e.g. cursor on a branch), fall back to the full
// total so the chip still shows something sensible.
let remaining_words: u64 = match para_words
.iter()
.position(|(id, _)| *id == open_paragraph_id)
{
Some(i) => para_words[i..].iter().map(|(_, w)| w).sum(),
None => total_words,
};
Some(BookReadingTime {
total_secs: reading_secs(total_words, wpm),
remaining_secs: reading_secs(remaining_words, wpm),
total_words,
})
}
/// Walk up to the root book of `node`. `ancestors`
/// returns root→…→parent, so the first entry is the root
/// book; when `node` has no ancestors it may itself be a
/// book (returned), otherwise `None`.
fn root_book_of<'a>(h: &'a Hierarchy, node: &'a Node) -> Option<&'a Node> {
match h.ancestors(node).first() {
Some(root) if root.kind == NodeKind::Book => Some(root),
_ if node.kind == NodeKind::Book => Some(node),
_ => None,
}
}
/// Format a duration as a compact `1h23m` / `12m` /
/// `45s` string for the status-bar chip.
pub(crate) fn fmt_compact(secs: u64) -> String {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
format!("{h}h{m:02}m")
} else if m > 0 {
format!("{m}m")
} else {
format!("{s}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── reading_secs ──────────────────────────────────
#[test]
fn reading_secs_at_200wpm() {
// 200 words at 200 wpm = 60s.
assert_eq!(reading_secs(200, 200), 60);
// 600 words at 200 wpm = 180s.
assert_eq!(reading_secs(600, 200), 180);
}
#[test]
fn reading_secs_zero_wpm_is_zero() {
assert_eq!(reading_secs(1000, 0), 0);
}
#[test]
fn reading_secs_zero_words_is_zero() {
assert_eq!(reading_secs(0, 200), 0);
}
// ── fmt_compact ───────────────────────────────────
#[test]
fn fmt_compact_seconds() {
assert_eq!(fmt_compact(45), "45s");
}
#[test]
fn fmt_compact_minutes() {
assert_eq!(fmt_compact(125), "2m");
}
#[test]
fn fmt_compact_hours() {
assert_eq!(fmt_compact(3 * 3600 + 25 * 60), "3h25m");
}
#[test]
fn fmt_compact_zero() {
assert_eq!(fmt_compact(0), "0s");
}
// ── compute (in-memory hierarchy) ─────────────────
fn node(
id: Uuid,
kind: &str,
slug: &str,
path: &[&str],
parent: Option<Uuid>,
order: u32,
words: u64,
) -> Node {
let raw = serde_json::json!({
"id": id,
"kind": kind,
"title": slug,
"slug": slug,
"path": path,
"parent_id": parent,
"order": order,
"file": null,
"word_count": words,
"modified_at": "2026-01-01T00:00:00Z",
});
serde_json::from_value(raw).expect("test node deserialises")
}
/// book → ch → [p1 200w, p2 400w, p3 600w].
fn sample() -> (Hierarchy, Vec<Uuid>) {
let ids: Vec<Uuid> = (0..5).map(|_| Uuid::now_v7()).collect();
let (book, ch, p1, p2, p3) =
(ids[0], ids[1], ids[2], ids[3], ids[4]);
let nodes = vec![
node(book, "book", "b", &[], None, 1, 0),
node(ch, "chapter", "c", &["b"], Some(book), 1, 0),
node(p1, "paragraph", "p1", &["b", "c"], Some(ch), 1, 200),
node(p2, "paragraph", "p2", &["b", "c"], Some(ch), 2, 400),
node(p3, "paragraph", "p3", &["b", "c"], Some(ch), 3, 600),
];
let h = build(nodes);
(h, ids)
}
fn build(nodes: Vec<Node>) -> Hierarchy {
Hierarchy::from_nodes_for_test(nodes)
}
#[test]
fn compute_total_and_remaining() {
let (h, ids) = sample();
// Total = 200+400+600 = 1200 words. At 200 wpm
// = 360s.
let bt = compute(&h, ids[2], 200).unwrap(); // open p1
assert_eq!(bt.total_words, 1200);
assert_eq!(bt.total_secs, 360);
// Remaining from p1 (inclusive) = all = 360s.
assert_eq!(bt.remaining_secs, 360);
}
#[test]
fn compute_remaining_from_middle() {
let (h, ids) = sample();
// Open p2: remaining = 400+600 = 1000 words =
// 300s.
let bt = compute(&h, ids[3], 200).unwrap();
assert_eq!(bt.remaining_secs, 300);
assert_eq!(bt.total_secs, 360);
}
#[test]
fn compute_remaining_from_last() {
let (h, ids) = sample();
// Open p3: remaining = 600 words = 180s.
let bt = compute(&h, ids[4], 200).unwrap();
assert_eq!(bt.remaining_secs, 180);
}
#[test]
fn compute_unknown_id_is_none() {
let (h, _ids) = sample();
assert!(compute(&h, Uuid::now_v7(), 200).is_none());
}
}