use std::collections::HashMap;
use anyhow::Result;
use arboard::Clipboard;
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike};
use crate::config::Config;
use crate::db::Database;
use crate::integrations::{IntegrationKind, SecretsManager};
use crate::models::{Task, TimeEntry};
use crate::triggers::TriggerEvent;
#[derive(Clone)]
pub struct TaskEditState {
pub task_id: Option<i64>, pub issue_key: String,
pub name: String,
pub project: String,
pub current_field: usize, }
#[derive(Clone)]
pub enum InputMode {
Normal,
Editing {
field: String,
entry_id: i64,
description: String,
start_time: String,
end_time: String,
issue_key: String,
current_field: usize,
cursor_pos: usize,
},
Creating {
field: String,
description: String,
start_time: String,
end_time: String,
issue_key: String,
current_field: usize,
cursor_pos: usize,
suggestions: Vec<(String, String, i64)>, selected_suggestion: usize, off_work: bool, },
ConfirmDelete {
entry_id: i64,
},
Settings {
field: String,
integration: IntegrationKind,
open_command: String,
open_worklog_command: String,
jira_url_setting: String,
jira_email: String,
jira_api_token: String,
date_format: String,
legacy_time_format: bool,
hide_eye_candy: bool,
colors: [String; 6],
current_field: usize,
cursor_pos: usize,
debug_log_scroll_offset: usize,
},
WhatsNew,
Tasks {
tasks: Vec<Task>,
selected_index: usize,
editing: Option<TaskEditState>,
confirm_delete: bool,
},
PassphrasePrompt {
passphrase: String,
cursor_pos: usize,
error_message: Option<String>,
confirm_delete: bool,
},
PassphraseChange {
old_passphrase: String,
new_passphrase: String,
confirm_passphrase: String,
current_field: usize,
cursor_pos: usize,
error_message: Option<String>,
is_initial_setup: bool,
},
OperationsMenu {
selected_index: usize,
},
Hotkeys,
EditingDay {
field: String,
start_time: String,
end_time: String,
current_field: usize,
cursor_pos: usize,
},
WeekSummary {
anchor: NaiveDate,
selected_day: usize,
selected_field: usize,
editing: Option<DayEditDraft>,
},
Triggers {
field: String,
enabled: [bool; 3],
urls: [String; 3],
bodies: [String; 3],
current_field: usize,
cursor_pos: usize,
},
}
#[derive(Clone)]
pub struct DayEditDraft {
pub date: NaiveDate,
pub kind: usize,
pub start: String,
pub end: String,
pub sub_field: usize,
pub lunch_entry_id: Option<i64>,
}
pub struct App {
pub db: Database,
pub entries: Vec<TimeEntry>,
pub selected_index: Option<usize>,
pub at_work_selected: bool,
pub day_start_override: Option<NaiveDateTime>,
pub day_end_override: Option<NaiveDateTime>,
pub current_date: NaiveDate,
pub input_mode: InputMode,
pub total_duration_minutes: i64,
pub weekly_stats: Vec<(String, i64)>,
pub config: Config,
pub status_message: Option<String>,
pub debug_log: Vec<String>,
pub clipboard: Option<Clipboard>,
pub secrets: SecretsManager,
pub pending_api_token: Option<String>,
pub task_names: HashMap<String, String>,
pub quit_armed_at: Option<std::time::Instant>,
pub term_palette: HashMap<u8, (u8, u8, u8)>,
pub last_seen_date: NaiveDate,
pub available_update: crate::update_check::UpdateSlot,
}
impl App {
pub fn new(db_path: &str) -> Result<Self> {
let db = Database::new(db_path)?;
let current_date = Local::now().date_naive();
let _ = db.auto_end_stale_running(current_date);
let entries = db.get_entries_for_date(current_date)?;
let total_duration_minutes = db.get_total_duration_for_date(current_date)?;
let weekly_stats = db.get_weekly_stats(current_date)?;
let config = Config::load()?;
let selected_index = if entries.is_empty() { None } else { Some(0) };
let secrets = SecretsManager::new();
let task_names = db.get_task_name_map().unwrap_or_default();
let (day_start_override, day_end_override) = db.get_day_overrides(current_date).unwrap_or((None, None));
Ok(App {
db,
entries,
selected_index,
at_work_selected: false,
day_start_override,
day_end_override,
current_date,
input_mode: InputMode::Normal,
total_duration_minutes,
weekly_stats,
config,
status_message: None,
debug_log: Vec::new(),
clipboard: None,
secrets,
pending_api_token: None,
task_names,
quit_armed_at: None,
term_palette: HashMap::new(),
last_seen_date: current_date,
available_update: std::sync::Arc::new(std::sync::Mutex::new(None)),
})
}
const QUIT_CONFIRM_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
pub fn quit_armed(&self) -> bool {
self.quit_armed_at
.map(|t| t.elapsed() < Self::QUIT_CONFIRM_WINDOW)
.unwrap_or(false)
}
pub fn handle_quit_key(&mut self) -> bool {
if self.quit_armed() {
true
} else {
self.quit_armed_at = Some(std::time::Instant::now());
false
}
}
pub fn refresh_task_names(&mut self) {
self.task_names = self.db.get_task_name_map().unwrap_or_default();
}
pub fn tick(&mut self) -> Result<()> {
let today = Local::now().date_naive();
if today != self.last_seen_date {
self.db.auto_end_stale_running(today)?;
self.last_seen_date = today;
self.refresh_entries()?;
}
Ok(())
}
pub fn refresh_entries(&mut self) -> Result<()> {
self.entries = self.db.get_entries_for_date(self.current_date)?;
self.total_duration_minutes = self.db.get_total_duration_for_date(self.current_date)?;
self.weekly_stats = self.db.get_weekly_stats(self.current_date)?;
self.refresh_task_names();
let (start_ov, end_ov) = self.db.get_day_overrides(self.current_date).unwrap_or((None, None));
self.day_start_override = start_ov;
self.day_end_override = end_ov;
if self.entries.is_empty() {
self.selected_index = None;
} else if let Some(idx) = self.selected_index {
if idx >= self.entries.len() {
self.selected_index = Some(self.entries.len() - 1);
}
} else {
self.selected_index = Some(0);
}
if self.at_work_selected && !self.has_day_row() {
self.at_work_selected = false;
}
Ok(())
}
pub fn at_work_span(&self) -> Option<(NaiveDateTime, NaiveDateTime, bool)> {
let now = Local::now().naive_local();
at_work_span_of(
&self.entries,
self.day_start_override,
self.day_end_override,
now,
)
}
pub fn has_day_row(&self) -> bool {
self.at_work_span().is_some()
}
pub fn select_next(&mut self) {
if self.at_work_selected {
self.at_work_selected = false;
if !self.entries.is_empty() {
self.selected_index = Some(0);
} else {
self.at_work_selected = true;
}
return;
}
if self.entries.is_empty() {
return;
}
self.selected_index = Some(match self.selected_index {
Some(i) => {
if i >= self.entries.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
});
}
pub fn select_previous(&mut self) {
if self.at_work_selected {
return; }
match self.selected_index {
Some(0) | None => {
if self.has_day_row() {
self.at_work_selected = true;
} else if !self.entries.is_empty() {
self.selected_index = Some(0);
}
}
Some(i) => self.selected_index = Some(i - 1),
}
}
pub fn move_entry_up(&mut self) -> Result<()> {
if self.at_work_selected {
return Ok(());
}
if let Some(idx) = self.selected_index {
if idx > 0 {
self.db.reorder_entries(self.current_date, idx, idx - 1)?;
self.refresh_entries()?;
self.selected_index = Some(idx - 1);
}
}
Ok(())
}
pub fn move_entry_down(&mut self) -> Result<()> {
if self.at_work_selected {
return Ok(());
}
if let Some(idx) = self.selected_index {
if idx < self.entries.len() - 1 {
self.db.reorder_entries(self.current_date, idx, idx + 1)?;
self.refresh_entries()?;
self.selected_index = Some(idx + 1);
}
}
Ok(())
}
pub fn next_day(&mut self) -> Result<()> {
self.current_date = self.current_date.succ_opt().unwrap_or(self.current_date);
self.refresh_entries()?;
Ok(())
}
pub fn previous_day(&mut self) -> Result<()> {
self.current_date = self.current_date.pred_opt().unwrap_or(self.current_date);
self.refresh_entries()?;
Ok(())
}
pub fn start_creating(&mut self) {
self.start_creating_inner(false);
}
pub fn start_creating_off_work(&mut self) {
self.start_creating_inner(true);
}
fn start_creating_inner(&mut self, off_work: bool) {
let now = Local::now().naive_local();
let suggestions = self
.db
.get_previous_tasks_with_issue_keys()
.unwrap_or_default();
self.input_mode = InputMode::Creating {
field: "Description".to_string(),
description: String::new(),
start_time: now.format("%H:%M").to_string(),
end_time: String::new(),
issue_key: String::new(),
current_field: 0,
cursor_pos: 0,
suggestions,
selected_suggestion: 0, off_work,
};
}
pub fn suggestion_next(&mut self) {
if let InputMode::Creating {
suggestions,
selected_suggestion,
..
} = &mut self.input_mode
{
let max_index = suggestions.len(); *selected_suggestion = (*selected_suggestion + 1) % (max_index + 1);
}
}
pub fn suggestion_previous(&mut self) {
if let InputMode::Creating {
suggestions,
selected_suggestion,
..
} = &mut self.input_mode
{
let max_index = suggestions.len();
*selected_suggestion = if *selected_suggestion == 0 {
max_index
} else {
*selected_suggestion - 1
};
}
}
pub fn apply_selected_suggestion(&mut self) {
if let InputMode::Creating {
suggestions,
selected_suggestion,
description,
issue_key,
..
} = &mut self.input_mode
{
if *selected_suggestion > 0 {
if let Some((sugg_issue_key, sugg_description, _)) =
suggestions.get(*selected_suggestion - 1)
{
*description = sugg_description.clone();
*issue_key = sugg_issue_key.clone();
}
}
}
self.update_field_label();
}
pub fn start_editing(&mut self) {
if let Some(idx) = self.selected_index {
if let Some(entry) = self.entries.get(idx) {
let desc_len = entry.description.chars().count();
self.input_mode = InputMode::Editing {
field: "Description".to_string(),
entry_id: entry.id,
description: entry.description.clone(),
start_time: entry.start_time.format("%H:%M").to_string(),
end_time: entry
.end_time
.map(|t| t.format("%H:%M").to_string())
.unwrap_or_default(),
issue_key: entry.issue_key.clone(),
current_field: 0,
cursor_pos: desc_len,
};
self.update_field_label();
}
}
}
pub fn cursor_left(&mut self) {
crate::cursor::cursor_left(&mut self.input_mode);
self.update_field_label();
}
pub fn cursor_right(&mut self) {
crate::cursor::cursor_right(&mut self.input_mode);
self.update_field_label();
}
pub fn input_char(&mut self, c: char) {
if let InputMode::Creating { current_field, selected_suggestion, .. } = &mut self.input_mode {
if *current_field == 0 || *current_field == 3 {
*selected_suggestion = 0;
}
}
crate::cursor::insert_char(&mut self.input_mode, c);
self.update_field_label();
}
pub fn delete_char(&mut self) {
if let InputMode::Creating { current_field, selected_suggestion, .. } = &mut self.input_mode {
if *current_field == 0 || *current_field == 3 {
*selected_suggestion = 0;
}
}
crate::cursor::delete_char(&mut self.input_mode);
self.update_field_label();
}
pub fn settings_legacy_time_format_field(&self) -> usize {
if let InputMode::Settings { integration, .. } = &self.input_mode {
match integration {
IntegrationKind::CustomCommands => 4,
IntegrationKind::Jira => 5,
}
} else {
4
}
}
pub fn settings_passphrase_field(&self) -> usize {
if let InputMode::Settings { integration, .. } = &self.input_mode {
match integration {
IntegrationKind::CustomCommands => 5,
IntegrationKind::Jira => 6,
}
} else {
5
}
}
pub fn open_triggers(&mut self) {
let t = &self.config.triggers;
self.input_mode = InputMode::Triggers {
field: String::new(),
enabled: [t.day_start.enabled, t.ooo_start.enabled, t.ooo_end.enabled],
urls: [t.day_start.url.clone(), t.ooo_start.url.clone(), t.ooo_end.url.clone()],
bodies: [t.day_start.body.clone(), t.ooo_start.body.clone(), t.ooo_end.body.clone()],
current_field: 0,
cursor_pos: 0,
};
self.update_field_label();
}
pub fn save_triggers(&mut self) -> Result<()> {
if let InputMode::Triggers { enabled, urls, bodies, .. } = &self.input_mode {
let events = [
&mut self.config.triggers.day_start,
&mut self.config.triggers.ooo_start,
&mut self.config.triggers.ooo_end,
];
let enabled = *enabled;
let urls = urls.clone();
let bodies = bodies.clone();
for (i, ev) in events.into_iter().enumerate() {
ev.enabled = enabled[i];
ev.url = urls[i].clone();
ev.body = bodies[i].clone();
}
self.config.save()?;
}
self.open_settings();
Ok(())
}
pub fn fire_trigger(&mut self, event: TriggerEvent, description: &str) {
let cfg = match event {
TriggerEvent::DayStart => &self.config.triggers.day_start,
TriggerEvent::OooStart => &self.config.triggers.ooo_start,
TriggerEvent::OooEnd => &self.config.triggers.ooo_end,
};
if !cfg.enabled || cfg.url.trim().is_empty() {
return;
}
let now = Local::now();
let date = now.format("%Y-%m-%d").to_string();
let time = now.format("%H:%M").to_string();
let datetime = now.to_rfc3339();
let body = Config::substitute_trigger_variables(
&cfg.body,
event.label(),
&date,
&time,
&datetime,
description,
);
let url = cfg.url.clone();
match crate::triggers::send(&url, &body) {
Ok(status) => self
.debug_log
.push(format!("[TRIGGER] {} → HTTP {}", event.label(), status)),
Err(e) => self
.debug_log
.push(format!("[TRIGGER ERROR] {}: {}", event.label(), e)),
}
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
fn maybe_fire_day_start(&mut self) {
if self.current_date != Local::now().date_naive() || !self.has_day_row() {
return;
}
let today = self.current_date.to_string();
let already = self.db.get_app_setting("trigger_day_start_fired").ok().flatten();
if already.as_deref() == Some(today.as_str()) {
return;
}
let _ = self.db.set_app_setting("trigger_day_start_fired", &today);
self.fire_trigger(TriggerEvent::DayStart, "");
}
pub fn cycle_integration(&mut self) {
if let InputMode::Settings { integration, current_field, .. } = &mut self.input_mode {
if *current_field == 0 {
*integration = integration.cycle_next();
}
}
self.update_field_label();
}
pub fn next_field(&mut self) {
match &mut self.input_mode {
InputMode::Creating { current_field, .. }
| InputMode::Editing { current_field, .. } => {
*current_field = (*current_field + 1) % 4;
}
InputMode::EditingDay { current_field, .. } => {
*current_field = (*current_field + 1) % 2;
}
InputMode::Triggers { current_field, .. } => {
*current_field = (*current_field + 1) % 9;
}
InputMode::Settings { current_field, integration, .. } => {
let count = match integration {
IntegrationKind::CustomCommands => 14,
IntegrationKind::Jira => 15,
};
*current_field = (*current_field + 1) % count;
}
InputMode::PassphraseChange { current_field, is_initial_setup, .. } => {
let count = if *is_initial_setup { 2 } else { 3 };
*current_field = (*current_field + 1) % count;
}
_ => {}
}
crate::cursor::reset_cursor_to_end(&mut self.input_mode);
self.update_field_label();
}
pub fn previous_field(&mut self) {
match &mut self.input_mode {
InputMode::Triggers { current_field, .. } => {
*current_field = if *current_field == 0 { 8 } else { *current_field - 1 };
}
InputMode::Settings { current_field, integration, .. } => {
let count = match integration {
IntegrationKind::CustomCommands => 14,
IntegrationKind::Jira => 15,
};
*current_field = if *current_field == 0 {
count - 1
} else {
*current_field - 1
};
}
InputMode::PassphraseChange { current_field, is_initial_setup, .. } => {
let count = if *is_initial_setup { 2 } else { 3 };
*current_field = if *current_field == 0 {
count - 1
} else {
*current_field - 1
};
}
_ => {}
}
crate::cursor::reset_cursor_to_end(&mut self.input_mode);
self.update_field_label();
}
pub fn cycle_color(&mut self, forward: bool) {
if let InputMode::Settings {
integration,
colors,
current_field,
..
} = &mut self.input_mode
{
let colors_start = match integration {
IntegrationKind::CustomCommands => 8,
IntegrationKind::Jira => 9,
};
if *current_field >= colors_start && *current_field < colors_start + 6 {
let color_idx = *current_field - colors_start;
let available = Config::available_colors();
if let Some(current_idx) = available.iter().position(|&c| c == colors[color_idx]) {
let new_idx = if forward {
(current_idx + 1) % available.len()
} else {
if current_idx == 0 {
available.len() - 1
} else {
current_idx - 1
}
};
colors[color_idx] = available[new_idx].to_string();
}
}
}
self.update_field_label();
}
pub fn insert_variable(&mut self) {
if let InputMode::Settings {
integration,
current_field,
..
} = &self.input_mode
{
if *integration != IntegrationKind::CustomCommands {
return;
}
if *current_field != 1 && *current_field != 2 {
return;
}
let variables = Config::available_variables();
static mut VARIABLE_INDEX: usize = 0;
let var = unsafe {
let v = variables[VARIABLE_INDEX % variables.len()];
VARIABLE_INDEX = (VARIABLE_INDEX + 1) % variables.len();
v
};
crate::cursor::insert_str(&mut self.input_mode, var);
}
self.update_field_label();
}
pub fn insert_trigger_variable(&mut self) {
if let InputMode::Triggers { current_field, .. } = &self.input_mode {
if current_field % 3 == 0 {
return;
}
let variables = Config::available_trigger_variables();
static mut TRIGGER_VARIABLE_INDEX: usize = 0;
let var = unsafe {
let v = variables[TRIGGER_VARIABLE_INDEX % variables.len()];
TRIGGER_VARIABLE_INDEX = (TRIGGER_VARIABLE_INDEX + 1) % variables.len();
v
};
crate::cursor::insert_str(&mut self.input_mode, var);
}
self.update_field_label();
}
pub fn increment_time(&mut self) {
match &mut self.input_mode {
InputMode::Creating {
start_time,
end_time,
current_field,
..
}
| InputMode::Editing {
start_time,
end_time,
current_field,
..
} => {
if *current_field == 1 {
*start_time = adjust_time_string(start_time, 1);
} else if *current_field == 2 {
*end_time = adjust_time_string(end_time, 1);
}
}
InputMode::EditingDay { start_time, end_time, current_field, .. } => {
if *current_field == 0 {
*start_time = adjust_time_string(start_time, 1);
} else if *current_field == 1 {
*end_time = adjust_time_string(end_time, 1);
}
}
_ => {}
}
self.update_field_label();
}
pub fn decrement_time(&mut self) {
match &mut self.input_mode {
InputMode::Creating {
start_time,
end_time,
current_field,
..
}
| InputMode::Editing {
start_time,
end_time,
current_field,
..
} => {
if *current_field == 1 {
*start_time = adjust_time_string(start_time, -1);
} else if *current_field == 2 {
*end_time = adjust_time_string(end_time, -1);
}
}
InputMode::EditingDay { start_time, end_time, current_field, .. } => {
if *current_field == 0 {
*start_time = adjust_time_string(start_time, -1);
} else if *current_field == 1 {
*end_time = adjust_time_string(end_time, -1);
}
}
_ => {}
}
self.update_field_label();
}
fn update_field_label(&mut self) {
let show_cursor = true;
match &mut self.input_mode {
InputMode::Creating {
field,
current_field,
cursor_pos,
description,
start_time,
end_time,
issue_key,
..
} => {
let cp = *cursor_pos;
*field = match current_field {
0 => format!("Description [{}]", crate::cursor::render_with_cursor(description, cp, show_cursor)),
1 => format!("Start Time [{}]", crate::cursor::render_with_cursor(start_time, cp, show_cursor)),
2 => format!("End Time [{}]", crate::cursor::render_with_cursor(end_time, cp, show_cursor)),
3 => format!("Issue Key [{}]", crate::cursor::render_with_cursor(issue_key, cp, show_cursor)),
_ => "Unknown".to_string(),
};
}
InputMode::Editing {
field,
current_field,
cursor_pos,
description,
start_time,
end_time,
issue_key,
..
} => {
let cp = *cursor_pos;
*field = match current_field {
0 => format!("Description [{}]", crate::cursor::render_with_cursor(description, cp, show_cursor)),
1 => format!("Start Time [{}]", crate::cursor::render_with_cursor(start_time, cp, show_cursor)),
2 => format!("End Time [{}]", crate::cursor::render_with_cursor(end_time, cp, show_cursor)),
3 => format!("Issue Key [{}]", crate::cursor::render_with_cursor(issue_key, cp, show_cursor)),
_ => "Unknown".to_string(),
};
}
InputMode::Settings {
field,
integration,
current_field,
open_command,
open_worklog_command,
jira_url_setting,
jira_email,
jira_api_token,
date_format,
legacy_time_format,
hide_eye_candy,
colors,
..
} => {
*field = match integration {
IntegrationKind::CustomCommands => match current_field {
0 => format!("Integration: {}", integration.display_name()),
1 => format!("Log Work Command: {}", open_command),
2 => format!("Open Issue Command: {}", open_worklog_command),
3 => format!("Date Format: {}", date_format),
4 => format!("Legacy Time Format: {}", if *legacy_time_format { "Yes" } else { "No" }),
5 => "Change Passphrase".to_string(),
6 => "Triggers".to_string(),
7 => format!("Hide Eye Candy: {}", if *hide_eye_candy { "Yes" } else { "No" }),
8..=13 => {
let color_idx = *current_field - 8;
format!("{}: {}", Config::color_names()[color_idx], colors[color_idx])
}
_ => "Settings".to_string(),
},
IntegrationKind::Jira => match current_field {
0 => format!("Integration: {}", integration.display_name()),
1 => format!("Jira URL: {}", jira_url_setting),
2 => format!("Jira Email: {}", jira_email),
3 => format!("API Token: {}", "*".repeat(jira_api_token.len().min(20))),
4 => format!("Date Format: {}", date_format),
5 => format!("Legacy Time Format: {}", if *legacy_time_format { "Yes" } else { "No" }),
6 => "Change Passphrase".to_string(),
7 => "Triggers".to_string(),
8 => format!("Hide Eye Candy: {}", if *hide_eye_candy { "Yes" } else { "No" }),
9..=14 => {
let color_idx = *current_field - 9;
format!("{}: {}", Config::color_names()[color_idx], colors[color_idx])
}
_ => "Settings".to_string(),
},
};
}
InputMode::EditingDay {
field,
current_field,
cursor_pos,
start_time,
end_time,
} => {
let cp = *cursor_pos;
*field = match current_field {
0 => format!("Start Time [{}]", crate::cursor::render_with_cursor(start_time, cp, show_cursor)),
1 => format!("End Time [{}]", crate::cursor::render_with_cursor(end_time, cp, show_cursor)),
_ => "Unknown".to_string(),
};
}
InputMode::Triggers {
field,
current_field,
cursor_pos,
urls,
bodies,
..
} => {
let cp = *cursor_pos;
let event = *current_field / 3;
let sub = *current_field % 3;
*field = match sub {
1 => format!("URL [{}]", crate::cursor::render_with_cursor(&urls[event], cp, show_cursor)),
2 => format!("Body [{}]", crate::cursor::render_with_cursor(&bodies[event], cp, show_cursor)),
_ => "Enabled".to_string(),
};
}
InputMode::PassphrasePrompt { .. } => {}
InputMode::PassphraseChange { .. } => {}
_ => {}
}
}
pub fn save_entry(&mut self) -> Result<()> {
match &self.input_mode {
InputMode::Creating {
description,
start_time,
end_time,
issue_key,
off_work,
..
} => {
let start = self.parse_time(start_time)?;
let end = if end_time.is_empty() {
None
} else {
Some(self.parse_time(end_time)?)
};
let ik = issue_key.clone();
let off_work = *off_work;
let description = description.clone();
let new_id = self
.db
.create_entry(description.clone(), start, end, ik.clone())?;
if off_work {
self.db.toggle_off_work(new_id)?;
}
if !ik.is_empty() {
let _ = self.db.ensure_task_exists(&ik);
if !self.task_names.contains_key(&ik) {
self.sync_task_name_from_jira(&ik);
}
}
self.refresh_entries()?;
if off_work {
self.fire_trigger(TriggerEvent::OooStart, &description);
} else {
self.maybe_fire_day_start();
}
}
InputMode::Editing {
entry_id,
description,
start_time,
end_time,
issue_key,
..
} => {
let start = self.parse_time(start_time)?;
let end = if end_time.is_empty() {
None
} else {
Some(self.parse_time(end_time)?)
};
let ik = issue_key.clone();
self.db.update_entry(
*entry_id,
description.clone(),
start,
end,
ik.clone(),
)?;
if !ik.is_empty() {
let _ = self.db.ensure_task_exists(&ik);
if !self.task_names.contains_key(&ik) {
self.sync_task_name_from_jira(&ik);
}
}
self.refresh_entries()?;
}
InputMode::EditingDay {
start_time,
end_time,
..
} => {
let start = self.parse_time(start_time)?;
let end = self.parse_time(end_time)?;
self.db.set_day_overrides(self.current_date, Some(start), Some(end))?;
self.refresh_entries()?;
}
_ => {}
}
self.input_mode = InputMode::Normal;
Ok(())
}
pub fn cancel_input(&mut self) {
self.input_mode = InputMode::Normal;
}
pub fn open_settings(&mut self) {
let jira_api_token = self.secrets.get("jira_api_token")
.unwrap_or(None)
.unwrap_or_default();
self.input_mode = InputMode::Settings {
field: "Integration".to_string(),
integration: self.config.integration.clone(),
open_command: self.config.open_command.clone(),
open_worklog_command: self.config.open_worklog_command.clone(),
jira_url_setting: self.config.jira_url_setting.clone().unwrap_or_default(),
jira_email: self.config.jira_email.clone().unwrap_or_default(),
jira_api_token,
date_format: self.config.date_format.clone(),
legacy_time_format: self.config.legacy_time_format,
hide_eye_candy: self.config.hide_eye_candy,
colors: self.config.colors.clone(),
current_field: 0,
cursor_pos: 0,
debug_log_scroll_offset: 0,
};
self.update_field_label();
}
pub fn save_settings(&mut self) -> Result<()> {
if let InputMode::Settings {
integration,
open_command,
open_worklog_command,
jira_url_setting,
jira_email,
jira_api_token,
date_format,
legacy_time_format,
hide_eye_candy,
colors,
..
} = &self.input_mode
{
self.config.integration = integration.clone();
self.config.open_command = open_command.clone();
self.config.open_worklog_command = open_worklog_command.clone();
self.config.jira_url_setting = if jira_url_setting.is_empty() {
None
} else {
Some(jira_url_setting.clone())
};
self.config.jira_email = if jira_email.is_empty() {
None
} else {
Some(jira_email.clone())
};
self.config.date_format = date_format.clone();
self.config.legacy_time_format = *legacy_time_format;
self.config.hide_eye_candy = *hide_eye_candy;
self.config.colors = colors.clone();
self.config.save()?;
if jira_api_token.is_empty() {
let _ = self.secrets.delete("jira_api_token");
} else {
if !self.secrets.is_unlocked() && !self.secrets.has_encrypted_file() {
self.pending_api_token = Some(jira_api_token.clone());
self.input_mode = InputMode::PassphraseChange {
old_passphrase: String::new(),
new_passphrase: String::new(),
confirm_passphrase: String::new(),
current_field: 0,
cursor_pos: 0,
error_message: None,
is_initial_setup: true,
};
return Ok(());
}
self.secrets.set("jira_api_token", jira_api_token)?;
}
}
self.input_mode = InputMode::Normal;
Ok(())
}
pub fn confirm_delete(&mut self) {
if let Some(idx) = self.selected_index {
if let Some(entry) = self.entries.get(idx) {
self.input_mode = InputMode::ConfirmDelete { entry_id: entry.id };
}
}
}
pub fn delete_entry(&mut self, entry_id: i64) -> Result<()> {
self.db.delete_entry(entry_id)?;
self.refresh_entries()?;
self.input_mode = InputMode::Normal;
Ok(())
}
pub fn force_delete_entry(&mut self) -> Result<()> {
if let Some(idx) = self.selected_index {
if let Some(entry) = self.entries.get(idx) {
self.db.delete_entry(entry.id)?;
self.refresh_entries()?;
}
}
Ok(())
}
pub fn stop_or_restart_entry(&mut self) -> Result<()> {
if let Some(idx) = self.selected_index {
if let Some(entry) = self.entries.get(idx) {
if entry.is_running() {
let now = Local::now().naive_local();
let was_off_work = entry.off_work;
let description = entry.description.clone();
self.db.update_entry(
entry.id,
entry.description.clone(),
entry.start_time,
Some(now),
entry.issue_key.clone(),
)?;
self.refresh_entries()?;
if was_off_work {
self.fire_trigger(TriggerEvent::OooEnd, &description);
}
} else {
let now = Local::now().naive_local();
let suggestions = self
.db
.get_previous_tasks_with_issue_keys()
.unwrap_or_default();
let desc_len = entry.description.chars().count();
self.input_mode = InputMode::Creating {
field: "Description".to_string(),
description: entry.description.clone(),
start_time: now.format("%H:%M").to_string(),
end_time: String::new(),
issue_key: entry.issue_key.clone(),
current_field: 0,
cursor_pos: desc_len,
suggestions,
selected_suggestion: 0,
off_work: false,
};
}
}
}
Ok(())
}
pub fn toggle_at_work_start(&mut self) -> Result<()> {
let new_start = if self.day_start_override.is_some() {
None
} else {
Some(Local::now().naive_local())
};
self.db.set_day_overrides(self.current_date, new_start, self.day_end_override)?;
self.refresh_entries()?;
self.maybe_fire_day_start();
Ok(())
}
pub fn start_editing_day(&mut self) {
if let Some((start, end, _)) = self.at_work_span() {
let start_str = start.format("%H:%M").to_string();
let cursor_pos = start_str.chars().count();
self.input_mode = InputMode::EditingDay {
field: String::new(),
start_time: start_str,
end_time: end.format("%H:%M").to_string(),
current_field: 0,
cursor_pos,
};
self.update_field_label();
}
}
pub fn reset_day_overrides(&mut self) -> Result<()> {
self.db.set_day_overrides(self.current_date, None, None)?;
self.refresh_entries()?;
Ok(())
}
fn parse_time(&self, time_str: &str) -> Result<NaiveDateTime> {
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
.or_else(|_| NaiveTime::parse_from_str(time_str, "%H:%M:%S"))?;
Ok(self.current_date.and_time(time))
}
pub fn copy_time_to_clipboard(&mut self) -> Result<()> {
if let Some(idx) = self.selected_index {
if let Some(entry) = self.entries.get(idx) {
if let Some(duration_minutes) = entry.duration_minutes() {
let time_str = format!("{}m", duration_minutes);
if self.clipboard.is_none() {
self.clipboard = Clipboard::new().ok();
}
if let Some(clip) = &mut self.clipboard {
match clip.set_text(time_str.clone()) {
Ok(_) => {
self.debug_log
.push(format!("[CLIPBOARD] Copied: {}", time_str));
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
Err(e) => {
self.debug_log.push(format!("[CLIPBOARD ERROR] {}", e));
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
}
}
}
}
}
Ok(())
}
fn unlog_entry(&mut self, entry_id: i64, issue_key: &str, worklog_id: &str, is_jira: bool) {
if is_jira && !worklog_id.is_empty() {
let jira_url = self.config.jira_url_setting.clone().unwrap_or_default();
let jira_email = self.config.jira_email.clone().unwrap_or_default();
let api_token = self.secrets.get("jira_api_token").unwrap_or(None).unwrap_or_default();
if let Some(output) = crate::integrations::delete_work(
&self.config.integration,
&jira_url,
&jira_email,
&api_token,
issue_key,
worklog_id,
) {
for msg in &output.messages {
self.debug_log.push(msg.clone());
}
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
if output.success {
let _ = self.db.unmark_logged(entry_id);
let _ = self.refresh_entries();
self.status_message = Some("Worklog deleted from Jira".to_string());
} else {
self.status_message = Some("Failed to delete worklog (see debug log)".to_string());
}
}
} else {
let _ = self.db.unmark_logged(entry_id);
let _ = self.refresh_entries();
self.status_message = Some("Unmarked as logged".to_string());
}
}
pub fn run_log_command(&mut self) -> Result<()> {
if let Some(idx) = self.selected_index {
if let Some(entry) = self.entries.get(idx) {
if entry.logged {
let entry_id = entry.id;
let issue_key = entry.issue_key.clone();
let worklog_id = entry.worklog_id.clone();
let is_jira = !issue_key.is_empty()
&& self.config.integration != IntegrationKind::CustomCommands;
self.unlog_entry(entry_id, &issue_key, &worklog_id, is_jira);
return Ok(());
}
if !entry.issue_key.is_empty() && self.config.integration != IntegrationKind::CustomCommands {
let jira_url = self.config.jira_url_setting.clone().unwrap_or_default();
let jira_email = self.config.jira_email.clone().unwrap_or_default();
let api_token = self.secrets.get("jira_api_token")
.unwrap_or(None)
.unwrap_or_default();
let entry_started = if self.config.legacy_time_format {
entry.start_time.and_utc().format("%Y-%m-%dT%H:%M:%S%.3f%z").to_string()
} else {
entry.start_time.and_utc().to_rfc3339()
};
let task_duration = entry
.duration_minutes()
.map(|m| format!("{}m", m))
.unwrap_or_default();
let entry_id = entry.id;
let is_logged = entry.logged;
let entry_description = entry.description.clone();
if let Some(output) = crate::integrations::log_work(
&self.config.integration,
&jira_url,
&jira_email,
&api_token,
&entry.issue_key,
&task_duration,
&entry_started,
&entry_description,
) {
for msg in &output.messages {
self.debug_log.push(msg.clone());
}
if output.success && !is_logged {
let wid = output.worklog_id.clone().unwrap_or_default();
if let Err(e) = self.db.mark_logged(entry_id, &wid) {
self.debug_log.push(format!("[JIRA] Failed to mark as logged: {}", e));
} else {
self.debug_log.push("[JIRA] Automatically marked as logged".to_string());
let _ = self.refresh_entries();
}
}
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
return Ok(());
}
if !entry.issue_key.is_empty() {
let entry_started = if self.config.legacy_time_format {
entry.start_time.and_utc().format("%Y-%m-%dT%H:%M:%S%.3f%z").to_string()
} else {
entry.start_time.and_utc().to_rfc3339()
};
self.debug_log.push(entry_started.clone());
let entry_ended = entry.end_time.map(|t| t.to_string()).unwrap_or_default();
let task_duration = entry
.duration_minutes()
.map(|m| format!("{}m", m))
.unwrap_or_default();
let command = self.config.substitute_variables(
&entry.issue_key,
&entry_started,
&entry_ended,
&task_duration,
&entry.description,
);
let mut process = if cfg!(target_os = "windows") {
let mut cmd = std::process::Command::new("cmd");
cmd.args(&["/C", &command]);
cmd
} else {
let mut cmd = std::process::Command::new("sh");
cmd.args(&["-c", &command]);
cmd
};
process.stdin(std::process::Stdio::null());
process.stdout(std::process::Stdio::piped());
process.stderr(std::process::Stdio::piped());
let entry_id = entry.id;
let is_logged = entry.logged;
match process.output() {
Ok(output) => {
self.debug_log
.push(format!("[LOG WORK] Executed: {}", command));
if !output.stdout.is_empty() {
if let Ok(stdout_str) = String::from_utf8(output.stdout) {
for line in stdout_str.lines() {
self.debug_log
.push(format!("[LOG WORK STDOUT] {}", line));
}
}
}
if !output.stderr.is_empty() {
if let Ok(stderr_str) = String::from_utf8(output.stderr) {
for line in stderr_str.lines() {
self.debug_log
.push(format!("[LOG WORK STDERR] {}", line));
}
}
}
self.debug_log
.push(format!("[LOG WORK EXIT CODE] {}", output.status));
if output.status.success() && !is_logged {
if let Err(e) = self.db.toggle_logged(entry_id) {
self.debug_log.push(format!(
"[LOG WORK] Failed to mark as logged: {}",
e
));
} else {
self.debug_log.push("[LOG WORK] Automatically marked as logged".to_string());
let _ = self.refresh_entries();
}
}
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
Err(e) => {
self.debug_log
.push(format!("[LOG WORK ERROR] Failed: {} - {}", command, e));
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
}
}
}
}
Ok(())
}
pub fn sync_task_name_from_jira(&mut self, issue_key: &str) {
let jira_url = self.config.jira_url_setting.clone().unwrap_or_default();
let jira_email = self.config.jira_email.clone().unwrap_or_default();
let api_token = self.secrets.get("jira_api_token")
.unwrap_or(None)
.unwrap_or_default();
if let Some(result) = crate::integrations::fetch_issue_summary(
&self.config.integration,
&jira_url,
&jira_email,
&api_token,
issue_key,
) {
match result {
Ok(name) if !name.is_empty() => {
if let Err(e) = self.db.update_task_name(issue_key, &name) {
self.debug_log.push(format!("[JIRA] Failed to save task name for {}: {}", issue_key, e));
} else {
self.task_names.insert(issue_key.to_string(), name.clone());
self.debug_log.push(format!("[JIRA] Synced {}: {}", issue_key, name));
}
}
Ok(_) => {
self.debug_log.push(format!("[JIRA] Empty summary for {}", issue_key));
}
Err(e) => {
self.debug_log.push(e);
}
}
}
}
pub fn sync_all_task_names(&mut self) {
let keys = self.db.get_tasks_with_empty_names().unwrap_or_default();
if keys.is_empty() {
self.status_message = Some("All tasks already have names".to_string());
return;
}
let count = keys.len();
for key in keys {
self.sync_task_name_from_jira(&key);
}
self.refresh_task_names();
self.status_message = Some(format!("Synced {} task name(s) from Jira", count));
}
pub fn run_open_worklog_command(&mut self) -> Result<()> {
if let Some(idx) = self.selected_index {
if let Some(entry) = self.entries.get(idx) {
if !entry.issue_key.is_empty() && self.config.integration != IntegrationKind::CustomCommands {
let jira_url = self.config.jira_url_setting.clone().unwrap_or_default();
if let Some(output) = crate::integrations::open_issue(
&self.config.integration,
&jira_url,
&entry.issue_key,
) {
for msg in &output.messages {
self.debug_log.push(msg.clone());
}
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
return Ok(());
}
if !entry.issue_key.is_empty() {
let entry_started = entry.start_time.to_string();
let entry_ended = entry.end_time.map(|t| t.to_string()).unwrap_or_default();
let task_duration = entry
.duration_minutes()
.map(|m| format!("{}m", m))
.unwrap_or_default();
let command = self
.config
.open_worklog_command
.replace("[[issue_key]]", &entry.issue_key)
.replace("[[entry_started]]", &entry_started)
.replace("[[entry_ended]]", &entry_ended)
.replace("[[task_duration]]", &task_duration)
.replace("[[description]]", &entry.description);
let mut process = if cfg!(target_os = "windows") {
let mut cmd = std::process::Command::new("cmd");
cmd.args(&["/C", &command]);
cmd
} else {
let mut cmd = std::process::Command::new("sh");
cmd.args(&["-c", &command]);
cmd
};
process.stdin(std::process::Stdio::null());
process.stdout(std::process::Stdio::piped());
process.stderr(std::process::Stdio::piped());
match process.output() {
Ok(output) => {
self.debug_log
.push(format!("[OPEN ISSUE] Executed: {}", command));
if !output.stdout.is_empty() {
if let Ok(stdout_str) = String::from_utf8(output.stdout) {
for line in stdout_str.lines() {
self.debug_log
.push(format!("[OPEN ISSUE STDOUT] {}", line));
}
}
}
if !output.stderr.is_empty() {
if let Ok(stderr_str) = String::from_utf8(output.stderr) {
for line in stderr_str.lines() {
self.debug_log
.push(format!("[OPEN ISSUE STDERR] {}", line));
}
}
}
self.debug_log.push(format!(
"[OPEN ISSUE OPEN ISSUE EXIT CODE] {}",
output.status
));
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
Err(e) => {
self.debug_log.push(format!(
"[OPEN ISSUE ERROR] Failed: {} - {}",
command, e
));
while self.debug_log.len() > 10000 {
self.debug_log.remove(0);
}
}
}
}
}
}
Ok(())
}
pub fn toggle_logged(&mut self) -> Result<()> {
if let Some(idx) = self.selected_index {
if let Some(entry) = self.entries.get(idx) {
self.db.toggle_logged(entry.id)?;
self.refresh_entries()?;
}
}
Ok(())
}
pub fn clear_status(&mut self) {
self.status_message = None;
}
pub fn check_version(&mut self) -> Result<()> {
let current_version = env!("CARGO_PKG_VERSION");
let last_seen = self.db.get_last_seen_version()?;
if last_seen.as_deref() != Some(current_version) {
self.input_mode = InputMode::WhatsNew;
}
Ok(())
}
pub fn close_whats_new(&mut self) -> Result<()> {
let current_version = env!("CARGO_PKG_VERSION");
self.db.set_last_seen_version(current_version)?;
self.input_mode = InputMode::Normal;
Ok(())
}
pub fn get_earliest_start_time(&self) -> Option<NaiveDateTime> {
self.entries.iter().map(|e| e.start_time).min()
}
pub fn get_latest_end_time(&self, date: Option<NaiveDate>) -> Option<NaiveDateTime> {
if self.entries.is_empty() {
return None;
}
let filtered_entries: Vec<&TimeEntry> = if let Some(filter_date) = date {
self.entries
.iter()
.filter(|e| e.start_time.date() == filter_date)
.collect()
} else {
self.entries.iter().collect()
};
if filtered_entries.is_empty() {
return None;
}
let has_running = filtered_entries.iter().any(|e| e.is_running());
if has_running {
Some(Local::now().naive_local())
} else {
filtered_entries.iter().filter_map(|e| e.end_time).max()
}
}
pub fn scroll_debug_log(&mut self, direction: i32) {
if let InputMode::Settings {
debug_log_scroll_offset,
..
} = &mut self.input_mode
{
if direction > 0 {
*debug_log_scroll_offset =
(*debug_log_scroll_offset + 1).min(self.debug_log.len().saturating_sub(1));
} else {
*debug_log_scroll_offset = debug_log_scroll_offset.saturating_sub(1);
}
}
}
pub fn toggle_legacy_time_format(&mut self) {
if let InputMode::Settings {
legacy_time_format,
..
} = &mut self.input_mode
{
*legacy_time_format = !*legacy_time_format;
}
self.update_field_label();
}
pub fn toggle_hide_eye_candy(&mut self) {
if let InputMode::Settings {
hide_eye_candy,
..
} = &mut self.input_mode
{
*hide_eye_candy = !*hide_eye_candy;
}
self.update_field_label();
}
pub fn week_selected_date(&self) -> Option<NaiveDate> {
if let InputMode::WeekSummary { anchor, selected_day, .. } = &self.input_mode {
let monday =
*anchor - Duration::days(anchor.weekday().num_days_from_monday() as i64);
Some(monday + Duration::days(*selected_day as i64))
} else {
None
}
}
pub fn week_select_day(&mut self, delta: i64) {
if let InputMode::WeekSummary { anchor, selected_day, .. } = &mut self.input_mode {
let sd = *selected_day as i64 + delta;
if sd < 0 {
*anchor -= Duration::days(7);
*selected_day = 6;
} else if sd > 6 {
*anchor += Duration::days(7);
*selected_day = 0;
} else {
*selected_day = sd as usize;
}
}
}
pub fn week_change_week(&mut self, delta: i64) {
if let InputMode::WeekSummary { anchor, .. } = &mut self.input_mode {
*anchor += Duration::days(7 * delta);
}
}
pub fn week_move_field(&mut self, delta: i64) {
if let InputMode::WeekSummary { selected_field, .. } = &mut self.input_mode {
*selected_field = (((*selected_field as i64 + delta) % 3 + 3) % 3) as usize;
}
}
pub fn week_open_day(&mut self) -> Result<()> {
let date = match self.week_selected_date() {
Some(d) => d,
None => return Ok(()),
};
self.current_date = date;
self.at_work_selected = false;
self.selected_index = Some(0);
self.refresh_entries()?;
self.input_mode = InputMode::Normal;
Ok(())
}
pub fn week_begin_edit(&mut self) {
let date = match self.week_selected_date() {
Some(d) => d,
None => return,
};
let kind = if let InputMode::WeekSummary { selected_field, .. } = &self.input_mode {
*selected_field
} else {
return;
};
let now = Local::now().naive_local();
let entries = self.db.get_entries_for_date(date).unwrap_or_default();
let (s_ov, e_ov) = self.db.get_day_overrides(date).unwrap_or((None, None));
let draft = if kind == 2 {
if let Some(e) = entries.iter().find(|e| e.off_work) {
let end = e.end_time.unwrap_or(now);
DayEditDraft {
date,
kind: 2,
start: e.start_time.format("%H:%M").to_string(),
end: end.format("%H:%M").to_string(),
sub_field: 0,
lunch_entry_id: Some(e.id),
}
} else {
let (start, end) = suggest_lunch_gap(&entries);
DayEditDraft { date, kind: 2, start, end, sub_field: 0, lunch_entry_id: None }
}
} else {
let (start, end) = at_work_span_of(&entries, s_ov, e_ov, now)
.map(|(s, e, _)| (s.format("%H:%M").to_string(), e.format("%H:%M").to_string()))
.unwrap_or_else(|| ("09:00".to_string(), "17:00".to_string()));
DayEditDraft { date, kind, start, end, sub_field: 0, lunch_entry_id: None }
};
if let InputMode::WeekSummary { editing, .. } = &mut self.input_mode {
*editing = Some(draft);
}
}
pub fn week_edit_adjust(&mut self, delta: i32) {
if let InputMode::WeekSummary { editing: Some(d), .. } = &mut self.input_mode {
let buf = day_edit_active_buf(d);
*buf = adjust_time_string(buf, delta);
}
}
pub fn week_edit_input(&mut self, c: char) {
if !(c.is_ascii_digit() || c == ':') {
return;
}
if let InputMode::WeekSummary { editing: Some(d), .. } = &mut self.input_mode {
let buf = day_edit_active_buf(d);
if buf.chars().count() < 5 {
buf.push(c);
}
}
}
pub fn week_edit_backspace(&mut self) {
if let InputMode::WeekSummary { editing: Some(d), .. } = &mut self.input_mode {
day_edit_active_buf(d).pop();
}
}
pub fn week_edit_tab(&mut self) {
if let InputMode::WeekSummary { editing: Some(d), .. } = &mut self.input_mode {
if d.kind == 2 {
d.sub_field = (d.sub_field + 1) % 2;
}
}
}
pub fn week_edit_cancel(&mut self) {
if let InputMode::WeekSummary { editing, .. } = &mut self.input_mode {
*editing = None;
}
}
pub fn week_edit_save(&mut self) -> Result<()> {
let draft = match &self.input_mode {
InputMode::WeekSummary { editing: Some(d), .. } => d.clone(),
_ => return Ok(()),
};
let parse = |s: &str| NaiveTime::parse_from_str(s, "%H:%M");
if draft.kind == 2 {
if let (Ok(st), Ok(en)) = (parse(&draft.start), parse(&draft.end)) {
let start_dt = draft.date.and_time(st);
let end_dt = draft.date.and_time(en);
if let Some(id) = draft.lunch_entry_id {
self.db
.update_entry(id, "Lunch".to_string(), start_dt, Some(end_dt), String::new())?;
} else {
let id =
self.db
.create_entry("Lunch".to_string(), start_dt, Some(end_dt), String::new())?;
self.db.toggle_off_work(id)?;
}
}
} else {
let (cur_s, cur_e) = self.db.get_day_overrides(draft.date).unwrap_or((None, None));
let (mut new_s, mut new_e) = (cur_s, cur_e);
if draft.kind == 0 {
if let Ok(t) = parse(&draft.start) {
new_s = Some(draft.date.and_time(t));
}
} else if let Ok(t) = parse(&draft.end) {
new_e = Some(draft.date.and_time(t));
}
self.db.set_day_overrides(draft.date, new_s, new_e)?;
}
if let InputMode::WeekSummary { editing, .. } = &mut self.input_mode {
*editing = None;
}
if draft.date == self.current_date {
self.refresh_entries()?;
}
Ok(())
}
}
pub fn at_work_span_of(
entries: &[TimeEntry],
start_override: Option<NaiveDateTime>,
end_override: Option<NaiveDateTime>,
now: NaiveDateTime,
) -> Option<(NaiveDateTime, NaiveDateTime, bool)> {
let auto_start = entries.iter().map(|e| e.start_time).min();
let auto_end = entries.iter().map(|e| e.end_time.unwrap_or(now)).max();
let start = start_override.or(auto_start);
let end = end_override.or(auto_end);
let manual = start_override.is_some() || end_override.is_some();
match (start, end) {
(Some(s), Some(e)) => Some((s, e.max(s), manual)),
(Some(s), None) => Some((s, now.max(s), manual)),
_ => None,
}
}
fn day_edit_active_buf(d: &mut DayEditDraft) -> &mut String {
match d.kind {
1 => &mut d.end, 2 if d.sub_field == 1 => &mut d.end, _ => &mut d.start, }
}
fn suggest_lunch_gap(entries: &[TimeEntry]) -> (String, String) {
let mut work: Vec<(NaiveDateTime, NaiveDateTime)> = entries
.iter()
.filter(|e| !e.off_work)
.filter_map(|e| e.end_time.map(|end| (e.start_time, end)))
.collect();
work.sort_by_key(|(s, _)| *s);
let mut best: Option<(NaiveDateTime, NaiveDateTime, i64)> = None;
for w in work.windows(2) {
let (prev_end, next_start) = (w[0].1, w[1].0);
let gap = next_start.signed_duration_since(prev_end).num_minutes();
if gap > 0 && best.is_none_or(|(_, _, b)| gap > b) {
best = Some((prev_end, next_start, gap));
}
}
match best {
Some((s, e, _)) => (s.format("%H:%M").to_string(), e.format("%H:%M").to_string()),
None => ("11:30".to_string(), "12:00".to_string()),
}
}
fn adjust_time_string(time_str: &str, minutes: i32) -> String {
if let Ok(time) = NaiveTime::parse_from_str(time_str, "%H:%M") {
let total_minutes = time.hour() as i32 * 60 + time.minute() as i32 + minutes;
let wrapped_minutes = ((total_minutes % 1440) + 1440) % 1440;
let new_hour = (wrapped_minutes / 60) as u32;
let new_minute = (wrapped_minutes % 60) as u32;
format!("{:02}:{:02}", new_hour, new_minute)
} else {
time_str.to_string()
}
}