use anyhow::Result;
use chrono::Utc;
use crate::db::Database;
use crate::model::{AppData, FocusSessionRecord, Priority, Task, TaskStatus, TimerMode};
pub fn next_id(db: &Database, data: &mut AppData) -> Result<u64> {
let id = data.next_id;
data.next_id = data.next_id.saturating_add(1);
db.set_setting("next_id", data.next_id.to_string())?;
Ok(id)
}
pub fn ensure_today_reset(db: &Database, data: &mut AppData) -> Result<()> {
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
if data.today_date.as_deref() != Some(today.as_str()) {
data.today_focus_minutes = 0;
data.today_date = Some(today.clone());
db.set_setting("today_focus_minutes", "0")?;
db.set_setting("today_date", &today)?;
}
Ok(())
}
pub fn parse_tags(input: &str) -> Vec<String> {
input
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn normalize_due_date(input: &str, allow_past: bool) -> Result<Option<String>, String> {
let s = input.trim();
if s.is_empty() {
return Ok(None);
}
match chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
Ok(parsed) => {
if !allow_past {
let today = chrono::Local::now().date_naive();
if parsed < today {
return Err("Due date cannot be in the past.".into());
}
}
Ok(Some(s.to_string()))
}
Err(_) => match s.to_lowercase().as_str() {
"today" => Ok(Some(chrono::Local::now().format("%Y-%m-%d").to_string())),
"tomorrow" => Ok(Some(
(chrono::Local::now() + chrono::Duration::days(1))
.format("%Y-%m-%d")
.to_string(),
)),
_ => Err("Due date must be YYYY-MM-DD, 'today', or 'tomorrow'.".into()),
},
}
}
pub struct TaskPayload {
pub title: String,
pub notes: String,
pub estimated_minutes: u32,
pub priority: Priority,
pub tags: Vec<String>,
pub due_date: Option<String>,
}
pub fn add_task_full(db: &Database, data: &mut AppData, payload: TaskPayload) -> Result<u64> {
let id = next_id(db, data)?;
let mut task = Task::new(id, payload.title);
task.notes = payload.notes;
task.estimated_minutes = payload.estimated_minutes.clamp(1, 480);
task.priority = payload.priority;
task.tags = payload.tags;
task.due_date = payload.due_date;
db.upsert_task(&task)?;
data.tasks.push(task);
Ok(id)
}
pub fn update_task(db: &Database, data: &mut AppData, id: u64, payload: TaskPayload) -> Result<()> {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
t.title = payload.title;
t.notes = payload.notes;
t.estimated_minutes = payload.estimated_minutes.clamp(1, 480);
t.priority = payload.priority;
t.tags = payload.tags;
t.due_date = payload.due_date;
db.upsert_task(t)?;
}
Ok(())
}
pub fn delete_task(db: &Database, data: &mut AppData, id: u64) -> Result<bool> {
let before = data.tasks.len();
data.tasks.retain(|t| t.id != id);
if before == data.tasks.len() {
return Ok(false);
}
db.delete_task(id)?;
if data.active_task_id == Some(id) {
data.active_task_id = None;
db.persist_active_task(None)?;
}
Ok(true)
}
pub fn promote_task_on_activate(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
if t.status == TaskStatus::Pending {
t.status = TaskStatus::InProgress;
db.upsert_task(t)?;
}
}
Ok(())
}
pub fn mark_task_done(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
t.status = TaskStatus::Done;
t.completed_at = Some(Utc::now());
db.upsert_task(t)?;
}
Ok(())
}
pub fn cycle_task_status(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
match t.status {
TaskStatus::Pending => t.status = TaskStatus::InProgress,
TaskStatus::InProgress => {
t.status = TaskStatus::Done;
t.completed_at = Some(Utc::now());
}
TaskStatus::Done => {
t.status = TaskStatus::Pending;
t.completed_at = None;
}
}
db.upsert_task(t)?;
}
Ok(())
}
pub fn toggle_today(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
t.today = !t.today;
db.upsert_task(t)?;
}
Ok(())
}
pub fn set_priority(db: &Database, data: &mut AppData, id: u64, priority: Priority) -> Result<()> {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
t.priority = priority;
db.upsert_task(t)?;
}
Ok(())
}
pub fn move_task(db: &Database, data: &mut AppData, id: u64, delta: i32) -> Result<()> {
let idx = match data.tasks.iter().position(|t| t.id == id) {
Some(i) => i,
None => return Ok(()),
};
let new_idx = (idx as i32 + delta).clamp(0, data.tasks.len() as i32 - 1) as usize;
if idx != new_idx {
let task = data.tasks.remove(idx);
data.tasks.insert(new_idx, task);
for (i, t) in data.tasks.iter_mut().enumerate() {
t.sort_order = i as u32;
}
db.sync_sort_orders(&data.tasks)?;
}
Ok(())
}
pub fn pick_best_task(data: &AppData) -> Option<u64> {
data.tasks
.iter()
.filter(|t| t.status != TaskStatus::Done)
.max_by(|a, b| {
a.priority
.rank()
.cmp(&b.priority.rank())
.then(b.today.cmp(&a.today))
.then(a.sort_order.cmp(&b.sort_order))
})
.map(|t| t.id)
}
pub fn advance_to_next_task(data: &AppData, current: Option<u64>) -> Option<u64> {
let pending: Vec<&Task> = data
.tasks
.iter()
.filter(|t| t.status != TaskStatus::Done)
.collect();
if pending.is_empty() {
return None;
}
if let Some(cur) = current {
if let Some(pos) = pending.iter().position(|t| t.id == cur) {
let next = (pos + 1) % pending.len();
return Some(pending[next].id);
}
}
pick_best_task(data)
}
pub fn record_focus_session(
db: &Database,
data: &mut AppData,
minutes: u32,
task_id: Option<u64>,
mode: TimerMode,
) -> Result<()> {
ensure_today_reset(db, data)?;
let mins = minutes.max(1);
data.total_focus_minutes = data.total_focus_minutes.saturating_add(mins);
data.today_focus_minutes = data.today_focus_minutes.saturating_add(mins);
data.total_sessions = data.total_sessions.saturating_add(1);
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
match &data.last_session_date {
Some(last) if last == &today => {}
Some(last) => {
let last_date = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").ok();
let today_date = chrono::NaiveDate::parse_from_str(&today, "%Y-%m-%d").ok();
if let (Some(l), Some(t)) = (last_date, today_date) {
if t.succ_opt() == Some(l) {
data.streak_days = data.streak_days.saturating_add(1);
} else if t != l {
data.streak_days = 1;
}
} else {
data.streak_days = 1;
}
}
None => data.streak_days = 1,
}
data.last_session_date = Some(today.clone());
data.today_date = Some(today.clone());
let record = FocusSessionRecord {
date: today,
minutes: mins,
task_id,
mode,
completed_at: Utc::now(),
};
db.insert_focus_session(&record)?;
update_goal_streak(data)?;
db.persist_session_stats(data)?;
if let Some(id) = task_id {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
t.actual_minutes = t.actual_minutes.saturating_add(mins);
t.sessions = t.sessions.saturating_add(1);
if t.status == TaskStatus::Pending {
t.status = TaskStatus::InProgress;
}
db.upsert_task(t)?;
}
}
Ok(())
}
pub fn today_focus_minutes(data: &AppData) -> u32 {
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
if data.today_date.as_deref() == Some(today.as_str()) {
data.today_focus_minutes
} else {
0
}
}
pub fn minutes_by_date(db: &Database, days: usize) -> Result<Vec<(String, u32)>> {
db.minutes_by_date(days)
}
pub fn focus_heatmap(db: &Database) -> Result<Vec<(String, u32)>> {
db.focus_minutes_grouped()
}
pub fn pending_tasks(data: &AppData) -> impl Iterator<Item = &Task> {
data.tasks.iter().filter(|t| t.status != TaskStatus::Done)
}
pub fn sorted_pending_tasks(data: &AppData) -> Vec<&Task> {
let mut tasks: Vec<&Task> = pending_tasks(data).collect();
tasks.sort_by(|a, b| {
b.priority
.rank()
.cmp(&a.priority.rank())
.then(b.today.cmp(&a.today))
.then(a.sort_order.cmp(&b.sort_order))
});
tasks
}
pub fn completed_tasks(data: &AppData) -> impl Iterator<Item = &Task> {
data.tasks.iter().filter(|t| t.status == TaskStatus::Done)
}
pub fn most_productive_hour_label(data: &AppData) -> String {
if data.session_history.is_empty() {
return "N/A".into();
}
let mut hours = [0u32; 24];
for session in &data.session_history {
use chrono::Timelike;
let hour = session.completed_at.with_timezone(&chrono::Local).hour();
hours[hour as usize] += session.minutes;
}
if let Some((hour, &mins)) = hours.iter().enumerate().max_by_key(|&(_, &c)| c) {
if mins > 0 {
let ampm = if hour < 12 { "AM" } else { "PM" };
let h = if hour == 0 {
12
} else if hour > 12 {
hour - 12
} else {
hour
};
return format!("{}{} ({}m)", h, ampm, mins);
}
}
"N/A".into()
}
pub fn queue_empty(data: &AppData) -> bool {
pending_tasks(data).next().is_none()
}
pub fn update_goal_streak(data: &mut AppData) -> Result<()> {
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
if data.today_focus_minutes < data.daily_goal_minutes {
return Ok(());
}
match &data.last_goal_date {
Some(last) if last == &today => {}
Some(last) => {
let last_date = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").ok();
let today_date = chrono::NaiveDate::parse_from_str(&today, "%Y-%m-%d").ok();
if let (Some(l), Some(t)) = (last_date, today_date) {
if t.succ_opt() == Some(l) {
data.goal_streak_days = data.goal_streak_days.saturating_add(1);
} else if t != l {
data.goal_streak_days = 1;
}
} else {
data.goal_streak_days = 1;
}
}
None => data.goal_streak_days = 1,
}
data.last_goal_date = Some(today);
Ok(())
}
pub fn record_break_session(
db: &Database,
data: &mut AppData,
mode: TimerMode,
minutes: u32,
) -> Result<()> {
if !data.log_breaks {
return Ok(());
}
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let record = FocusSessionRecord {
date: today,
minutes: minutes.max(1),
task_id: None,
mode,
completed_at: Utc::now(),
};
db.insert_focus_session(&record)?;
data.total_sessions = data.total_sessions.saturating_add(1);
db.persist_session_stats(data)?;
Ok(())
}
pub fn delete_session(db: &Database, data: &mut AppData, id: i64) -> Result<()> {
let stored = db.get_session(id)?;
let r = &stored.record;
if matches!(r.mode, TimerMode::Focus | TimerMode::Custom) {
data.total_focus_minutes = data.total_focus_minutes.saturating_sub(r.minutes);
data.today_focus_minutes = data.today_focus_minutes.saturating_sub(r.minutes);
}
data.total_sessions = data.total_sessions.saturating_sub(1);
if let Some(tid) = r.task_id {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == tid) {
t.actual_minutes = t.actual_minutes.saturating_sub(r.minutes);
t.sessions = t.sessions.saturating_sub(1);
db.upsert_task(t)?;
}
}
db.delete_focus_session(id)?;
db.persist_session_stats(data)?;
Ok(())
}
pub fn adjust_session_minutes(
db: &Database,
data: &mut AppData,
id: i64,
new_minutes: u32,
) -> Result<()> {
let stored = db.get_session(id)?;
let old = stored.record.minutes;
let new_minutes = new_minutes.clamp(1, 480);
if old == new_minutes {
return Ok(());
}
if matches!(stored.record.mode, TimerMode::Focus | TimerMode::Custom) {
let delta = new_minutes as i32 - old as i32;
if delta > 0 {
data.total_focus_minutes = data.total_focus_minutes.saturating_add(delta as u32);
data.today_focus_minutes = data.today_focus_minutes.saturating_add(delta as u32);
} else {
data.total_focus_minutes = data.total_focus_minutes.saturating_sub((-delta) as u32);
data.today_focus_minutes = data.today_focus_minutes.saturating_sub((-delta) as u32);
}
}
if let Some(tid) = stored.record.task_id {
if let Some(t) = data.tasks.iter_mut().find(|t| t.id == tid) {
if new_minutes > old {
t.actual_minutes = t.actual_minutes.saturating_add(new_minutes - old);
} else {
t.actual_minutes = t.actual_minutes.saturating_sub(old - new_minutes);
}
db.upsert_task(t)?;
}
}
db.update_session_minutes(id, new_minutes)?;
update_goal_streak(data)?;
db.persist_session_stats(data)?;
Ok(())
}
pub fn sessions_remaining_hint(task: &Task, focus_minutes: u32) -> u32 {
if task.estimated_minutes <= task.actual_minutes {
return 0;
}
let left = task.estimated_minutes - task.actual_minutes;
let session = focus_minutes.max(1);
left.div_ceil(session)
}