mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
agenda, capture and finder: what needs doing comes to you
GET /api/agenda gathers unfinished cards from every board that are late, due within a week, or in hand, plus whether the diary has today; the reminder mail and the agenda share one diary check. t opens it (and it opens itself once a day when something is late or due), T writes today's entry, the bar counts late and due cards, Ctrl-P searches every space, and a document o made that is left empty is removed on the way out.
This commit is contained in:
1 parent
e1c8e22ea5
commit
9255d36b61
14 files changed
+686
-40
No files matched your search
+24
-18
@@ -93,23 +93,7 @@ impl Notifier {
|
||||
/// "You have not written today." Asked once the reminder time has passed,
|
||||
/// and only if the diary space gained no document that local day.
|
||||
async fn sweep_reminder(&self, today: NaiveDate) -> Result<()> {
|
||||
let (start, end) = day_bounds(self.config.mail.timezone, today);
|
||||
let written: i64 = sqlx::query_scalar(
|
||||
"WITH RECURSIVE tree (id) AS (
|
||||
SELECT id FROM nodes WHERE parent_id IS NULL AND slug = 'diary'
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN tree t ON n.parent_id = t.id
|
||||
)
|
||||
SELECT count(*) FROM nodes
|
||||
WHERE kind = 'document' AND id IN (SELECT id FROM tree)
|
||||
AND created_at >= ? AND created_at < ?",
|
||||
)
|
||||
.bind(start)
|
||||
.bind(end)
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
|
||||
if written > 0 {
|
||||
if diary_written(&self.db, self.config.mail.timezone, today).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -398,8 +382,30 @@ fn device_hash(user_agent: &str) -> String {
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Whether the diary space gained a document on the local day `day`. Shared
|
||||
/// by the evening reminder and the agenda, so the two can never disagree about
|
||||
/// whether today has been written.
|
||||
pub(crate) async fn diary_written(db: &SqlitePool, tz: Tz, day: NaiveDate) -> sqlx::Result<bool> {
|
||||
let (start, end) = day_bounds(tz, day);
|
||||
let written: i64 = sqlx::query_scalar(
|
||||
"WITH RECURSIVE tree (id) AS (
|
||||
SELECT id FROM nodes WHERE parent_id IS NULL AND slug = 'diary'
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN tree t ON n.parent_id = t.id
|
||||
)
|
||||
SELECT count(*) FROM nodes
|
||||
WHERE kind = 'document' AND id IN (SELECT id FROM tree)
|
||||
AND created_at >= ? AND created_at < ?",
|
||||
)
|
||||
.bind(start)
|
||||
.bind(end)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
Ok(written > 0)
|
||||
}
|
||||
|
||||
/// The local date an instant falls on.
|
||||
fn local_day(tz: Tz, at: i64) -> NaiveDate {
|
||||
pub(crate) fn local_day(tz: Tz, at: i64) -> NaiveDate {
|
||||
Utc.timestamp_opt(at, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now)
|
||||
|
||||
+152
-1
@@ -124,6 +124,91 @@ fn reordered(ids: &[i64], card: i64, to: usize) -> Vec<i64> {
|
||||
rest
|
||||
}
|
||||
|
||||
/// How far ahead the agenda looks for due dates.
|
||||
const AGENDA_DAYS: i64 = 7;
|
||||
|
||||
/// What is open across every board: the answer to "what should I be doing",
|
||||
/// asked without having to remember which space to look in.
|
||||
#[derive(Serialize)]
|
||||
pub struct Agenda {
|
||||
pub cards: Vec<AgendaCard>,
|
||||
/// Whether the diary has an entry for today, in the configured timezone —
|
||||
/// `None` when there is no diary space to ask about.
|
||||
pub diary_today: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AgendaCard {
|
||||
#[serde(flatten)]
|
||||
pub card: Card,
|
||||
pub space_id: i64,
|
||||
pub space_slug: String,
|
||||
pub list_name: String,
|
||||
/// `inbox` for the first list, `doing` for any list between it and done.
|
||||
pub role: &'static str,
|
||||
}
|
||||
|
||||
/// Every unfinished card that is due within a week (or overdue), or that sits
|
||||
/// in a list between a board's inbox and its done list — work in hand. The
|
||||
/// last list of each board is done by definition, so nothing there is asked.
|
||||
pub async fn agenda(_: Session, State(state): State<AppState>) -> AppResult<Json<Agenda>> {
|
||||
let cards = agenda_cards(&state.db, crate::now() + (AGENDA_DAYS + 1) * 86400).await?;
|
||||
|
||||
let has_diary: Option<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM nodes WHERE parent_id IS NULL AND slug = 'diary'")
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let diary_today = match has_diary {
|
||||
Some(_) => {
|
||||
let tz = state.config.mail.timezone;
|
||||
let today = crate::notify::local_day(tz, crate::now());
|
||||
Some(crate::notify::diary_written(&state.db, tz, today).await?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Json(Agenda { cards, diary_today }))
|
||||
}
|
||||
|
||||
/// The agenda's cards, due before `horizon` or in hand. Apart from the handler
|
||||
/// so the selection can be tested against a real schema.
|
||||
async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult<Vec<AgendaCard>> {
|
||||
let rows = sqlx::query(
|
||||
"WITH bounds AS (
|
||||
SELECT space_id, MIN(position) AS first, MAX(position) AS last
|
||||
FROM board_lists GROUP BY space_id
|
||||
)
|
||||
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,
|
||||
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name,
|
||||
s.id AS space_id, s.slug AS space_slug, l.name AS list_name,
|
||||
l.position = b.first AS is_first
|
||||
FROM cards c
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
JOIN bounds b ON b.space_id = l.space_id
|
||||
JOIN nodes s ON s.id = l.space_id
|
||||
WHERE s.has_board <> 0
|
||||
AND c.done_at IS NULL
|
||||
AND (l.position < b.last OR b.first = b.last)
|
||||
AND ((c.due_at IS NOT NULL AND c.due_at < ?1) OR (l.position > b.first))
|
||||
ORDER BY c.due_at IS NULL, c.due_at, s.position, l.position, c.position",
|
||||
)
|
||||
.bind(horizon)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| AgendaCard {
|
||||
card: row_to_card(row),
|
||||
space_id: row.get("space_id"),
|
||||
space_slug: row.get("space_slug"),
|
||||
list_name: row.get("list_name"),
|
||||
role: if row.get::<bool, _>("is_first") { "inbox" } else { "doing" },
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `done_at` after a card moves from list `from` to list `to` on a board whose
|
||||
/// last list is `last`. Shuffling inside one list changes nothing; arriving in
|
||||
/// the last list finishes the card (keeping an earlier stamp), leaving it
|
||||
@@ -580,7 +665,73 @@ async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{done_after_move, reordered};
|
||||
use super::{agenda_cards, done_after_move, reordered};
|
||||
|
||||
/// 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.
|
||||
#[tokio::test]
|
||||
async fn the_agenda_holds_what_is_due_or_in_hand() {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("an in-memory database");
|
||||
sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply");
|
||||
|
||||
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
|
||||
.expect("a space");
|
||||
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
|
||||
.expect("a list");
|
||||
lists.push(id);
|
||||
}
|
||||
let card = |list: i64, title: &'static str, due: Option<i64>, done: Option<i64>| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
sqlx::query(
|
||||
"INSERT INTO cards (list_id, title, due_at, done_at, position, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, 0, 0, 0)",
|
||||
)
|
||||
.bind(list)
|
||||
.bind(title)
|
||||
.bind(due)
|
||||
.bind(done)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("a card");
|
||||
}
|
||||
};
|
||||
card(lists[0], "someday", None, None).await;
|
||||
card(lists[0], "due soon", Some(500), None).await;
|
||||
card(lists[0], "due later", Some(5000), None).await;
|
||||
card(lists[1], "in hand", None, None).await;
|
||||
card(lists[2], "finished", Some(500), Some(600)).await;
|
||||
|
||||
let titles: Vec<(String, &str)> = agenda_cards(&db, 1000)
|
||||
.await
|
||||
.expect("the agenda")
|
||||
.into_iter()
|
||||
.map(|c| (c.card.title, c.role))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
titles,
|
||||
vec![("due soon".to_string(), "inbox"), ("in hand".to_string(), "doing")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arriving_in_the_last_list_finishes_a_card_and_leaving_reopens_it() {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/backup", get(backup::status).post(backup::run))
|
||||
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
||||
.route("/spaces/overview", get(nodes::overview))
|
||||
.route("/agenda", get(boards::agenda))
|
||||
.route("/spaces/{id}/board", get(boards::board))
|
||||
.route("/spaces/{id}/lists", post(boards::create_list))
|
||||
.route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list))
|
||||
|
||||
Reference in new issue
Block a user