judo 2.0.0

Judo - TUI for ToDo lists
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use sqlx::SqlitePool;

use crate::db::models::{NewTodoItem, NewTodoList, Priority, TodoItem, TodoList, UIItem, UIList};
use ratatui::widgets::ListState;

impl TodoList {
    /// Create a new todo list
    pub async fn create(pool: &SqlitePool, new_list: NewTodoList) -> Result<TodoList> {
        let now = Utc::now();

        // Get the next ordering value (max + 1)
        let next_ordering: i64 =
            sqlx::query_scalar("SELECT COALESCE(MAX(ordering), 0) + 1 FROM todo_lists")
                .fetch_one(pool)
                .await
                .with_context(|| "Failed to get next ordering value")?;

        // Use query_as to map results to a struct
        let row = sqlx::query_as::<_, TodoList>(
            r#"
            INSERT INTO todo_lists (name, ordering, created_at, updated_at)
            VALUES (?1, ?2, ?3, ?4)
            RETURNING id, name, ordering, created_at, updated_at
            "#,
        )
        .bind(&new_list.name)
        .bind(next_ordering)
        .bind(now)
        .bind(now)
        .fetch_one(pool)
        .await
        .with_context(|| "Failed to create todo list")?;

        Ok(row)
    }

    /// Get all todo lists
    pub async fn get_all(pool: &SqlitePool) -> Result<Vec<TodoList>> {
        let lists = sqlx::query_as::<_, TodoList>(
            "SELECT id, name, ordering, created_at, updated_at FROM todo_lists ORDER BY ordering",
        )
        .fetch_all(pool)
        .await
        .with_context(|| "Failed to fetch all todo lists")?;

        Ok(lists)
    }

    /// Get a specific todo list by ID
    pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result<Option<TodoList>> {
        let list = sqlx::query_as::<_, TodoList>(
            "SELECT id, name, ordering, created_at, updated_at FROM todo_lists WHERE id = ?1",
        )
        .bind(id)
        .fetch_optional(pool)
        .await
        .with_context(|| "Failed to fetch todo list by id")?;

        Ok(list)
    }

    /// Update todo list name
    pub async fn update_name(&mut self, pool: &SqlitePool, new_name: String) -> Result<()> {
        let now = Utc::now();

        sqlx::query("UPDATE todo_lists SET name = ?1, updated_at = ?2 WHERE id = ?3")
            .bind(&new_name)
            .bind(now)
            .bind(self.id)
            .execute(pool)
            .await
            .with_context(|| "Failed to update todo list")?;

        self.name = new_name;
        self.updated_at = now;
        Ok(())
    }

    /// Delete todo list (and all its items due to CASCADE)
    pub async fn delete(self, pool: &SqlitePool) -> Result<()> {
        sqlx::query("DELETE FROM todo_lists WHERE id = ?1")
            .bind(self.id)
            .execute(pool)
            .await
            .with_context(|| "Failed to delete todo list")?;

        Ok(())
    }

    /// Move list up (decrease ordering, swap with previous)
    pub async fn move_up(&mut self, pool: &SqlitePool) -> Result<()> {
        // Find the list with the next lower ordering value
        let prev_list: Option<(i64, i64)> = sqlx::query_as(
            "SELECT id, ordering FROM todo_lists WHERE ordering < ?1 ORDER BY ordering DESC LIMIT 1"
        )
        .bind(self.ordering)
        .fetch_optional(pool)
        .await
        .with_context(|| "Failed to find previous list")?;

        if let Some((prev_id, prev_ordering)) = prev_list {
            // Swap orderings
            sqlx::query("UPDATE todo_lists SET ordering = ?1 WHERE id = ?2")
                .bind(self.ordering)
                .bind(prev_id)
                .execute(pool)
                .await
                .with_context(|| "Failed to update previous list ordering")?;

            sqlx::query("UPDATE todo_lists SET ordering = ?1 WHERE id = ?2")
                .bind(prev_ordering)
                .bind(self.id)
                .execute(pool)
                .await
                .with_context(|| "Failed to update current list ordering")?;

            self.ordering = prev_ordering;
        }

        Ok(())
    }

    /// Move list down (increase ordering, swap with next)
    pub async fn move_down(&mut self, pool: &SqlitePool) -> Result<()> {
        // Find the list with the next higher ordering value
        let next_list: Option<(i64, i64)> = sqlx::query_as(
            "SELECT id, ordering FROM todo_lists WHERE ordering > ?1 ORDER BY ordering ASC LIMIT 1",
        )
        .bind(self.ordering)
        .fetch_optional(pool)
        .await
        .with_context(|| "Failed to find next list")?;

        if let Some((next_id, next_ordering)) = next_list {
            // Swap orderings
            sqlx::query("UPDATE todo_lists SET ordering = ?1 WHERE id = ?2")
                .bind(self.ordering)
                .bind(next_id)
                .execute(pool)
                .await
                .with_context(|| "Failed to update next list ordering")?;

            sqlx::query("UPDATE todo_lists SET ordering = ?1 WHERE id = ?2")
                .bind(next_ordering)
                .bind(self.id)
                .execute(pool)
                .await
                .with_context(|| "Failed to update current list ordering")?;

            self.ordering = next_ordering;
        }

        Ok(())
    }

