chute_kun/lib/
task.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4pub enum Category {
5    #[default]
6    General,
7    Work,
8    Home,
9    Hobby,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Session {
14    pub start_min: u16,
15    #[serde(default)]
16    pub end_min: Option<u16>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum TaskState {
21    Planned,
22    Active,
23    Paused,
24    Done,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Task {
29    pub title: String,
30    pub estimate_min: u16,
31    pub actual_min: u16,
32    /// Partially accumulated seconds (<60) toward `actual_min` for this task.
33    #[serde(default)]
34    pub actual_carry_sec: u16,
35    /// Actual first start time in minutes since local midnight.
36    /// Recorded when the task first transitions to Active.
37    #[serde(default)]
38    pub started_at_min: Option<u16>,
39    /// Actual finish time in minutes since local midnight.
40    /// Recorded when the task is marked Done.
41    #[serde(default)]
42    pub finished_at_min: Option<u16>,
43    /// Full session log for this task (start/end pairs). The last session
44    /// may have `end_min=None` while the task is Active.
45    #[serde(default)]
46    pub sessions: Vec<Session>,
47    pub state: TaskState,
48    /// Planned date for the task (YYYYMMDD). Defaults to today on creation.
49    /// Used to support date selection on creation and postpone/bring operations.
50    #[serde(default)]
51    pub planned_ymd: u32,
52    #[serde(default)]
53    pub done_ymd: Option<u32>,
54    /// User classification for the task (e.g., Work/Home/Hobby). Defaults to General.
55    #[serde(default)]
56    pub category: Category,
57    /// Optional fixed planned start time in minutes since local midnight.
58    /// When present, the visual schedule uses `max(cursor, fixed_start_min)` at this task
59    /// and pushes the subsequent tasks based on estimates from there.
60    #[serde(default)]
61    pub fixed_start_min: Option<u16>,
62}
63
64impl Task {
65    pub fn new(title: &str, estimate_min: u16) -> Self {
66        Self {
67            title: title.to_string(),
68            estimate_min,
69            actual_min: 0,
70            actual_carry_sec: 0,
71            started_at_min: None,
72            finished_at_min: None,
73            sessions: Vec::new(),
74            state: TaskState::Planned,
75            planned_ymd: crate::date::today_ymd(),
76            done_ymd: None,
77            category: Category::General,
78            fixed_start_min: None,
79        }
80    }
81}
82
83impl Task {
84    pub fn start_session(&mut self, now_min: u16) {
85        // MSRV 1.74: `Option::is_none_or` is not available; use `map_or`.
86        let need_new = self.sessions.last().map_or(true, |s| s.end_min.is_some());
87        if need_new {
88            self.sessions.push(Session { start_min: now_min, end_min: None });
89        }
90    }
91    pub fn end_session(&mut self, now_min: u16) {
92        if let Some(last) = self.sessions.last_mut() {
93            if last.end_min.is_none() {
94                last.end_min = Some(now_min);
95            }
96        }
97    }
98}
99
100#[derive(Debug, Default)]
101pub struct DayPlan {
102    pub tasks: Vec<Task>,
103    active: Option<usize>,
104}
105
106impl DayPlan {
107    pub fn new(tasks: Vec<Task>) -> Self {
108        let active = tasks.iter().position(|t| matches!(t.state, TaskState::Active));
109        Self { tasks, active }
110    }
111
112    pub fn active_index(&self) -> Option<usize> {
113        self.active
114    }
115
116    pub fn add_task(&mut self, task: Task) -> usize {
117        self.tasks.push(task);
118        self.tasks.len() - 1
119    }
120
121    // start or activate a task at index, pausing any existing active task
122    pub fn start(&mut self, index: usize) {
123        if let Some(cur) = self.active {
124            if cur != index {
125                if let Some(t) = self.tasks.get_mut(cur) {
126                    t.state = TaskState::Paused;
127                }
128            } else {
129                // already active, nothing to do
130                return;
131            }
132        }
133        if let Some(t) = self.tasks.get_mut(index) {
134            t.state = TaskState::Active;
135            self.active = Some(index);
136        }
137    }
138
139    pub fn pause_active(&mut self) {
140        if let Some(cur) = self.active.take() {
141            if let Some(t) = self.tasks.get_mut(cur) {
142                t.state = TaskState::Paused;
143            }
144        }
145    }
146
147    /// Mark the task at `index` as Done with the given date (YYYYMMDD).
148    /// - If it was the active task, clear active.
149    pub fn finish_at(&mut self, index: usize, today_ymd: u32) {
150        if index >= self.tasks.len() {
151            return;
152        }
153        if self.active == Some(index) {
154            self.active = None;
155            if let Some(t) = self.tasks.get_mut(index) {
156                t.state = TaskState::Done;
157                t.done_ymd = Some(today_ymd);
158            }
159        } else if let Some(t) = self.tasks.get_mut(index) {
160            t.state = TaskState::Done;
161            t.done_ymd = Some(today_ymd);
162        }
163    }
164
165    pub fn add_actual_to_active(&mut self, minutes: u16) {
166        if let Some(cur) = self.active {
167            if let Some(t) = self.tasks.get_mut(cur) {
168                t.actual_min = t.actual_min.saturating_add(minutes);
169            }
170        }
171    }
172
173    pub fn remaining_total_min(&self) -> u16 {
174        self.tasks
175            .iter()
176            .map(|t| match t.state {
177                TaskState::Done => 0,
178                _ => t.estimate_min.saturating_sub(t.actual_min),
179            })
180            .sum()
181    }
182
183    pub fn esd(&self, now_min: u16) -> u16 {
184        // Sum estimates of non-done tasks (decoupled from progress)
185        let est_sum: u16 = self
186            .tasks
187            .iter()
188            .map(|t| match t.state {
189                TaskState::Done => 0,
190                _ => t.estimate_min,
191            })
192            .sum();
193        // Base time: latest measured finish (task finish or session end) or now, whichever is later
194        let base = match self.latest_actual_finish_min() {
195            Some(last) => last.max(now_min),
196            None => now_min,
197        };
198        esd_from(base, &[est_sum])
199    }
200
201    /// Latest actual finish minute across all tasks for today.
202    fn latest_actual_finish_min(&self) -> Option<u16> {
203        let mut max_end: Option<u16> = None;
204        for t in &self.tasks {
205            if let Some(f) = t.finished_at_min {
206                max_end = Some(max_end.map_or(f, |m| m.max(f)));
207            }
208            if let Some(e) = t.sessions.iter().filter_map(|s| s.end_min).max() {
209                max_end = Some(max_end.map_or(e, |m| m.max(e)));
210            }
211        }
212        max_end
213    }
214
215    pub fn reorder_down(&mut self, index: usize) -> usize {
216        if index + 1 >= self.tasks.len() {
217            return index;
218        }
219        self.tasks.swap(index, index + 1);
220        if let Some(a) = self.active.as_mut() {
221            if *a == index {
222                *a = index + 1;
223            } else if *a == index + 1 {
224                *a = index;
225            }
226        }
227        index + 1
228    }
229
230    pub fn reorder_up(&mut self, index: usize) -> usize {
231        if index == 0 || index >= self.tasks.len() {
232            return index;
233        }
234        self.tasks.swap(index - 1, index);
235        if let Some(a) = self.active.as_mut() {
236            if *a == index {
237                *a = index - 1;
238            } else if *a == index - 1 {
239                *a = index;
240            }
241        }
242        index - 1
243    }
244
245    /// Move task from `from` index to logical position `to` (clamped to list bounds).
246    /// Returns the new index of the moved task. Adjusts `active` pointer accordingly.
247    pub fn move_index(&mut self, from: usize, to_slot: usize) -> usize {
248        let len = self.tasks.len();
249        if len == 0 || from >= len {
250            return from;
251        }
252        // Allow insertion at end by accepting to_slot == len
253        let slot = to_slot.min(len);
254        // Desired final index after move in the resulting vector
255        let dest_final = if from < slot { slot - 1 } else { slot };
256        if dest_final == from {
257            return from;
258        }
259        let item = self.tasks.remove(from);
260        self.tasks.insert(dest_final, item);
261        // Fix active pointer relative to move span
262        if let Some(a) = self.active.as_mut() {
263            if *a == from {
264                *a = dest_final;
265            } else if from < dest_final {
266                // [from+1 ..= dest_final] shifted left by 1
267                if *a > from && *a <= dest_final {
268                    *a = a.saturating_sub(1);
269                }
270            } else {
271                // [dest_final .. from-1] shifted right by 1
272                if *a >= dest_final && *a < from {
273                    *a = a.saturating_add(1);
274                }
275            }
276        }
277        dest_final
278    }
279
280    pub fn adjust_estimate(&mut self, index: usize, delta_min: i16) {
281        if let Some(t) = self.tasks.get_mut(index) {
282            let cur = t.estimate_min as i16 + delta_min;
283            t.estimate_min = cur.max(0) as u16;
284        }
285    }
286
287    pub fn remove(&mut self, index: usize) -> Option<Task> {
288        if index >= self.tasks.len() {
289            return None;
290        }
291        // fix active pointer
292        if let Some(a) = self.active {
293            if a == index {
294                self.active = None;
295            } else if a > index {
296                self.active = Some(a - 1);
297            }
298        }
299        Some(self.tasks.remove(index))
300    }
301}
302
303// ESD(見込み終了時刻) = now(min) + 残分合計
304pub fn esd_from(now_min: u16, remaining_mins: &[u16]) -> u16 {
305    let sum: u16 = remaining_mins.iter().copied().sum();
306    now_min.saturating_add(sum)
307}
308
309pub fn tc_log_line(task: &Task) -> String {
310    let state = match task.state {
311        TaskState::Planned => "Planned",
312        TaskState::Active => "Active",
313        TaskState::Paused => "Paused",
314        TaskState::Done => "Done",
315    };
316    format!(
317        "tc-log | {} | act:{}m | est:{}m | state:{}",
318        task.title, task.actual_min, task.estimate_min, state
319    )
320}