diff --git a/README.md b/README.md index bfcf6f9..0169ed7 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ into the folder being browsed, and so does dropping them on the list. `:name ` (`:card!` links the open document to it), -`:due `, `:done` and `:list ` (`:list!` deletes one). +`:due `, `:repeat `, `:done` and `:list ` (`:list!` deletes one). On a touch device modal editing is off by default (`:set vim` turns it on) and the same actions are buttons: the `⋯` on a row — or a long press — opens what @@ -233,13 +233,30 @@ deleting the document leaves the card standing, because the work is still open once its notes are gone. Only a document can be a card's notes; a folder or a file is refused. +### Cards that come back + +`:repeat weekly` (or the rail's ↻ button) makes a card come back once it is +done. The rules are `daily`, `weekly`, `monthly`, `yearly`, and every `+3d`, +`+2w`, `+6m` or `+1y`; `:repeat -` stops it. Finishing the card, on the board +or in the agenda, leaves a copy at the bottom of the inbox with the same title, +notes, document and rule, due on the next date, and the status line says when. +The next date counts on from the old due date, in `WORKSPACE_TIMEZONE`, so a +card due at noon stays due at noon across a change of the clocks. A card due on +the 31st comes back on the last day of a shorter month and on the 31st again +where there is one. A card finished late skips the dates that have passed and +comes back on the first one from today on; a card without a due date counts from +the moment it was finished. The finished card gives up its rule, so reopening it +and finishing it again makes no second copy. The board and the agenda show the +rule as `↻ weekly` beside the date. + ## The agenda `t` (or `:agenda`) gathers every unfinished card from every board that is late, due within the week, or sitting in a column between the inbox and done — the work already in hand — and says whether the diary has an entry for today. `j`/`k` walk it, `Enter` goes to the card on its own board, `Space` finishes it where -it stands, `D` sets its due date (`:due` on the line), `H` / `L` send it to +it stands, `D` sets its due date (`:due` on the line), `:` opens the line with +the agenda kept up so `:repeat` acts on the card, `H` / `L` send it to the list left / right on its board, and `T` writes today's entry. The bar shows `late` and `today` counts whenever either is above zero, and the agenda opens by itself once a day when something is late or due, so nothing has to be remembered to be looked at. @@ -362,7 +379,9 @@ other embedded file in `media/`, under their real names, and relative links between documents, folders and files, so the archive reads in any markdown viewer without this application. What plain files have no place for — when each entry was written, names as they were typed, which space is the diary, -every board with its cards — goes into one `.workspace.json` beside them. +every board with its cards and their repeat rules — goes into one +`.workspace.json` beside them. An older build reads a newer file's cards +without their rules rather than refusing it. The diary is the diary by role, not by name: rename it and the reminder and the agenda still know which space it is, and it cannot be deleted — empty it @@ -780,8 +799,8 @@ sealed or not. | `POST` | `/api/spaces/{id}/lists` | add a list | | `PUT`/`DELETE` | `/api/lists/{id}` | rename / delete a list and its cards | | `POST` | `/api/lists/{id}/cards` | add a card | -| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, done, link) / delete | -| `POST` | `/api/cards/{id}/move` | to another list and place in it | +| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, repeat, done, link) / delete; finishing a repeating card answers its copy as `again` | +| `POST` | `/api/cards/{id}/move` | to another list and place in it; `again` as above | | `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings | | `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none | | `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node | diff --git a/migrations/0023_repeat.sql b/migrations/0023_repeat.sql new file mode 100644 index 0000000..a3ad3ca --- /dev/null +++ b/migrations/0023_repeat.sql @@ -0,0 +1,5 @@ +-- A card that comes back: `daily`, `weekly`, `monthly`, `yearly`, or every +-- `+Nd`, `+Nw`, `+Nm`, `+Ny`. Finishing such a card leaves a copy in the +-- inbox with the next due date and clears the rule on the finished one, so +-- reopening it and finishing it again does not make a second copy. +ALTER TABLE cards ADD COLUMN repeat TEXT; diff --git a/src/routes/boards.rs b/src/routes/boards.rs index a4f2dd8..032be50 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -22,6 +22,7 @@ use axum::{ }; use std::collections::HashMap; +use chrono_tz::Tz; use serde::{Deserialize, Serialize}; use sqlx::{Row, SqliteConnection, SqlitePool}; @@ -31,6 +32,7 @@ use crate::{ routes::{ manifest::ListMeta, nodes::{DOCUMENT, SPACE}, + repeat::Repeat, }, state::AppState, }; @@ -66,6 +68,8 @@ pub struct Card { pub node_name: Option, pub due_at: Option, pub done_at: Option, + /// The rule it comes back by once finished, as `Repeat::name` writes it. + pub repeat: Option, pub position: i64, pub created_at: i64, pub updated_at: i64, @@ -101,9 +105,20 @@ pub struct CardPatch { pub node_id: Option>, #[serde(default, deserialize_with = "double_option")] pub due_at: Option>, + #[serde(default, deserialize_with = "double_option")] + pub repeat: Option>, pub done: Option, } +/// What a move or an edit answers with: the card, and the one it left in the +/// inbox when it was a repeating card that has just been finished. +#[derive(Serialize)] +pub struct Moved { + #[serde(flatten)] + pub card: Card, + pub again: Option, +} + fn double_option<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, @@ -190,7 +205,7 @@ async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult Card { node_name: row.get("node_name"), due_at: row.get("due_at"), done_at: row.get("done_at"), + repeat: row.get("repeat"), position: row.get("position"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), @@ -429,9 +445,9 @@ pub(crate) async fn restore( .await?; for card in &list.cards { sqlx::query( - "INSERT INTO cards (list_id, node_id, title, body, due_at, done_at, position, - created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO cards (list_id, node_id, title, body, due_at, done_at, repeat, + position, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", ) .bind(id) .bind(card.document.as_ref().and_then(|path| documents.get(path))) @@ -439,6 +455,14 @@ pub(crate) async fn restore( .bind(&card.body) .bind(card.due_at) .bind(card.done_at) + // A rule this build cannot read is dropped rather than kept as + // text nothing would ever act on. + .bind( + card.repeat + .as_deref() + .and_then(Repeat::parse) + .map(Repeat::name), + ) .bind(position) .bind(card.created_at) .bind(card.updated_at) @@ -493,7 +517,7 @@ async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult< // is small, and the round trips are what would be felt. let card_rows = sqlx::query( "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, - c.position, c.created_at, c.updated_at, + c.repeat, c.position, c.created_at, c.updated_at, (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name FROM cards c JOIN board_lists l ON l.id = c.list_id @@ -738,7 +762,7 @@ pub async fn update_card( State(state): State, Path(card_id): Path, Json(patch): Json, -) -> AppResult> { +) -> AppResult> { let current = card(&state.db, card_id).await?; let now = crate::now(); @@ -752,12 +776,51 @@ pub async fn update_card( if let Some(node_id) = patch.node_id { linkable(&state.db, node_id).await?; } + let repeat = match patch.repeat { + Some(Some(rule)) => Some( + Repeat::parse(&rule) + .ok_or_else(|| { + AppError::BadRequest(format!( + "not a rule: {} — try daily, weekly, monthly, yearly or +2w", + rule.trim() + )) + })? + .name(), + ), + Some(None) => None, + None => current.repeat.clone(), + }; let mut tx = crate::db::begin_write(&state.db).await?; + sqlx::query( + "UPDATE cards SET title = ?1, body = ?2, node_id = ?3, due_at = ?4, repeat = ?5, + updated_at = ?6 + WHERE id = ?7", + ) + .bind(&title) + .bind(patch.body.as_ref().unwrap_or(¤t.body)) + .bind(patch.node_id.unwrap_or(current.node_id)) + .bind(patch.due_at.unwrap_or(current.due_at)) + .bind(&repeat) + .bind(now) + .bind(card_id) + .execute(&mut *tx) + .await?; + // The move below sees the card as it is now, so a card given its rule + // and finished in one request comes back by that rule. + let current = Card { + title, + body: patch.body.unwrap_or(current.body), + node_id: patch.node_id.unwrap_or(current.node_id), + due_at: patch.due_at.unwrap_or(current.due_at), + repeat, + ..current + }; // `done` is where the card stands, not a flag beside it: finishing a card // moves it into the last list, reopening one moves it back to the first, // and the stamp follows from the move as it does for any other. + let mut again = None; if let Some(done) = patch.done.filter(|&done| done != current.done_at.is_some()) { let space_id = list_space(&state.db, current.list_id).await?; let lists: Vec = sqlx::query_scalar( @@ -775,25 +838,21 @@ pub async fn update_card( )); } let to = if done { last } else { first }; - relocate(&mut tx, ¤t, to, last, None, now).await?; + again = relocate( + &mut tx, + ¤t, + to, + last, + None, + state.config.mail.timezone, + now, + ) + .await?; } - - sqlx::query( - "UPDATE cards SET title = ?1, body = ?2, node_id = ?3, due_at = ?4, updated_at = ?5 - WHERE id = ?6", - ) - .bind(&title) - .bind(patch.body.unwrap_or(current.body)) - .bind(patch.node_id.unwrap_or(current.node_id)) - .bind(patch.due_at.unwrap_or(current.due_at)) - .bind(now) - .bind(card_id) - .execute(&mut *tx) - .await?; tx.commit().await?; state.backup.signal(); - card(&state.db, card_id).await.map(Json) + moved(&state.db, card_id, again).await.map(Json) } pub async fn remove_card( @@ -822,7 +881,7 @@ pub async fn move_card( State(state): State, Path(card_id): Path, Json(input): Json, -) -> AppResult> { +) -> AppResult> { let current = card(&state.db, card_id).await?; let from = list_space(&state.db, current.list_id).await?; let to = list_space(&state.db, input.list_id).await?; @@ -839,25 +898,51 @@ pub async fn move_card( let last = last_list(&mut *tx, to) .await? .map_or(input.list_id, |(id, _)| id); - relocate(&mut tx, ¤t, input.list_id, last, input.position, now).await?; + let tz = state.config.mail.timezone; + let again = relocate( + &mut tx, + ¤t, + input.list_id, + last, + input.position, + tz, + now, + ) + .await?; tx.commit().await?; state.backup.signal(); - card(&state.db, card_id).await.map(Json) + moved(&state.db, card_id, again).await.map(Json) +} + +async fn moved(db: &SqlitePool, id: i64, again: Option) -> AppResult { + Ok(Moved { + card: card(db, id).await?, + again: match again { + Some(again) => Some(card(db, again).await?), + None => None, + }, + }) } /// Put a card on `to` at `position` (the end, if none), stamping or clearing -/// its done time by where it lands, and close the ranks of both lists. +/// its done time by where it lands, and close the ranks of both lists. A +/// repeating card that this finishes comes back: the id is of its copy. async fn relocate( db: &mut SqliteConnection, current: &Card, to: i64, last: i64, position: Option, + tz: Tz, now: i64, -) -> AppResult<()> { +) -> AppResult> { let card_id = current.id; let done_at = done_after_move(current.list_id, to, last, current.done_at, now); + let again = match current.done_at.is_none() && done_at.is_some() { + true => come_back(&mut *db, current, tz, now).await?, + false => None, + }; sqlx::query("UPDATE cards SET list_id = ?1, done_at = ?2, updated_at = ?3 WHERE id = ?4") .bind(to) @@ -898,13 +983,65 @@ async fn relocate( .await?; } } - Ok(()) + Ok(again) +} + +/// The next one of a repeating card that has just been finished, at the +/// bottom of its board's inbox: the same title, notes, document and rule, +/// due on the rule's next date from today on. A card without a date counts +/// from the moment it was finished. The finished card gives its rule up, so +/// taking it out of done and finishing it again leaves no second copy. +async fn come_back( + db: &mut SqliteConnection, + card: &Card, + tz: Tz, + now: i64, +) -> AppResult> { + let Some(rule) = card.repeat.as_deref().and_then(Repeat::parse) else { + return Ok(None); + }; + let today = crate::notify::day_bounds(tz, crate::notify::local_day(tz, now)).0; + let Some(due_at) = rule.next(tz, card.due_at.unwrap_or(now), today) else { + return Ok(None); + }; + + let inbox: i64 = sqlx::query_scalar( + "SELECT id FROM board_lists + WHERE space_id = (SELECT space_id FROM board_lists WHERE id = ?1) + ORDER BY position, id LIMIT 1", + ) + .bind(card.list_id) + .fetch_one(&mut *db) + .await?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO cards (list_id, node_id, title, body, due_at, repeat, position, + created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, + (SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE list_id = ?1), + ?7, ?7) + RETURNING id", + ) + .bind(inbox) + .bind(card.node_id) + .bind(&card.title) + .bind(&card.body) + .bind(due_at) + .bind(rule.name()) + .bind(now) + .fetch_one(&mut *db) + .await?; + + sqlx::query("UPDATE cards SET repeat = NULL WHERE id = ?1") + .bind(card.id) + .execute(&mut *db) + .await?; + Ok(Some(id)) } async fn card(db: &SqlitePool, id: i64) -> AppResult { let row = sqlx::query( "SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at, - c.position, c.created_at, c.updated_at, + c.repeat, c.position, c.created_at, c.updated_at, (SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name FROM cards c WHERE c.id = ?1", ) @@ -918,7 +1055,178 @@ async fn card(db: &SqlitePool, id: i64) -> AppResult { #[cfg(test)] mod tests { - use super::{agenda_cards, card, done_after_move, relocate, reordered, settle}; + use super::{agenda_cards, card, done_after_move, relocate, reordered, restore, settle}; + use crate::routes::manifest::{CardMeta, ListMeta}; + use std::collections::HashMap; + + /// A space with a board of `inbox`, `doing`, `done` on a fresh database. + async fn board() -> (sqlx::SqlitePool, i64, Vec) { + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&db).await.unwrap(); + let space: i64 = sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, body, has_board, created_at, updated_at) + VALUES (NULL, 'space', 'job', 'job', '', 1, 0, 0) RETURNING id", + ) + .fetch_one(&db) + .await + .unwrap(); + let mut lists = Vec::new(); + for (position, name) in ["inbox", "doing", "done"].iter().enumerate() { + let id: i64 = sqlx::query_scalar( + "INSERT INTO board_lists (space_id, name, position, created_at) + VALUES (?1, ?2, ?3, 0) RETURNING id", + ) + .bind(space) + .bind(name) + .bind(position as i64) + .fetch_one(&db) + .await + .unwrap(); + lists.push(id); + } + (db, space, lists) + } + + async fn finish(db: &sqlx::SqlitePool, id: i64, to: i64, last: i64, now: i64) -> Option { + let current = card(db, id).await.unwrap(); + let mut conn = db.acquire().await.unwrap(); + relocate(&mut conn, ¤t, to, last, None, chrono_tz::UTC, now) + .await + .unwrap() + } + + /// Finishing a weekly card leaves next week's in the inbox, once: the + /// finished one gives its rule up, so reopening it and finishing it again + /// makes nothing new. + #[tokio::test] + async fn a_repeating_card_comes_back_once() { + let (db, _, lists) = board().await; + let (inbox, doing, done) = (lists[0], lists[1], lists[2]); + let day = 86400; + let id: i64 = sqlx::query_scalar( + "INSERT INTO cards (list_id, title, body, due_at, repeat, position, created_at, updated_at) + VALUES (?1, 'water the plants', 'the big one too', ?2, 'weekly', 0, 0, 0) RETURNING id", + ) + .bind(doing) + .bind(10 * day + 12 * 3600) + .fetch_one(&db) + .await + .unwrap(); + + // Finished on day 11, a day late: back on day 17, a week after it was due. + let again = finish(&db, id, done, done, 11 * day).await.expect("a copy"); + let copy = card(&db, again).await.unwrap(); + assert_eq!(copy.list_id, inbox); + assert_eq!(copy.title, "water the plants"); + assert_eq!(copy.body, "the big one too"); + assert_eq!(copy.due_at, Some(17 * day + 12 * 3600)); + assert_eq!(copy.repeat.as_deref(), Some("weekly")); + assert_eq!(copy.done_at, None); + assert_eq!(card(&db, id).await.unwrap().repeat, None); + + assert_eq!(finish(&db, id, inbox, done, 12 * day).await, None); + assert_eq!(finish(&db, id, done, done, 12 * day).await, None); + let cards: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM cards") + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(cards, 2); + + // Weeks late, it comes back on the first of its days from today on. + assert_eq!( + card(&db, finish(&db, again, done, done, 40 * day).await.unwrap()) + .await + .unwrap() + .due_at, + Some(45 * day + 12 * 3600) + ); + } + + /// A card without a rule, or one moved between open lists, stays one card. + #[tokio::test] + async fn only_finishing_a_repeating_card_makes_a_copy() { + let (db, _, lists) = board().await; + let (inbox, doing, done) = (lists[0], lists[1], lists[2]); + let insert = |repeat: Option<&'static str>| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, i64>( + "INSERT INTO cards (list_id, title, due_at, repeat, position, created_at, updated_at) + VALUES (?1, 'c', 0, ?2, 0, 0, 0) RETURNING id", + ) + .bind(inbox) + .bind(repeat) + .fetch_one(&db) + .await + .unwrap() + } + }; + let plain = insert(None).await; + assert_eq!(finish(&db, plain, done, done, 100).await, None); + let weekly = insert(Some("weekly")).await; + assert_eq!(finish(&db, weekly, doing, done, 100).await, None); + assert_eq!( + card(&db, weekly).await.unwrap().repeat.as_deref(), + Some("weekly") + ); + } + + /// An export carries the rule, and an import puts it back — a rule it + /// cannot read is dropped rather than stored. + #[tokio::test] + async fn an_import_keeps_the_rule() { + let (db, space, lists) = board().await; + let meta = |title: &str, repeat: Option<&str>| CardMeta { + title: title.into(), + body: String::new(), + due_at: Some(0), + done_at: None, + repeat: repeat.map(String::from), + created_at: 0, + updated_at: 0, + document: None, + }; + let manifest = vec![ListMeta { + name: "inbox".into(), + cards: vec![ + meta("rent", Some("+1m")), + meta("odd", Some("sometimes")), + meta("once", None), + ], + }]; + let json = serde_json::to_string(&manifest).unwrap(); + assert!(json.contains(r#""repeat":"+1m""#)); + assert_eq!( + json.matches("repeat").count(), + 2, + "absent when there is none" + ); + let manifest: Vec = serde_json::from_str(&json).unwrap(); + + let mut conn = db.acquire().await.unwrap(); + restore(&mut conn, space, &manifest, &HashMap::new()) + .await + .unwrap(); + drop(conn); + let rules: Vec<(String, Option)> = + sqlx::query_as("SELECT title, repeat FROM cards WHERE list_id = ?1 ORDER BY position") + .bind(lists[0]) + .fetch_all(&db) + .await + .unwrap(); + assert_eq!( + rules, + vec![ + ("rent".into(), Some("monthly".into())), + ("odd".into(), None), + ("once".into(), None) + ] + ); + } /// The agenda asks for what is due soon or in hand, and never for what is /// finished, parked in the inbox without a date, or due far off. @@ -1036,7 +1344,7 @@ mod tests { let current = card(&db, id).await.unwrap(); let mut conn = db.acquire().await.unwrap(); - relocate(&mut conn, ¤t, done, done, None, 100) + relocate(&mut conn, ¤t, done, done, None, chrono_tz::UTC, 100) .await .unwrap(); drop(conn); diff --git a/src/routes/export.rs b/src/routes/export.rs index 99b3818..6e8a3b6 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -227,7 +227,7 @@ pub async fn export(_: Session, State(state): State) -> AppResult)> = sqlx::query( - "SELECT list_id, title, body, due_at, done_at, created_at, updated_at, node_id + "SELECT list_id, title, body, due_at, done_at, repeat, created_at, updated_at, node_id FROM cards ORDER BY position, id", ) .fetch_all(&state.db) @@ -239,6 +239,7 @@ pub async fn export(_: Session, State(state): State) -> AppResult, #[serde(default)] pub done_at: Option, + /// The rule a card comes back by, `weekly` or `+2w`. Added without a + /// bump of `FORMAT`: an older build ignores the field and imports the card + /// as one that does not repeat, which loses the rule but misreads nothing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repeat: Option, pub created_at: i64, pub updated_at: i64, /// The archive path of the document that holds the card's notes. diff --git a/src/routes/mod.rs b/src/routes/mod.rs index fb3cb17..e46d274 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -9,6 +9,7 @@ pub mod links; pub mod manifest; pub mod media; pub mod nodes; +pub mod repeat; pub mod revisions; pub mod seal; pub mod session; diff --git a/src/routes/repeat.rs b/src/routes/repeat.rs new file mode 100644 index 0000000..07f96b3 --- /dev/null +++ b/src/routes/repeat.rs @@ -0,0 +1,218 @@ +//! Cards that come back: the rules `:repeat` takes, and the date a finished +//! card comes back on. +//! +//! Steps are counted on the calendar in the configured timezone, not in +//! seconds, so a card due at noon stays due at noon across a change to +//! daylight saving time, and "monthly" means the same day of the next month — +//! the last day of it when the month is shorter. + +use chrono::{DateTime, Days, Months, NaiveDateTime, TimeZone}; +use chrono_tz::Tz; + +/// The longest step a rule takes, so a slip of the keyboard (`+2000w`) is +/// refused rather than parked decades ahead. +const MAX_STEP: u32 = 999; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Repeat { + Days(u32), + Months(u32), +} + +impl Repeat { + /// `daily`, `weekly`, `monthly`, `yearly`, `+3d`, `+2w`, `+6m`, `+1y`, in + /// any case. Anything else is not a rule. + pub fn parse(text: &str) -> Option { + let text = text.trim().to_ascii_lowercase(); + let rule = match text.as_str() { + "daily" => Repeat::Days(1), + "weekly" => Repeat::Days(7), + "monthly" => Repeat::Months(1), + "yearly" => Repeat::Months(12), + _ => { + let step = text.strip_prefix('+')?; + let (split, unit) = step.char_indices().last()?; + let n: u32 = step[..split].parse().ok()?; + if n == 0 || n > MAX_STEP { + return None; + } + match unit { + 'd' => Repeat::Days(n), + 'w' => Repeat::Days(n * 7), + 'm' => Repeat::Months(n), + 'y' => Repeat::Months(n * 12), + _ => return None, + } + } + }; + Some(rule) + } + + /// The rule as it is stored and shown: the word where there is one, the + /// largest unit that fits otherwise, so `+14d` and `+2w` are one rule. + pub fn name(self) -> String { + match self { + Repeat::Days(1) => "daily".into(), + Repeat::Days(7) => "weekly".into(), + Repeat::Days(n) if n % 7 == 0 => format!("+{}w", n / 7), + Repeat::Days(n) => format!("+{n}d"), + Repeat::Months(1) => "monthly".into(), + Repeat::Months(12) => "yearly".into(), + Repeat::Months(n) if n % 12 == 0 => format!("+{}y", n / 12), + Repeat::Months(n) => format!("+{n}m"), + } + } + + /// `k` steps after `from`, on the local calendar. + fn after(self, from: NaiveDateTime, k: u32) -> Option { + match self { + Repeat::Days(n) => from.checked_add_days(Days::new(u64::from(n) * u64::from(k))), + // Always counted from the first date rather than step by step, so + // a card due on the 31st that once landed on the 28th still comes + // back on the 31st of the months that have one. + Repeat::Months(n) => from.checked_add_months(Months::new(n.checked_mul(k)?)), + } + } + + /// When a card due at `due_at` comes back: the first step after it that + /// falls on `today_start` or later, so finishing a card that is weeks + /// late does not leave a copy that is late already. + pub fn next(self, tz: Tz, due_at: i64, today_start: i64) -> Option { + let from = DateTime::from_timestamp(due_at, 0)? + .with_timezone(&tz) + .naive_local(); + let mut k = 1; + loop { + let local = self.after(from, k)?; + // The hour a change to summer time skips does not exist; the clock + // has passed it, so the card is due the hour after. + let at = tz + .from_local_datetime(&local) + .earliest() + .or_else(|| { + tz.from_local_datetime(&(local + chrono::Duration::hours(1))) + .earliest() + })? + .timestamp(); + if at >= today_start { + return Some(at); + } + k = k.checked_add(1)?; + } + } +} + +#[cfg(test)] +mod tests { + use super::Repeat; + use chrono::{NaiveDate, TimeZone}; + use chrono_tz::Tz; + + const BERLIN: Tz = chrono_tz::Europe::Berlin; + + fn at(y: i32, m: u32, d: u32, h: u32) -> i64 { + BERLIN + .with_ymd_and_hms(y, m, d, h, 0, 0) + .earliest() + .unwrap() + .timestamp() + } + + fn day(y: i32, m: u32, d: u32) -> i64 { + crate::notify::day_bounds(BERLIN, NaiveDate::from_ymd_opt(y, m, d).unwrap()).0 + } + + #[test] + fn rules_read_in_words_and_steps_and_are_stored_one_way() { + assert_eq!(Repeat::parse("Weekly"), Some(Repeat::Days(7))); + assert_eq!(Repeat::parse(" +3d "), Some(Repeat::Days(3))); + assert_eq!( + Repeat::parse("+14d").map(Repeat::name).as_deref(), + Some("+2w") + ); + assert_eq!( + Repeat::parse("+1w").map(Repeat::name).as_deref(), + Some("weekly") + ); + assert_eq!( + Repeat::parse("+12m").map(Repeat::name).as_deref(), + Some("yearly") + ); + assert_eq!( + Repeat::parse("+3m").map(Repeat::name).as_deref(), + Some("+3m") + ); + for wrong in [ + "", + "+", + "+0d", + "+d", + "+3x", + "3d", + "fortnightly", + "+1000d", + "+-1d", + "+3é", + ] { + assert_eq!(Repeat::parse(wrong), None, "{wrong}"); + } + } + + #[test] + fn each_rule_steps_on_the_calendar() { + let due = at(2026, 10, 20, 12); + let today = day(2026, 10, 20); + let next = |rule: &str| Repeat::parse(rule).unwrap().next(BERLIN, due, today); + assert_eq!(next("daily"), Some(at(2026, 10, 21, 12))); + assert_eq!(next("weekly"), Some(at(2026, 10, 27, 12))); + assert_eq!(next("+3d"), Some(at(2026, 10, 23, 12))); + assert_eq!(next("+2w"), Some(at(2026, 11, 3, 12))); + assert_eq!(next("monthly"), Some(at(2026, 11, 20, 12))); + assert_eq!(next("+6m"), Some(at(2027, 4, 20, 12))); + assert_eq!(next("yearly"), Some(at(2027, 10, 20, 12))); + } + + /// Noon stays noon when the clocks go back on 25 October. + #[test] + fn a_step_across_daylight_saving_keeps_the_time_of_day() { + let due = at(2026, 10, 24, 12); + assert_eq!( + Repeat::Days(1).next(BERLIN, due, day(2026, 10, 24)), + Some(at(2026, 10, 25, 12)) + ); + } + + #[test] + fn the_end_of_a_month_clamps_and_does_not_drift() { + let due = at(2026, 1, 31, 12); + let monthly = Repeat::Months(1); + assert_eq!( + monthly.next(BERLIN, due, day(2026, 1, 31)), + Some(at(2026, 2, 28, 12)) + ); + // Rolled forward from the 31st, March has one again. + assert_eq!( + monthly.next(BERLIN, due, day(2026, 3, 1)), + Some(at(2026, 3, 31, 12)) + ); + assert_eq!( + Repeat::Months(12).next(BERLIN, at(2028, 2, 29, 12), day(2028, 2, 29)), + Some(at(2029, 2, 28, 12)) + ); + } + + /// A card finished weeks late comes back on its next date from today on, + /// not on one that has passed already. + #[test] + fn a_late_card_comes_back_today_or_later() { + let due = at(2026, 9, 1, 12); + assert_eq!( + Repeat::Days(7).next(BERLIN, due, day(2026, 10, 20)), + Some(at(2026, 10, 20, 12)) + ); + assert_eq!( + Repeat::Days(7).next(BERLIN, due, day(2026, 10, 21)), + Some(at(2026, 10, 27, 12)) + ); + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte index 9857e9b..f2f66bb 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -327,6 +327,11 @@ cmdline = ':due ' } return true + case ':': + // And so does `:repeat`, and any other command that asks for it. + cmdGlobal = false + cmdline = ':' + return true case 'H': case 'L': if (card) void workspace.guard(() => workspace.sendAgendaCard(card, event.key === 'H' ? -1 : 1)) diff --git a/web/src/lib/Agenda.svelte b/web/src/lib/Agenda.svelte index 63cc575..79c71c7 100644 --- a/web/src/lib/Agenda.svelte +++ b/web/src/lib/Agenda.svelte @@ -68,6 +68,9 @@ > {card.title} {card.space_slug}/{card.list_name} + {#if card.repeat !== null} + ↻ {card.repeat} + {/if} {#if card.due_at !== null} {formatWeekday(card.due_at)} {formatDay(card.due_at).slice(5)} {/if} diff --git a/web/src/lib/Board.svelte b/web/src/lib/Board.svelte index c5a2f31..93a44f2 100644 --- a/web/src/lib/Board.svelte +++ b/web/src/lib/Board.svelte @@ -85,11 +85,14 @@ {card.title} - {#if card.due_at !== null || card.node_id !== null} + {#if card.due_at !== null || card.node_id !== null || card.repeat !== null} {#if card.due_at !== null} due {formatDay(card.due_at)} {/if} + {#if card.repeat !== null} + ↻ {card.repeat} + {/if} {#if card.node_id !== null} → {card.node_name ?? `#${card.node_id}`} {/if} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 4ba387b..d0f523c 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -79,6 +79,7 @@ ['Enter', 'to the card, on its own board — or to the entry'], ['Space', 'done, where it stands'], ['D', 'due date: fri, tm, +3d … — :due on the line'], + [':', 'the line, with the agenda kept up — :repeat weekly acts on the card'], ['H / L', 'to the list left / right on its board'], ['T', "today's diary entry"], ['t · q · Esc', 'close the agenda'], diff --git a/web/src/lib/Icon.svelte b/web/src/lib/Icon.svelte index b40be82..22e8879 100644 --- a/web/src/lib/Icon.svelte +++ b/web/src/lib/Icon.svelte @@ -39,6 +39,7 @@ down: ['M12 5v14', 'M6 13l6 6 6-6'], drop: ['M12 4v11', 'M7 10l5 5 5-5', 'M5 20h14'], rename: ['M4 20h4L19 9l-4-4L4 16z', 'M13 7l4 4'], + repeat: ['M17 3l3 3-3 3', 'M4 11V9a3 3 0 0 1 3-3h13', 'M7 21l-3-3 3-3', 'M20 13v2a3 3 0 0 1-3 3H4'], trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'], more: ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'], } satisfies Record diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index 5b77de3..1949306 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -45,6 +45,7 @@ { icon: 'check', label: 'Done, or back to the inbox', keys: ['Space'], run: () => workspace.guard(() => workspace.toggleDone()), dim: !card }, { icon: 'right', label: 'Send the card right', keys: ['L'], run: () => workspace.guard(() => workspace.sendCard(1)), dim: !card }, { icon: 'calendar', label: 'Due date', keys: ['D'], run: () => hooks.openCommandLine(':due '), dim: !card }, + { icon: 'repeat', label: 'Repeat once done', keys: [':repeat'], run: () => hooks.openCommandLine(':repeat '), dim: !card }, { icon: 'doc', label: "The card's notes", keys: ['Enter'], run: () => workspace.guard(() => workspace.openCard()), dim: !card }, ], [ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4f3cdab..13e52e4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -166,11 +166,18 @@ export interface Card { node_name: string | null due_at: number | null done_at: number | null + /** `daily`, `weekly`, `monthly`, `yearly`, `+3d`, `+2w`, `+6m` — or none. */ + repeat: string | null position: number created_at: number updated_at: number } +/** A card after a move or an edit, and the copy a repeating card it finished left in the inbox. */ +export interface Moved extends Card { + again: Card | null +} + /** An open card as the agenda lists it, with the board it lives on. */ export interface AgendaCard extends Card { space_id: number @@ -551,14 +558,15 @@ export const api = { body?: string node_id?: number | null due_at?: number | null + repeat?: string | null done?: boolean }, - ) => request(`/cards/${id}`, { method: 'PUT', body: body(patch) }), + ) => request(`/cards/${id}`, { method: 'PUT', body: body(patch) }), deleteCard: (id: number) => request<{ ok: true }>(`/cards/${id}`, { method: 'DELETE' }), moveCard: (id: number, list_id: number, position?: number) => - request(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }), + request(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }), listMedia: () => pages((limit, offset) => request(`/media?limit=${limit}&offset=${offset}`)), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 99a7f76..56fc801 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -466,7 +466,7 @@ export const commands: CommandSpec[] = [ const agenda = workspace.overlay === 'agenda' if (workspace.view !== 'board' && !agenda) return workspace.say('no board open — :board', 'error') const set = (due_at: number | null) => - agenda ? workspace.dueAgendaCard(due_at) : workspace.patchCard({ due_at }) + agenda ? workspace.patchAgendaCard({ due_at }) : workspace.patchCard({ due_at }) const target = arg.trim() if (target === '-' || target === '') return workspace.guard(() => set(null)) const at = parseDue(target) @@ -475,6 +475,24 @@ export const commands: CommandSpec[] = [ workspace.say(`due ${formatDay(at)}`) }, }, + { + name: 'repeat', + args: '', + group: 'board', + help: 'once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it', + complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'], + run: async (arg) => { + const agenda = workspace.overlay === 'agenda' + if (workspace.view !== 'board' && !agenda) return workspace.say('no board open — :board', 'error') + const rule = arg.trim() + const repeat = rule === '-' || rule === '' ? null : rule + await workspace.guard(async () => { + const card = await (agenda ? workspace.patchAgendaCard({ repeat }) : workspace.patchCard({ repeat })) + // The server's spelling of the rule: `+14d` is kept as `+2w`. + if (card) workspace.say(card.repeat === null ? 'repeats no more' : `repeats ${card.repeat} once done`) + }) + }, + }, { name: 'done', group: 'board', diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 9009dbf..92297db 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -12,6 +12,7 @@ import { type DiaryEntry, type WorkspaceNode, type MediaFile, + type Moved, type NodeSummary, type Revision, type RevisionSummary, @@ -19,7 +20,7 @@ import { type TrashItem, type VaultStatus, } from './api' -import { formatBytes, formatDay, formatStamp, isTouchDevice, parseDay, sealedNote, type Heading } from './util' +import { formatBytes, formatDay, formatStamp, formatWeekday, isTouchDevice, parseDay, sealedNote, type Heading } from './util' export type Pane = 'list' | 'document' /** The tree of documents, or the board of the space being browsed. */ @@ -41,6 +42,12 @@ type Spot = { export type Draft = { name: string; body: string; created_at: number } +/** `done: x`, and when a repeating card comes back. */ +function doneSaid(moved: Moved): string { + const again = moved.again?.due_at + return again == null ? `done: ${moved.title}` : `done: ${moved.title} — again ${formatWeekday(again)} ${formatDay(again)}` +} + let tiles = 0 /** @@ -1369,22 +1376,23 @@ class Workspace { async finishCard(card: AgendaCard) { const done = (await api.board(card.space_id)).lists.at(-1) if (!done) return - await api.moveCard(card.id, done.id, done.cards.length) + const moved = await api.moveCard(card.id, done.id, done.cards.length) await this.loadAgenda() if (this.view === 'board' && this.spaceId === card.space_id) await this.reloadBoard() - this.say(`done: ${card.title}`) + this.say(doneSaid(moved)) } - /** `:due` with the agenda up: the highlighted card's date, on its own board. */ - async dueAgendaCard(due_at: number | null) { + /** `:due` or `:repeat` with the agenda up: the highlighted card, on its own board. */ + async patchAgendaCard(patch: Parameters[1]) { const card = this.agendaCards[this.agendaCursor] if (!card) return this.say('no card highlighted', 'error') - await api.updateCard(card.id, { due_at }) + const updated = await api.updateCard(card.id, patch) await this.loadAgenda() // It may have changed group; the cursor goes with it. const at = this.agendaCards.findIndex((c) => c.id === card.id) if (at >= 0) this.agendaCursor = at if (this.view === 'board' && this.spaceId === card.space_id) await this.reloadBoard() + return updated } /** @@ -1396,12 +1404,12 @@ class Workspace { const from = lists.findIndex((list) => list.id === card.list_id) const target = lists[from + delta] if (from < 0 || !target) return this.say(`${card.title} is already in the ${delta < 0 ? 'first' : 'last'} list`, 'error') - await api.moveCard(card.id, target.id, target.cards.length) + const moved = await api.moveCard(card.id, target.id, target.cards.length) await this.loadAgenda() const at = this.agendaCards.findIndex((c) => c.id === card.id) if (at >= 0) this.agendaCursor = at if (this.view === 'board' && this.spaceId === card.space_id) await this.reloadBoard() - this.say(from + delta === lists.length - 1 ? `done: ${card.title}` : `"${card.title}" → ${target.name}`) + this.say(from + delta === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`) } async loadOverview() { @@ -1563,9 +1571,9 @@ class Workspace { const lists = this.board?.lists ?? [] const target = lists[index] if (!card || !target || index === this.listCursor) return - await api.moveCard(card.id, target.id, target.cards.length) + const moved = await api.moveCard(card.id, target.id, target.cards.length) await this.reloadBoard(this.spaceId, card.id) - this.say(index === lists.length - 1 ? `done: ${card.title}` : `"${card.title}" → ${target.name}`) + this.say(index === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`) } /** @@ -1595,8 +1603,9 @@ class Workspace { async patchCard(patch: Parameters[1]) { const card = this.card if (!card) return this.say('no card selected', 'error') - await api.updateCard(card.id, patch) + const updated = await api.updateCard(card.id, patch) await this.reloadBoard(this.spaceId, card.id) + return updated } /** Space: into the done list, or back out of it to the inbox. */