    pub async fn get_all_items(&self, pool: &SqlitePool) -> Result<Vec<TodoItem>> {
        TodoItem::get_by_list_id(pool, self.id).await
    }
}

impl TodoItem {
    /// Create a new todo item
    pub async fn create(pool: &SqlitePool, new_item: NewTodoItem) -> Result<TodoItem> {
        let now = Utc::now();

        // Get the next ordering value for this list (max + 1)
        let next_ordering: i64 = sqlx::query_scalar(
            "SELECT COALESCE(MAX(ordering), 0) + 1 FROM todo_items WHERE list_id = ?1",
        )
        .bind(new_item.list_id)
        .fetch_one(pool)
        .await
        .with_context(|| "Failed to get next ordering value")?;

        let row = sqlx::query_as::<_, TodoItem>(
            r#"
            INSERT INTO todo_items (list_id, name, is_done, priority, due_date, ordering, created_at, updated_at)
            VALUES (?1, ?2, FALSE, ?3, ?4, ?5, ?6, ?7)
            RETURNING id, list_id, name, is_done, priority, due_date, ordering, created_at, updated_at
            "#,
        )
        .bind(new_item.list_id)
        .bind(&new_item.name)
        .bind(&new_item.priority)
        .bind(new_item.due_date)
        .bind(next_ordering)
        .bind(now)
        .bind(now)
        .fetch_one(pool)
        .await
        .with_context(|| "Failed to create todo item")?;

        Ok(row)
    }

    /// Get all items for a specific list
    pub async fn get_by_list_id(pool: &SqlitePool, list_id: i64) -> Result<Vec<TodoItem>> {
        let items = sqlx::query_as::<_, TodoItem>(
            r#"
            SELECT id, list_id, name, is_done, priority, due_date, ordering, created_at, updated_at
            FROM todo_items 
            WHERE list_id = ?1 
            ORDER BY ordering
            "#,
        )
        .bind(list_id)
        .fetch_all(pool)
        .await
        .with_context(|| "Failed to fetch todo items")?;

        Ok(items)
    }

    /// Get item with a specific id
    pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result<Option<TodoItem>> {
        let item = sqlx::query_as::<_, TodoItem>(
            r#"
            SELECT id, list_id, name, is_done, priority, due_date, ordering, created_at, updated_at
            FROM todo_items 
            WHERE id = ?1 
            "#,
        )
        .bind(id)
        .fetch_optional(pool)
        .await
        .with_context(|| "Failed to fetch todo item")?;

        Ok(item)
    }

    /// Update to-do item name
    pub async fn update_name(&mut self, pool: &SqlitePool, new_name: String) -> Result<()> {
        let now = Utc::now();

        sqlx::query("UPDATE todo_items SET name = ?1, updated_at = ?2 WHERE id = ?3")
            .bind(&new_name)
            .bind(now)
            .bind(self.id)
            .execute(pool)
            .await
            .with_context(|| "Failed to update todo item name")?;

        self.name = new_name;
        self.updated_at = now;

        Ok(())
    }

    /// Toggle item completion status (from false to true or from true to false)
    pub async fn toggle_done(&mut self, pool: &SqlitePool) -> Result<()> {
        let now = Utc::now();
        let new_status = !self.is_done;

        sqlx::query("UPDATE todo_items SET is_done = ?1, updated_at = ?2 WHERE id = ?3")
            .bind(new_status)
            .bind(now)
            .bind(self.id)
            .execute(pool)
            .await
            .with_context(|| "Failed to update todo item status")?;

        self.is_done = new_status;
        self.updated_at = now;

        Ok(())
    }

    /// Update item priority
    pub async fn update_priority(
        &mut self,
        pool: &SqlitePool,
        new_priority: Priority,
    ) -> Result<()> {
        let now = Utc::now();

        sqlx::query("UPDATE todo_items SET priority = ?1, updated_at = ?2 WHERE id = ?3")
            .bind(&new_priority)
            .bind(now)
            .bind(self.id)
            .execute(pool)
            .await
            .with_context(|| "Failed to update todo item priority")?;

        self.priority = Some(new_priority);
        self.updated_at = now;

        Ok(())
    }

