use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use uuid::Uuid;
use crate::storage::engine::StorageEngine;
use duckdb::types::Value as DuckValue;
pub const PROJECT_SCOPE_BOOK_ID: Uuid =
Uuid::from_u128(0x10001000_1000_1000_1000_100010001000);
const INIT_SQL: &str = "
CREATE SEQUENCE IF NOT EXISTS writing_event_id_seq START 1;
CREATE TABLE IF NOT EXISTS writing_events (
id BIGINT PRIMARY KEY DEFAULT nextval('writing_event_id_seq'),
ts BIGINT NOT NULL, -- unix-seconds
node_id TEXT, -- nullable for project-wide events
book_id TEXT, -- nullable for project-wide events
kind TEXT NOT NULL, -- save | status_change | snapshot | delete
word_delta INTEGER NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0,
extra_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_ts ON writing_events(ts);
CREATE INDEX IF NOT EXISTS idx_events_book ON writing_events(book_id);
CREATE INDEX IF NOT EXISTS idx_events_node ON writing_events(node_id);
CREATE TABLE IF NOT EXISTS writing_baselines (
day INTEGER NOT NULL, -- days since epoch (UTC)
book_id TEXT NOT NULL, -- includes PROJECT_SCOPE_BOOK_ID for project-wide
total_words INTEGER NOT NULL,
PRIMARY KEY (day, book_id)
);
";
#[derive(Clone)]
pub struct ProgressStore {
engine: std::sync::Arc<StorageEngine>,
}
impl ProgressStore {
pub fn open(path: &Path) -> Result<Self> {
let engine = StorageEngine::new(path, INIT_SQL, 2)?;
Ok(Self {
engine: std::sync::Arc::new(engine),
})
}
pub fn record_event(
&self,
kind: &str,
node_id: Uuid,
book_id: Option<Uuid>,
word_delta: i64,
total_words: i64,
extra: Option<&str>,
) -> Result<()> {
let ts = now_unix_secs();
let node_str = node_id.to_string();
let book_str = book_id.map(|b| b.to_string());
self.engine.execute_with(
"INSERT INTO writing_events
(ts, node_id, book_id, kind, word_delta, total_words, extra_json)
VALUES (?, ?, ?, ?, ?, ?, ?)",
&[
&ts,
&node_str,
&book_str,
&kind,
&word_delta,
&total_words,
&extra,
],
)?;
Ok(())
}
pub fn capture_baselines_today(
&self,
per_book: &[(Uuid, i64)],
project_total: i64,
) -> Result<()> {
let day = today_utc_days();
for (book, total) in per_book {
let book_str = book.to_string();
self.engine.execute_with(
"INSERT INTO writing_baselines (day, book_id, total_words)
VALUES (?, ?, ?)
ON CONFLICT (day, book_id) DO NOTHING",
&[&day, &book_str, total],
)?;
}
let project_str = PROJECT_SCOPE_BOOK_ID.to_string();
self.engine.execute_with(
"INSERT INTO writing_baselines (day, book_id, total_words)
VALUES (?, ?, ?)
ON CONFLICT (day, book_id) DO NOTHING",
&[&day, &project_str, &project_total],
)?;
Ok(())
}
pub fn baseline_for(&self, day: i64, book_id: Uuid) -> Result<Option<i64>> {
let book_str = book_id.to_string();
let rows = self.engine.select_all(&format!(
"SELECT total_words FROM writing_baselines
WHERE day = {day} AND book_id = '{book}'",
book = sql_escape(&book_str),
))?;
match rows.into_iter().next() {
None => Ok(None),
Some(row) => match row.into_iter().next() {
Some(DuckValue::Int(i)) => Ok(Some(i as i64)),
Some(DuckValue::BigInt(i)) => Ok(Some(i)),
_ => Ok(None),
},
}
}
pub fn today_words(&self, book_id: Uuid, current_total: i64) -> Result<i64> {
let day = today_utc_days();
match self.baseline_for(day, book_id)? {
Some(base) => Ok(current_total - base),
None => Ok(0),
}
}
pub fn last_n_daily(
&self,
book_id: Uuid,
current_total: i64,
n: usize,
) -> Result<Vec<i64>> {
let today = today_utc_days();
let mut out = Vec::with_capacity(n);
let book_str = book_id.to_string();
let earliest = today - n as i64;
let rows = self.engine.select_all(&format!(
"SELECT day, total_words FROM writing_baselines
WHERE book_id = '{book}' AND day >= {earliest}
ORDER BY day ASC",
book = sql_escape(&book_str),
))?;
let mut bl: std::collections::HashMap<i64, i64> =
std::collections::HashMap::new();
for row in rows {
let mut it = row.into_iter();
let day = match it.next() {
Some(DuckValue::Int(i)) => i as i64,
Some(DuckValue::BigInt(i)) => i,
_ => continue,
};
let tw = match it.next() {
Some(DuckValue::Int(i)) => i as i64,
Some(DuckValue::BigInt(i)) => i,
_ => continue,
};
bl.insert(day, tw);
}
for i in 0..n {
let d = today - (n as i64 - 1) + i as i64;
let next_d_total = if d == today {
current_total
} else {
*bl.get(&(d + 1)).unwrap_or_else(|| {
bl.get(&d).unwrap_or(&0)
})
};
let this_d = *bl.get(&d).unwrap_or(&0);
let written = (next_d_total - this_d).max(0);
out.push(written);
}
Ok(out)
}
pub fn status_promotions_recent(
&self,
days_back: i64,
) -> Result<Vec<(String, i64)>> {
let cutoff_secs = now_unix_secs() - days_back * 86_400;
let rows = self.engine.select_all(&format!(
"SELECT
json_extract_string(extra_json, '$.to') AS to_status,
COUNT(*) AS n
FROM writing_events
WHERE kind = 'status_change' AND ts >= {cutoff_secs}
GROUP BY to_status",
))?;
let mut out: Vec<(String, i64)> = Vec::new();
for row in rows {
let mut it = row.into_iter();
let status = match it.next() {
Some(DuckValue::Text(s)) => s,
_ => continue,
};
let n = match it.next() {
Some(DuckValue::Int(i)) => i as i64,
Some(DuckValue::BigInt(i)) => i,
_ => 0,
};
if !status.is_empty() {
out.push((status, n));
}
}
Ok(out)
}
pub fn writing_days_recent(&self, days_back: i64) -> Result<Vec<i64>> {
let cutoff_secs = now_unix_secs() - days_back * 86_400;
let rows = self.engine.select_all(&format!(
"SELECT DISTINCT (ts / 86400) AS day
FROM writing_events
WHERE kind = 'save' AND word_delta > 0 AND ts >= {cutoff_secs}
ORDER BY day DESC",
))?;
let mut out = Vec::new();
for row in rows {
match row.into_iter().next() {
Some(DuckValue::Int(i)) => out.push(i as i64),
Some(DuckValue::BigInt(i)) => out.push(i),
_ => {}
}
}
Ok(out)
}
pub fn active_seconds_in_range(
&self,
from_secs: i64,
until_secs: i64,
cap_seconds: i64,
) -> Result<i64> {
let rows = self.engine.select_all(&format!(
"WITH saves AS (
SELECT ts FROM writing_events
WHERE kind = 'save' AND ts >= {from_secs} AND ts < {until_secs}
ORDER BY ts
),
gaps AS (
SELECT ts - LAG(ts) OVER (ORDER BY ts) AS gap FROM saves
)
SELECT COALESCE(SUM(LEAST(gap, {cap_seconds})), 0)
FROM gaps WHERE gap IS NOT NULL",
))?;
let value = rows
.into_iter()
.next()
.and_then(|row| row.into_iter().next());
Ok(match value {
Some(DuckValue::Int(i)) => i as i64,
Some(DuckValue::BigInt(i)) => i,
Some(DuckValue::HugeInt(i)) => i as i64,
Some(DuckValue::Double(f)) => f as i64,
_ => 0,
})
}
}
pub fn today_utc_days() -> i64 {
let secs = now_unix_secs();
secs / 86_400
}
fn now_unix_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn sql_escape(s: &str) -> String {
s.replace('\'', "''")
}