inkhaven 1.4.10

Inkhaven — TUI literary work editor for Typst books
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
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Aggregates the status-bar widget + Ctrl+V G modal consume.
//!
//! All aggregates are computed at query time — `writing_events`
//! and `writing_baselines` are the source of truth, no cached
//! summaries. The query volume is tiny (one snapshot per modal
//! open, one per status-bar redraw which we throttle), so this
//! stays cheap even for projects with years of history.

use std::collections::HashMap;

use anyhow::Result;
use uuid::Uuid;

use super::store::{ProgressStore, PROJECT_SCOPE_BOOK_ID};
use crate::config::GoalsConfig;

/// Top-level structure handed to the renderer. All counts are
/// signed — negative `today_words` means the user deleted more
/// than they wrote today.
#[derive(Debug, Clone)]
pub struct ProgressSnapshot {
    pub project: BookProgress,
    pub books: Vec<BookProgress>,
    pub status: StatusLadderCounts,
    pub streak: StreakStatus,
    /// Last-30-days sparkline data, oldest first, project-wide.
    pub sparkline: Vec<i64>,
    /// Active writing seconds today — sum of save→save gaps,
    /// each gap capped at 5 min so AFK doesn't inflate. Honest
    /// about "time at the keyboard" without keystroke tracking.
    pub active_seconds_today: i64,
    /// Same calculation over the trailing 7 days.
    pub active_seconds_week: i64,
}

impl ProgressSnapshot {
    pub fn empty() -> Self {
        Self {
            project: BookProgress::empty("project"),
            books: Vec::new(),
            status: StatusLadderCounts::default(),
            streak: StreakStatus::default(),
            sparkline: Vec::new(),
            active_seconds_today: 0,
            active_seconds_week: 0,
        }
    }
}

#[derive(Debug, Clone)]
pub struct BookProgress {
    pub label: String,
    pub today_words: i64,
    pub daily_goal: Option<i64>,
    pub total_words: i64,
    pub target_words: Option<i64>,
    /// Words/day the user must average to hit `target_words` by
    /// the deadline. None if no deadline / target.
    pub required_pace: Option<i64>,
    /// Days remaining until the deadline (negative = overdue).
    pub days_to_deadline: Option<i64>,
}

impl BookProgress {
    pub fn empty(label: &str) -> Self {
        Self {
            label: label.to_string(),
            today_words: 0,
            daily_goal: None,
            total_words: 0,
            target_words: None,
            required_pace: None,
            days_to_deadline: None,
        }
    }
}

/// Last 7 days of recorded promotions, grouped by `to` status,
/// plus the user's per-status goal for the week.
#[derive(Debug, Clone, Default)]
pub struct StatusLadderCounts {
    pub recent: Vec<(String, i64)>,
    pub goals: Vec<(String, i64)>,
}

#[derive(Debug, Clone, Default)]
pub struct StreakStatus {
    pub days: i64,
    pub grace_used: i64,
    pub grace_per_week: i64,
    /// Longest streak ever recorded, computed over the project's
    /// full writing history (not just the recent window) with the
    /// same grace rule. `0` when there is no history yet.
    pub best: i64,
}

/// Streak-length milestones worth celebrating, ascending. A hook
/// fires the first time the current streak crosses one upward.
pub const STREAK_MILESTONES: [i64; 4] = [7, 30, 100, 365];

/// The largest milestone newly crossed when the streak grows from
/// `prev` to `now` (both current-streak lengths). `None` when no
/// milestone boundary sits in `(prev, now]`.
pub fn milestone_crossed(prev: i64, now: i64) -> Option<i64> {
    STREAK_MILESTONES
        .iter()
        .rev()
        .copied()
        .find(|&m| prev < m && now >= m)
}