    /// Update item due date
    pub async fn update_due_date(
        &mut self,
        pool: &SqlitePool,
        new_due_date: DateTime<Utc>,
    ) -> Result<()> {
        let now = Utc::now();

        sqlx::query("UPDATE todo_items SET due_date = ?1, updated_at = ?2 WHERE id = ?3")
            .bind(new_due_date)
            .bind(now)
            .bind(self.id)
            .execute(pool)
            .await
            .with_context(|| "Failed to update todo item priority")?;

        self.due_date = Some(new_due_date);
        self.updated_at = now;
        Ok(())
    }

    /// Delete todo item
    pub async fn delete(self, pool: &SqlitePool) -> Result<()> {
        sqlx::query("DELETE FROM todo_items WHERE id = ?1")
            .bind(self.id)
            .execute(pool)
            .await
            .with_context(|| "Failed to delete todo item")?;

        Ok(())
    }

    /// Move item up (decrease ordering, swap with previous in same list)
    pub async fn move_up(&mut self, pool: &SqlitePool) -> Result<()> {
        // Find the item with the next lower ordering value in the same list
        let prev_item: Option<(i64, i64)> = sqlx::query_as(
            "SELECT id, ordering FROM todo_items WHERE list_id = ?1 AND ordering < ?2 ORDER BY ordering DESC LIMIT 1"
        )
        .bind(self.list_id)
        .bind(self.ordering)
        .fetch_optional(pool)
        .await
        .with_context(|| "Failed to find previous item")?;

        if let Some((prev_id, prev_ordering)) = prev_item {
            // Swap orderings
            sqlx::query("UPDATE todo_items SET ordering = ?1 WHERE id = ?2")
                .bind(self.ordering)
                .bind(prev_id)
                .execute(pool)
                .await
                .with_context(|| "Failed to update previous item ordering")?;

            sqlx::query("UPDATE todo_items SET ordering = ?1 WHERE id = ?2")
                .bind(prev_ordering)
                .bind(self.id)
                .execute(pool)
                .await
                .with_context(|| "Failed to update current item ordering")?;

            self.ordering = prev_ordering;
        }

        Ok(())
    }

    /// Move item down (increase ordering, swap with next in same list)
    pub async fn move_down(&mut self, pool: &SqlitePool) -> Result<()> {
        // Find the item with the next higher ordering value in the same list
        let next_item: Option<(i64, i64)> = sqlx::query_as(
            "SELECT id, ordering FROM todo_items WHERE list_id = ?1 AND ordering > ?2 ORDER BY ordering ASC LIMIT 1"
        )
        .bind(self.list_id)
        .bind(self.ordering)
        .fetch_optional(pool)
        .await
        .with_context(|| "Failed to find next item")?;

        if let Some((next_id, next_ordering)) = next_item {
            // Swap orderings
            sqlx::query("UPDATE todo_items SET ordering = ?1 WHERE id = ?2")
                .bind(self.ordering)
                .bind(next_id)
                .execute(pool)
                .await
                .with_context(|| "Failed to update next item ordering")?;

            sqlx::query("UPDATE todo_items SET ordering = ?1 WHERE id = ?2")
                .bind(next_ordering)
                .bind(self.id)
                .execute(pool)
                .await
                .with_context(|| "Failed to update current item ordering")?;

            self.ordering = next_ordering;
        }

        Ok(())
    }
}

impl UIList {
    /// Get all lists in db already attached to their items
    pub async fn get_all(pool: &SqlitePool) -> Result<Vec<UIList>> {
        // Fetch all lists
        let lists = TodoList::get_all(pool)
            .await
            .with_context(|| "Failed to fetch lists from db")?;

        let mut ui_lists = Vec::new();

        // For each list, fetch its items and create a UIList
        for list in lists {
            let items = TodoItem::get_by_list_id(pool, list.id)
                .await
                .with_context(|| format!("Failed to fetch items for list {}", list.id))?
                .iter()
                .map(|i| UIItem {
                    item: i.clone(),
                    state: ListState::default(),
                })
                .collect();

            ui_lists.push(UIList {
                list,
                item_state: ListState::default(),
                items,
            });
        }

        Ok(ui_lists)
    }

    /// Update items when something changes (new item, deleted item).
    /// Keeps the same list state instead of reinitializing it
    pub async fn update_items(&mut self, pool: &SqlitePool) -> Result<()> {
        // Re-fetch the items but don't change the list state
        let items = TodoItem::get_by_list_id(pool, self.list.id)
            .await
            .with_context(|| "Failed to fetch items for list")?
            .iter()
            .map(|i| UIItem {
                item: i.clone(),
                state: self.item_state.clone(),
            })
            .collect();

        // Update the items
        self.items = items;

        Ok(())
    }
}