From 6673298cb285c223a8d6107277e4aa0c0abf75cc Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 21 Oct 2026 00:02:42 +0200 Subject: [PATCH] boards: a card can come back once it is done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:repeat weekly` gives a card a rule: daily, weekly, monthly, yearly, or every +Nd, +Nw, +Nm, +Ny. Finishing it, by a move into the last list or by the API's done, leaves a copy at the bottom of the inbox with the same title, notes, document and rule, due on the next date. The finished card gives its rule up in the same transaction, so reopening it and finishing it again makes no second copy. The next date is counted on the calendar in WORKSPACE_TIMEZONE, so noon stays noon across a change of the clocks, and months are counted from the old due date rather than step by step, so a card due on the 31st comes back on the 31st wherever a month has one. A card finished late skips the dates already past; one without a date counts from the moment it was finished. A move and an edit answer with `again`, the copy, so the status line can say when the card comes back. The board and the agenda show the rule as `↻ weekly`, the rail has a button for it, and `:` in the agenda now opens the line with the agenda kept up, so `:repeat` knows which card. The rule travels in `.workspace.json` without a format bump: an older build ignores the field and imports the card as one that does not repeat. An import drops a rule it cannot read. --- README.md | 29 ++- migrations/0023_repeat.sql | 5 + src/routes/boards.rs | 368 +++++++++++++++++++++++++++++++++--- src/routes/export.rs | 3 +- src/routes/manifest.rs | 5 + src/routes/mod.rs | 1 + src/routes/repeat.rs | 218 +++++++++++++++++++++ web/src/App.svelte | 5 + web/src/lib/Agenda.svelte | 3 + web/src/lib/Board.svelte | 5 +- web/src/lib/Help.svelte | 1 + web/src/lib/Icon.svelte | 1 + web/src/lib/Rail.svelte | 1 + web/src/lib/api.ts | 12 +- web/src/lib/commands.ts | 20 +- web/src/lib/store.svelte.ts | 31 +-- 16 files changed, 657 insertions(+), 51 deletions(-) create mode 100644 migrations/0023_repeat.sql create mode 100644 src/routes/repeat.rs 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. */