/// Caller-supplied "live" word counts (computed from the
/// hierarchy walk). The aggregator can't read paragraph bodies
/// itself without re-implementing the hierarchy crawl, so the
/// editor passes these in alongside the goals.
#[derive(Debug, Clone, Default)]
pub struct LiveTotals {
    pub per_book: HashMap<Uuid, i64>,
    pub project_total: i64,
    /// Slug→title map for nice labels in the modal. The status-
    /// bar widget only needs project-wide, so this can be empty.
    pub book_titles: HashMap<Uuid, String>,
    /// Slug for each book — used to match HJSON `goals.books.<slug>`
    /// entries. Lowercased so the lookup is case-insensitive.
    pub book_slugs: HashMap<Uuid, String>,
}

/// Build the snapshot. `live` carries the per-book + project
/// word totals + labels the editor computed; `store` provides
/// the historical aggregates.
pub fn build_snapshot(
    store: &ProgressStore,
    goals: &GoalsConfig,
    live: &LiveTotals,
) -> Result<ProgressSnapshot> {
    let today = crate::dayclock::today_days();

    // Project-wide aggregates.
    let project_today = store
        .today_words(PROJECT_SCOPE_BOOK_ID, live.project_total)
        .unwrap_or(0);
    let project = BookProgress {
        label: "project".into(),
        today_words: project_today,
        daily_goal: nonzero(goals.daily_words),
        total_words: live.project_total,
        target_words: None,
        required_pace: None,
        days_to_deadline: None,
    };

    // Per-book breakdown.
    let mut books: Vec<BookProgress> = Vec::new();
    for (id, total) in live.per_book.iter() {
        let today_w = store.today_words(*id, *total).unwrap_or(0);
        let slug = live
            .book_slugs
            .get(id)
            .cloned()
            .unwrap_or_default()
            .to_ascii_lowercase();
        let title = live
            .book_titles
            .get(id)
            .cloned()
            .unwrap_or_else(|| slug.clone());
        let goal = goals.books.get(&slug);
        let target_words = goal.map(|g| g.target_words).filter(|n| *n > 0);
        let days_to_deadline = goal
            .filter(|g| !g.deadline.is_empty())
            .and_then(|g| parse_iso_date_days(&g.deadline))
            .map(|d| d - today);
        let required_pace = match (target_words, days_to_deadline) {
            (Some(t), Some(dd)) => required_pace(*total, t, dd),
            _ => None,
        };
        books.push(BookProgress {
            label: title,
            today_words: today_w,
            daily_goal: nonzero(goals.daily_words),
            total_words: *total,
            target_words,
            required_pace,
            days_to_deadline,
        });
    }
    books.sort_by(|a, b| a.label.cmp(&b.label));

    // Streak. Query the full history (DISTINCT writing-days is one
    // cheap row per day) so the current streak and the lifetime best
    // both come from the same vector — no separate windowed query.
    let writing_days = store.writing_days_recent(ALL_HISTORY_DAYS).unwrap_or_default();
    let mut streak = compute_streak(&writing_days, today, goals.streak_grace_per_week);
    streak.best = longest_streak(&writing_days, goals.streak_grace_per_week);

    // Status ladder.
    let recent = store.status_promotions_recent(7).unwrap_or_default();
    let goal_pairs: Vec<(String, i64)> = goals
        .status_ladder
        .iter()
        .map(|(k, v)| (k.to_ascii_lowercase(), *v))
        .collect();
    let status = StatusLadderCounts {
        recent,
        goals: goal_pairs,
    };

    // Sparkline.
    let sparkline = store
        .last_n_daily(PROJECT_SCOPE_BOOK_ID, live.project_total, 30)
        .unwrap_or_default();

    // Active-time aggregates (1.2.4+). Today's window is from the
    // boundary's day-start (local midnight or 00:00 UTC) to now; week is
    // the trailing 7 such days.
    let today_start = crate::dayclock::today_start_secs();
    let now_secs = today_start + 86_400; // future bound — saves can't
                                         // be in the future anyway
    const ACTIVE_GAP_CAP_SEC: i64 = 300; // 5 min per gap
    let active_seconds_today = store
        .active_seconds_in_range(today_start, now_secs, ACTIVE_GAP_CAP_SEC)
        .unwrap_or(0);
    let week_start = today_start - 6 * 86_400;
    let active_seconds_week = store
        .active_seconds_in_range(week_start, now_secs, ACTIVE_GAP_CAP_SEC)
        .unwrap_or(0);

    Ok(ProgressSnapshot {
        project,
        books,
        status,
        streak,
        sparkline,
        active_seconds_today,
        active_seconds_week,
    })
}

