mod export;
mod import;
mod schema;
use std::path::PathBuf;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use crate::model::{
AppData, EmptyQueueBehavior, EstimateCompleteBehavior, FocusSessionRecord, Priority,
StoredSession, Task, TaskStatus, TimerMode,
};
use crate::theme;
pub struct Database {
conn: Connection,
}
impl Database {
pub fn open() -> Result<Self> {
let path = db_path()?;
let existed = path.exists();
let conn = Connection::open(&path).context("opening SQLite database")?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
schema::migrate(&conn)?;
let db = Self { conn };
if !existed {
let json = legacy_json_path()?;
if json.exists() {
import::import_json(&db, &json)?;
let backup = json.with_extension("json.migrated");
let _ = std::fs::rename(&json, &backup);
}
}
Ok(db)
}
pub fn load_app_data(&self) -> Result<AppData> {
let mut data = AppData::default();
load_settings(&self.conn, &mut data)?;
data.tasks = load_tasks(&self.conn)?;
data.session_history = Vec::new();
Ok(data)
}
pub fn save_app_data(&self, data: &AppData) -> Result<()> {
let tx = self.conn.unchecked_transaction()?;
save_settings(&tx, data)?;
sync_tasks(&tx, &data.tasks)?;
tx.commit()?;
Ok(())
}
pub fn insert_focus_session(&self, record: &FocusSessionRecord) -> Result<i64> {
self.conn.execute(
"INSERT INTO focus_sessions (date, minutes, task_id, mode, completed_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
record.date,
record.minutes,
record.task_id.map(|id| id as i64),
encode_timer_mode(record.mode),
record.completed_at.to_rfc3339(),
],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn get_session(&self, id: i64) -> Result<StoredSession> {
Ok(self.conn.query_row(
"SELECT date, minutes, task_id, mode, completed_at
FROM focus_sessions WHERE id = ?1",
params![id],
|row| {
let mode_str: String = row.get(3)?;
Ok(StoredSession {
id,
record: FocusSessionRecord {
date: row.get(0)?,
minutes: row.get(1)?,
task_id: read_opt_u64(row, 2)?,
mode: decode_timer_mode(&mode_str),
completed_at: parse_datetime(&row.get::<_, String>(4)?),
},
})
},
)?)
}
pub fn delete_focus_session(&self, id: i64) -> Result<()> {
self.conn
.execute("DELETE FROM focus_sessions WHERE id = ?1", params![id])?;
Ok(())
}
pub fn update_session_minutes(&self, id: i64, minutes: u32) -> Result<()> {
self.conn.execute(
"UPDATE focus_sessions SET minutes = ?1 WHERE id = ?2",
params![minutes, id],
)?;
Ok(())
}
pub fn recent_sessions(&self, limit: usize) -> Result<Vec<StoredSession>> {
let mut stmt = self.conn.prepare(
"SELECT id, date, minutes, task_id, mode, completed_at
FROM focus_sessions
ORDER BY completed_at DESC
LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |row| {
let id: i64 = row.get(0)?;
let mode_str: String = row.get(4)?;
Ok(StoredSession {
id,
record: FocusSessionRecord {
date: row.get(1)?,
minutes: row.get(2)?,
task_id: read_opt_u64(row, 3)?,
mode: decode_timer_mode(&mode_str),
completed_at: parse_datetime(&row.get::<_, String>(5)?),
},
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.context("loading recent sessions")
}
pub fn session_counts_by_mode(&self) -> Result<(u32, u32, u32)> {
let focus: u32 = self.conn.query_row(
"SELECT COUNT(*) FROM focus_sessions WHERE mode = ?1",
params![encode_timer_mode(TimerMode::Focus)],
|row| row.get(0),
)?;
let custom: u32 = self.conn.query_row(
"SELECT COUNT(*) FROM focus_sessions WHERE mode = ?1",
params![encode_timer_mode(TimerMode::Custom)],
|row| row.get(0),
)?;
let breaks: u32 = self.conn.query_row(
"SELECT COUNT(*) FROM focus_sessions WHERE mode IN (?1, ?2)",
params![
encode_timer_mode(TimerMode::ShortBreak),
encode_timer_mode(TimerMode::LongBreak),
],
|row| row.get(0),
)?;
Ok((focus, custom, breaks))
}
pub fn load_timer_state(&self) -> (u32, TimerMode) {
let count: u32 = self
.conn
.query_row(
"SELECT value FROM settings WHERE key = 'timer_completed_focus_sessions'",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let mode = self
.conn
.query_row(
"SELECT value FROM settings WHERE key = 'timer_mode'",
[],
|row| row.get::<_, String>(0),
)
.ok()
.map(|s| decode_timer_mode(&s))
.unwrap_or(TimerMode::Focus);
(count, mode)
}
pub fn persist_timer_state(&self, completed: u32, mode: TimerMode) -> Result<()> {
self.set_setting("timer_completed_focus_sessions", completed.to_string())?;
self.set_setting("timer_mode", encode_timer_mode(mode))?;
Ok(())
}
pub fn set_setting(&self, key: &str, value: impl AsRef<str>) -> Result<()> {
self.conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value.as_ref()],
)?;
Ok(())
}
pub fn upsert_task(&self, task: &Task) -> Result<()> {
let tx = self.conn.unchecked_transaction()?;
upsert_task_row(&tx, task)?;
tx.commit()?;
Ok(())
}
pub fn delete_task(&self, id: u64) -> Result<()> {
self.conn
.execute("DELETE FROM tasks WHERE id = ?1", params![id as i64])?;
Ok(())
}
pub fn sync_sort_orders(&self, tasks: &[Task]) -> Result<()> {
let tx = self.conn.unchecked_transaction()?;
for task in tasks {
tx.execute(
"UPDATE tasks SET sort_order = ?1 WHERE id = ?2",
params![task.sort_order, task.id as i64],
)?;
}
tx.commit()?;
Ok(())
}
pub fn persist_session_stats(&self, data: &AppData) -> Result<()> {
self.set_setting("total_focus_minutes", data.total_focus_minutes.to_string())?;
self.set_setting("total_sessions", data.total_sessions.to_string())?;
self.set_setting("streak_days", data.streak_days.to_string())?;
self.set_setting(
"last_session_date",
data.last_session_date.clone().unwrap_or_default(),
)?;
self.set_setting("today_focus_minutes", data.today_focus_minutes.to_string())?;
self.set_setting("today_date", data.today_date.clone().unwrap_or_default())?;
self.set_setting("goal_streak_days", data.goal_streak_days.to_string())?;
self.set_setting(
"last_goal_date",
data.last_goal_date.clone().unwrap_or_default(),
)?;
Ok(())
}
pub fn persist_timer_settings(&self, data: &AppData) -> Result<()> {
self.set_setting("focus_minutes", data.focus_minutes.to_string())?;
self.set_setting("short_break_minutes", data.short_break_minutes.to_string())?;
self.set_setting("long_break_minutes", data.long_break_minutes.to_string())?;
self.set_setting("long_break_every", data.long_break_every.to_string())?;
Ok(())
}
pub fn persist_active_task(&self, id: Option<u64>) -> Result<()> {
let value = id.map(|i| i.to_string()).unwrap_or_default();
self.set_setting("active_task_id", value)
}
pub fn export_json(&self) -> Result<PathBuf> {
export::export_json(&self.conn)
}
pub fn minutes_by_date(&self, days: usize) -> Result<Vec<(String, u32)>> {
let today = chrono::Local::now().date_naive();
let mut out = Vec::with_capacity(days);
for offset in (0..days).rev() {
let date = today - chrono::Duration::days(offset as i64);
let key = date.format("%Y-%m-%d").to_string();
let mins = self.focus_minutes_on_date(&key)?;
let label = date.format("%a").to_string();
out.push((label, mins));
}
Ok(out)
}
pub fn focus_minutes_series(&self, days: usize) -> Result<Vec<(String, u32)>> {
let today = chrono::Local::now().date_naive();
let mut out = Vec::with_capacity(days);
for offset in (0..days).rev() {
let date = today - chrono::Duration::days(offset as i64);
let key = date.format("%Y-%m-%d").to_string();
let mins = self.focus_minutes_on_date(&key)?;
out.push((key, mins));
}
Ok(out)
}
pub fn focus_minutes_grouped(&self) -> Result<Vec<(String, u32)>> {
let mut stmt = self.conn.prepare(
"SELECT date, COALESCE(SUM(minutes), 0) AS mins
FROM focus_sessions
WHERE mode IN (?1, ?2)
GROUP BY date
ORDER BY date ASC",
)?;
let rows = stmt.query_map(
params![
encode_timer_mode(TimerMode::Focus),
encode_timer_mode(TimerMode::Custom),
],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?)),
)?;
rows.collect::<Result<Vec<_>, _>>()
.context("loading focus minutes")
}
fn focus_minutes_on_date(&self, key: &str) -> Result<u32> {
self.conn
.query_row(
"SELECT COALESCE(SUM(minutes), 0) FROM focus_sessions
WHERE date = ?1 AND mode IN (?2, ?3)",
params![
key,
encode_timer_mode(TimerMode::Focus),
encode_timer_mode(TimerMode::Custom),
],
|row| row.get(0),
)
.map_err(Into::into)
}
}
pub fn db_path() -> Result<PathBuf> {
let dir = data_dir()?;
Ok(dir.join("void.db"))
}
pub fn legacy_json_path() -> Result<PathBuf> {
Ok(data_dir()?.join("data.json"))
}
fn data_dir() -> Result<PathBuf> {
let dir = dirs::data_local_dir()
.or_else(dirs::config_dir)
.context("could not resolve local data directory")?;
let focus_dir = dir.join("void");
std::fs::create_dir_all(&focus_dir).context("creating data directory")?;
Ok(focus_dir)
}
pub(crate) fn load_settings(conn: &Connection, data: &mut AppData) -> Result<()> {
let mut stmt = conn.prepare("SELECT key, value FROM settings")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for row in rows {
let (key, value) = row?;
apply_setting(data, &key, &value);
}
Ok(())
}
fn save_settings(conn: &Connection, data: &AppData) -> Result<()> {
let pairs: Vec<(&str, String)> = vec![
("next_id", data.next_id.to_string()),
("total_focus_minutes", data.total_focus_minutes.to_string()),
("total_sessions", data.total_sessions.to_string()),
("streak_days", data.streak_days.to_string()),
(
"last_session_date",
data.last_session_date.clone().unwrap_or_default(),
),
("daily_goal_minutes", data.daily_goal_minutes.to_string()),
("sound_enabled", bool_str(data.sound_enabled)),
("auto_start_breaks", bool_str(data.auto_start_breaks)),
("auto_start_focus", bool_str(data.auto_start_focus)),
("today_focus_minutes", data.today_focus_minutes.to_string()),
("today_date", data.today_date.clone().unwrap_or_default()),
("focus_minutes", data.focus_minutes.to_string()),
("short_break_minutes", data.short_break_minutes.to_string()),
("long_break_minutes", data.long_break_minutes.to_string()),
("long_break_every", data.long_break_every.to_string()),
("auto_pick_task", bool_str(data.auto_pick_task)),
("auto_advance_task", bool_str(data.auto_advance_task)),
("theme", data.theme.clone()),
(
"active_task_id",
data.active_task_id
.map(|id| id.to_string())
.unwrap_or_default(),
),
("notify_on_finish", bool_str(data.notify_on_finish)),
("goal_streak_days", data.goal_streak_days.to_string()),
(
"last_goal_date",
data.last_goal_date.clone().unwrap_or_default(),
),
(
"empty_queue_behavior",
encode_empty_queue(data.empty_queue_behavior).to_string(),
),
("log_breaks", bool_str(data.log_breaks)),
(
"estimate_complete",
encode_estimate_complete(data.estimate_complete).to_string(),
),
];
for (key, value) in pairs {
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
}
Ok(())
}
fn apply_setting(data: &mut AppData, key: &str, value: &str) {
match key {
"next_id" => data.next_id = parse_u64(value, data.next_id),
"total_focus_minutes" => {
data.total_focus_minutes = parse_u32(value, data.total_focus_minutes)
}
"total_sessions" => data.total_sessions = parse_u32(value, data.total_sessions),
"streak_days" => data.streak_days = parse_u32(value, data.streak_days),
"last_session_date" => data.last_session_date = opt_string(value),
"daily_goal_minutes" => data.daily_goal_minutes = parse_u32(value, data.daily_goal_minutes),
"sound_enabled" => data.sound_enabled = parse_bool(value, data.sound_enabled),
"auto_start_breaks" => data.auto_start_breaks = parse_bool(value, data.auto_start_breaks),
"auto_start_focus" => data.auto_start_focus = parse_bool(value, data.auto_start_focus),
"today_focus_minutes" => {
data.today_focus_minutes = parse_u32(value, data.today_focus_minutes)
}
"today_date" => data.today_date = opt_string(value),
"focus_minutes" => data.focus_minutes = parse_u32(value, data.focus_minutes),
"short_break_minutes" => {
data.short_break_minutes = parse_u32(value, data.short_break_minutes)
}
"long_break_minutes" => data.long_break_minutes = parse_u32(value, data.long_break_minutes),
"long_break_every" => data.long_break_every = parse_u32(value, data.long_break_every),
"auto_pick_task" => data.auto_pick_task = parse_bool(value, data.auto_pick_task),
"auto_advance_task" => data.auto_advance_task = parse_bool(value, data.auto_advance_task),
"theme" if !value.is_empty() => {
data.theme = theme::normalize_theme_id(value);
}
"active_task_id" => data.active_task_id = value.parse().ok(),
"notify_on_finish" => data.notify_on_finish = parse_bool(value, data.notify_on_finish),
"goal_streak_days" => data.goal_streak_days = parse_u32(value, data.goal_streak_days),
"last_goal_date" => data.last_goal_date = opt_string(value),
"empty_queue_behavior" => {
data.empty_queue_behavior =
decode_empty_queue(value).unwrap_or(data.empty_queue_behavior)
}
"log_breaks" => data.log_breaks = parse_bool(value, data.log_breaks),
"estimate_complete" => {
data.estimate_complete =
decode_estimate_complete(value).unwrap_or(data.estimate_complete)
}
_ => {}
}
}
pub(crate) fn load_tasks(conn: &Connection) -> Result<Vec<Task>> {
let mut stmt = conn.prepare(
"SELECT id, title, notes, priority, status, estimated_minutes, actual_minutes,
sessions, created_at, completed_at, due_date, today, sort_order
FROM tasks
ORDER BY sort_order ASC, id ASC",
)?;
let rows = stmt.query_map([], |row| {
Ok(Task {
id: read_u64(row, 0)?,
title: row.get(1)?,
notes: row.get(2)?,
priority: decode_priority(&row.get::<_, String>(3)?),
status: decode_task_status(&row.get::<_, String>(4)?),
estimated_minutes: row.get(5)?,
actual_minutes: row.get(6)?,
sessions: row.get(7)?,
created_at: parse_datetime(&row.get::<_, String>(8)?),
completed_at: row.get::<_, Option<String>>(9)?.map(|s| parse_datetime(&s)),
due_date: row.get::<_, Option<String>>(10)?,
today: row.get::<_, i32>(11)? != 0,
sort_order: row.get(12)?,
tags: Vec::new(),
})
})?;
let mut tasks = Vec::new();
for row in rows {
let mut task = row?;
task.tags = load_task_tags(conn, task.id)?;
tasks.push(task);
}
Ok(tasks)
}
fn load_task_tags(conn: &Connection, task_id: u64) -> Result<Vec<String>> {
let mut stmt = conn.prepare("SELECT tag FROM task_tags WHERE task_id = ?1 ORDER BY tag ASC")?;
let tags = stmt
.query_map(params![task_id as i64], |row| row.get(0))?
.collect::<Result<Vec<String>, _>>()?;
Ok(tags)
}
fn sync_tasks(conn: &Connection, tasks: &[Task]) -> Result<()> {
conn.execute("DELETE FROM task_tags", [])?;
conn.execute("DELETE FROM tasks", [])?;
for task in tasks {
upsert_task_row(conn, task)?;
}
Ok(())
}
fn upsert_task_row(conn: &Connection, task: &Task) -> Result<()> {
conn.execute(
"INSERT INTO tasks (
id, title, notes, priority, status, estimated_minutes, actual_minutes,
sessions, created_at, completed_at, due_date, today, sort_order
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
notes = excluded.notes,
priority = excluded.priority,
status = excluded.status,
estimated_minutes = excluded.estimated_minutes,
actual_minutes = excluded.actual_minutes,
sessions = excluded.sessions,
created_at = excluded.created_at,
completed_at = excluded.completed_at,
due_date = excluded.due_date,
today = excluded.today,
sort_order = excluded.sort_order",
params![
task.id as i64,
task.title,
task.notes,
encode_priority(task.priority),
encode_task_status(task.status),
task.estimated_minutes,
task.actual_minutes,
task.sessions,
task.created_at.to_rfc3339(),
task.completed_at.map(|dt| dt.to_rfc3339()),
task.due_date,
if task.today { 1 } else { 0 },
task.sort_order,
],
)?;
conn.execute(
"DELETE FROM task_tags WHERE task_id = ?1",
params![task.id as i64],
)?;
for tag in &task.tags {
conn.execute(
"INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)",
params![task.id as i64, tag],
)?;
}
Ok(())
}
fn encode_priority(p: Priority) -> &'static str {
match p {
Priority::Low => "low",
Priority::Medium => "medium",
Priority::High => "high",
}
}
fn decode_priority(s: &str) -> Priority {
match s {
"high" => Priority::High,
"low" => Priority::Low,
_ => Priority::Medium,
}
}
fn encode_task_status(s: TaskStatus) -> &'static str {
match s {
TaskStatus::Pending => "pending",
TaskStatus::InProgress => "inprogress",
TaskStatus::Done => "done",
}
}
fn decode_task_status(s: &str) -> TaskStatus {
match s {
"done" => TaskStatus::Done,
"inprogress" | "in_progress" => TaskStatus::InProgress,
_ => TaskStatus::Pending,
}
}
fn encode_timer_mode(m: TimerMode) -> &'static str {
match m {
TimerMode::Focus => "focus",
TimerMode::ShortBreak => "shortbreak",
TimerMode::LongBreak => "longbreak",
TimerMode::Custom => "custom",
}
}
fn decode_timer_mode(s: &str) -> TimerMode {
match s {
"shortbreak" | "short_break" => TimerMode::ShortBreak,
"longbreak" | "long_break" => TimerMode::LongBreak,
"custom" => TimerMode::Custom,
_ => TimerMode::Focus,
}
}
fn encode_empty_queue(b: EmptyQueueBehavior) -> &'static str {
match b {
EmptyQueueBehavior::FreeFocus => "free-focus",
EmptyQueueBehavior::PauseTimer => "pause-timer",
EmptyQueueBehavior::AskEachTime => "ask",
}
}
fn decode_empty_queue(s: &str) -> Option<EmptyQueueBehavior> {
Some(match s {
"pause-timer" => EmptyQueueBehavior::PauseTimer,
"ask" => EmptyQueueBehavior::AskEachTime,
_ => EmptyQueueBehavior::FreeFocus,
})
}
fn encode_estimate_complete(b: EstimateCompleteBehavior) -> &'static str {
match b {
EstimateCompleteBehavior::Nudge => "nudge",
EstimateCompleteBehavior::None => "none",
EstimateCompleteBehavior::AutoDone => "auto-done",
}
}
fn decode_estimate_complete(s: &str) -> Option<EstimateCompleteBehavior> {
Some(match s {
"none" => EstimateCompleteBehavior::None,
"auto-done" => EstimateCompleteBehavior::AutoDone,
_ => EstimateCompleteBehavior::Nudge,
})
}
pub(crate) fn parse_datetime(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now())
}
fn bool_str(v: bool) -> String {
if v { "1" } else { "0" }.to_string()
}
fn parse_bool(s: &str, default: bool) -> bool {
match s {
"1" | "true" | "yes" => true,
"0" | "false" | "no" => false,
_ => default,
}
}
pub(crate) fn read_u64(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<u64> {
Ok(row.get::<_, i64>(idx)? as u64)
}
pub(crate) fn read_opt_u64(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<Option<u64>> {
let value: Option<i64> = row.get(idx)?;
Ok(value.map(|id| id as u64))
}
fn parse_u32(s: &str, default: u32) -> u32 {
s.parse().unwrap_or(default)
}
fn parse_u64(s: &str, default: u64) -> u64 {
s.parse().unwrap_or(default)
}
fn opt_string(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}