fn nonzero(n: i64) -> Option<i64> {
    if n > 0 { Some(n) } else { None }
}

/// Parse `YYYY-MM-DD` into days-since-epoch UTC. Returns None on
/// any parse failure — the caller treats absence as "no deadline".
fn parse_iso_date_days(s: &str) -> Option<i64> {
    let parsed = chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()?;
    let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?;
    Some(parsed.signed_duration_since(epoch).num_days())
}

/// "Since the beginning of the project" expressed as a look-back
/// window, because `writing_days_recent` takes a `days_back` and
/// derives its cutoff as `now − days_back·86400`. It's not a cap on
/// how long anyone writes — it just has to land the cutoff before
/// the first row, and the Unix epoch (1970) is only ~20k days back,
/// so any value past that returns the whole history. 200 years is a
/// round, overflow-safe pick comfortably below i64.
const ALL_HISTORY_DAYS: i64 = 365 * 200;

/// Streak length: trailing run of "writing days" (≥1 positive
/// save event) ending today, allowing `grace_per_week` skipped
/// days inside the rolling 7-day window. A skip beyond the
/// allowance breaks the streak.
pub fn compute_streak(
    writing_days_desc: &[i64],
    today: i64,
    grace_per_week: i64,
) -> StreakStatus {
    if writing_days_desc.is_empty() {
        return StreakStatus {
            days: 0,
            grace_used: 0,
            grace_per_week,
            best: 0,
        };
    }
    let writing: std::collections::HashSet<i64> =
        writing_days_desc.iter().copied().collect();
    let (days, grace_used) = streak_len_at(&writing, today, grace_per_week);
    StreakStatus {
        days,
        grace_used: grace_used.max(0),
        grace_per_week,
        best: 0,
    }
}

/// Core streak scan: trailing run of writing-days ending at `today`,
/// allowing `grace_per_week` skips inside the rolling 7-day window.
/// Returns `(days, grace_used_in_window)`. Takes a prebuilt set so
/// callers that probe many endpoints don't rebuild it each time (M1).
fn streak_len_at(
    writing: &std::collections::HashSet<i64>,
    today: i64,
    grace_per_week: i64,
) -> (i64, i64) {
    let mut days: i64 = 0;
    let mut grace_used_window: i64 = 0;
    let mut window: std::collections::VecDeque<bool> =
        std::collections::VecDeque::with_capacity(7); // true = skipped
    let mut d = today;
    loop {
        let skipped = !writing.contains(&d);
        // Slide the rolling 7-day window forward.
        if window.len() == 7 {
            if let Some(old) = window.pop_front() {
                if old {
                    grace_used_window -= 1;
                }
            }
        }
        window.push_back(skipped);
        if skipped {
            grace_used_window += 1;
            if grace_used_window > grace_per_week {
                break;
            }
        }
        days += 1;
        d -= 1;
        // Bound the scan — practical streaks fit easily.
        if days > 1_000 {
            break;
        }
    }
    (days, grace_used_window)
}

/// Longest streak ever, over the full writing-day history, with the
/// same grace rule as `compute_streak`. A streak ending on a
/// non-writing day is never longer than one ending on the previous
/// writing day, so it suffices to take the max of the streaks ending
/// at each writing-day.
///
/// M1 — builds the writing-day set ONCE and reuses it across every
/// candidate endpoint. The previous version rebuilt a full HashSet per
/// candidate (O(D²) allocation churn), which stalled the progress modal
/// for years-old daily-writing projects.
pub fn longest_streak(writing_days_desc: &[i64], grace_per_week: i64) -> i64 {
    if writing_days_desc.is_empty() {
        return 0;
    }
    let writing: std::collections::HashSet<i64> =
        writing_days_desc.iter().copied().collect();
    writing_days_desc
        .iter()
        .map(|&d| streak_len_at(&writing, d, grace_per_week).0)
        .max()
        .unwrap_or(0)
}

/// Required daily pace to hit `target_words` by the deadline.
/// Negative or zero days_to_deadline → pace is the remaining
/// gap (the user is past due; pacing is moot).
pub fn required_pace(current: i64, target: i64, days_to_deadline: i64) -> Option<i64> {
    if days_to_deadline <= 0 {
        let gap = target - current;
        if gap > 0 {
            Some(gap)
        } else {
            None
        }
    } else {
        let gap = (target - current).max(0);
        Some((gap + days_to_deadline - 1) / days_to_deadline) // ceil
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn streak_unbroken() {
        // wrote every one of the last 5 days
        let today = 100;
        let days = vec![100, 99, 98, 97, 96];
        let s = compute_streak(&days, today, 0);
        assert_eq!(s.days, 5);
    }

    #[test]
    fn streak_breaks_no_grace() {
        let today = 100;
        // wrote 100, 99, then skipped 98, wrote 97
        let days = vec![100, 99, 97];
        let s = compute_streak(&days, today, 0);
        assert_eq!(s.days, 2);
    }

    #[test]
    fn streak_grace_one_per_week() {
        let today = 100;
        // wrote 100, 99, skipped 98, wrote 97, 96 — grace 1 lets us span it
        let days = vec![100, 99, 97, 96];
        let s = compute_streak(&days, today, 1);
        assert_eq!(s.days, 5);
    }

    #[test]
    fn longest_streak_finds_best_run_not_trailing() {
        // History (desc): a 3-day run, a gap, then today's 2-day run.
        // Current streak is 2 but the lifetime best is 3.
        let days = vec![100, 99, 95, 94, 93];
        assert_eq!(compute_streak(&days, 100, 0).days, 2);
        assert_eq!(longest_streak(&days, 0), 3);
    }

    #[test]
    fn longest_streak_respects_grace() {
        // 100,99, skip 98, 97,96 — with 1 grace/week it's one 5-run.
        let days = vec![100, 99, 97, 96];
        assert_eq!(longest_streak(&days, 0), 2); // strict: best is the 2-run
        assert_eq!(longest_streak(&days, 1), 5); // grace bridges the gap
    }

    #[test]
    fn longest_streak_empty_is_zero() {
        assert_eq!(longest_streak(&[], 0), 0);
    }

    #[test]
    fn milestone_crossings() {
        assert_eq!(milestone_crossed(6, 7), Some(7));
        assert_eq!(milestone_crossed(7, 8), None); // already past 7
        assert_eq!(milestone_crossed(29, 31), Some(30));
        // Jumping multiple milestones at once celebrates the highest.
        assert_eq!(milestone_crossed(0, 100), Some(100));
        assert_eq!(milestone_crossed(0, 0), None);
    }

    #[test]
    fn required_pace_simple() {
        assert_eq!(required_pace(0, 1000, 10), Some(100));
        assert_eq!(required_pace(500, 1000, 5), Some(100));
        assert_eq!(required_pace(1500, 1000, 5), Some(0));
    }

    #[test]
    fn required_pace_past_due() {
        // overdue: pace becomes the remaining gap in one big push.
        assert_eq!(required_pace(500, 1000, 0), Some(500));
        assert_eq!(required_pace(500, 1000, -3), Some(500));
        assert_eq!(required_pace(1000, 1000, -3), None);
    }
}