diff --git a/README.md b/README.md index 77626b4..83602e6 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,13 @@ it; in the document pane it is that document. In the browse pane: | `i`, `a` | edit the open entry | | `R` | rename what is highlighted — document, folder or space | | `b` | the board of this space | +| `t` | the agenda: what is late, due this week, or in hand, on every board | +| `T` | today's diary entry, started if there is none | +| `c` | capture a card into this space's inbox without leaving where you are | | `Esc`, `q` | back out to the document | | `h` | up one level in the tree | -| `/` | full-text search (`n` clears it) | +| `/` | full-text search in this space (`n` clears it) | +| `Ctrl-P` | full-text search across every space; `Enter` goes to the match | | `s` / `y` | toggle sharing / mint a new share link | | `x`, `dd` | delete the entry under the cursor (asks `y`/`N`) | | `:` | command line | @@ -128,11 +132,22 @@ closing that document returns to the board on the same card. The link is deliber deleting the document leaves the card standing, because the work is still open once its notes are gone. +## 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, 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. + ## Not losing things Every change is written to `localStorage` as it is typed and to the server a couple of seconds after typing stops, so `:w` is a habit rather than a -necessity. If a tab dies mid-document, opening it again restores the draft and +necessity. A document made with `o` and left with neither a name nor a word in +it is removed again on the way out, so abandoned starts do not pile up. If a tab dies mid-document, opening it again restores the draft and says so — `:e!` throws it away, the way vim handles a swap file. `:export` downloads the open document as markdown. `:export!` downloads the @@ -298,6 +313,7 @@ Everything except the two share routes requires the session cookie. | `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create | | `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) | | `GET` | `/api/spaces/{id}/board` | the lists and cards of one space | +| `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, and whether the diary has today | | `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 | diff --git a/src/notify/mod.rs b/src/notify/mod.rs index 213d947..b696290 100644 --- a/src/notify/mod.rs +++ b/src/notify/mod.rs @@ -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 { + 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) diff --git a/src/routes/boards.rs b/src/routes/boards.rs index a5ae657..8486f4d 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -124,6 +124,91 @@ fn reordered(ids: &[i64], card: i64, to: usize) -> Vec { 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, + /// 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, +} + +#[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) -> AppResult> { + let cards = agenda_cards(&state.db, crate::now() + (AGENDA_DAYS + 1) * 86400).await?; + + let has_diary: Option = + 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> { + 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::("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 { #[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, done: Option| { + 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() { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 1ef2f48..e225a5f 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -24,6 +24,7 @@ pub fn api_router() -> Router { .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)) diff --git a/web/src/App.svelte b/web/src/App.svelte index 3a09596..94a522b 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,6 +1,7 @@ + +
+
+ agenda {total} open + +
+ + {#if workspace.agenda.diary_today === false} +

+ nothing in the diary today + T writes it +

+ {/if} + + {#each groups as group (group.key)} +
+

{titles[group.key]} {group.cards.length}

+
    + {#each group.cards as card (card.id)} + {@const index = indexOf(card)} +
  • + +
  • + {/each} +
+
+ {:else} +

nothing late, nothing due this week, nothing in hand. c captures the next thing.

+ {/each} + +
+ jk move + + ↵ to its board + + ␣ done + + T diary + + tesc close +
+
+ + diff --git a/web/src/lib/Bar.svelte b/web/src/lib/Bar.svelte index dec6303..7d33087 100644 --- a/web/src/lib/Bar.svelte +++ b/web/src/lib/Bar.svelte @@ -6,11 +6,12 @@ * that is genuinely faster to walk with a pointer than with `:cd`. */ import Clock from './Clock.svelte' - import { hooks } from './commands' + import { hooks, runCommand } from './commands' import { workspace } from './store.svelte' // The space is the first crumb, and it is already named by its pill. const crumbs = $derived(workspace.path.slice(1)) + const due = $derived(workspace.dueCounts) @@ -62,10 +63,19 @@ - - user - {workspace.user} - + + {#if due.late + due.today > 0} + + {:else} + + user + {workspace.user} + + {/if} @@ -83,6 +93,20 @@ .crumbs { flex: 0 1 auto; } .n { color: var(--muted); font-size: 10px; } + + .due { + align-items: center; + padding: 2px 8px; + border: 1px solid transparent; + background: transparent; + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + } + .due:hover { border-color: var(--rim); background: transparent; } + .due .late { color: var(--alarm); } + .due .now { color: var(--light); } + .due .value + .key { margin-left: 6px; } .pill:hover .n { color: var(--light-dim); } .pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; } .pill.add { color: var(--muted); } @@ -124,6 +148,7 @@ */ @media (max-width: 767px) { .crumbs, .divider.path, .bar .user, .divider.wide { display: none; } + .due { margin-left: auto; padding: 2px 4px; } .spaces { max-width: none; flex: 0 1 auto; gap: 2px; } .pill:not([aria-current='true']) .slug { display: none; } .pill:not([aria-current='true']) { min-width: 28px; height: 24px; } diff --git a/web/src/lib/CommandLine.svelte b/web/src/lib/CommandLine.svelte index 293e4c4..a8162e2 100644 --- a/web/src/lib/CommandLine.svelte +++ b/web/src/lib/CommandLine.svelte @@ -5,10 +5,12 @@ interface Props { initial: string + /** A `/` search across every space rather than this one — Ctrl-P. */ + global?: boolean close: () => void } - const { initial, close }: Props = $props() + const { initial, global = false, close }: Props = $props() let value = $state(untrack(() => initial)) let input = $state() @@ -23,7 +25,7 @@ $effect(() => { if (!isSearch) return const query = value.slice(1) - const timer = setTimeout(() => void workspace.guard(() => workspace.search(query)), 120) + const timer = setTimeout(() => void workspace.guard(() => workspace.search(query, global)), 120) return () => clearTimeout(timer) }) @@ -67,6 +69,7 @@ {/if}
+ {#if isSearch}{global ? 'all spaces' : (workspace.space?.slug ?? '')}{/if}
@@ -92,7 +95,8 @@ animation: rise var(--t-slow) var(--ease-snap) both; } - form { display: flex; padding: 6px; } + form { display: flex; align-items: center; gap: 8px; padding: 6px; } + .scope { flex: none; padding-left: 6px; font-size: 10px; color: var(--muted); } input { flex: 1; diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index b268f52..db0098e 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -19,6 +19,9 @@ ['i · a', 'edit the open document'], ['R', 'rename what is under the cursor — document, folder or card'], ['m / p', 'pick a node up, then drop it into the folder you walk to'], + ['t', 'agenda — late, due this week, in hand, on every board'], + ['T', "today's diary entry, started if there is none"], + ['Ctrl-P', 'find anything, in every space'], ['b', "open this space's board"], ['c', "capture a card into this space's inbox, without leaving here"], ['Esc · q', 'close the document'], diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index 2eae66b..027ef8b 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -34,7 +34,7 @@ return [ [ { icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') }, - { icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') }, + { icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list '), desk: true }, { icon: 'list', label: 'Back to the tree', keys: ['b', 'q'], run: () => runCommand('board'), on: true }, ], [ @@ -57,6 +57,10 @@ { icon: 'folder-plus', label: 'New folder', keys: ['⇧ O'], run: () => hooks.openCommandLine(':mkdir ') }, ...(workspace.space ? [board] : []), ], + [ + { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => runCommand('agenda') }, + { icon: 'capture', label: 'Capture a card to the inbox', keys: ['c'], run: () => hooks.openCommandLine(':card ') }, + ], [ { icon: 'grid', label: 'Every space', keys: ['w'], run: () => hooks.openOverview() }, { diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 2e87254..2bbb361 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -36,6 +36,7 @@ {#if workspace.query}
/{workspace.query} + {#if workspace.globalSearch}all spaces{/if}
{/if} @@ -105,6 +106,7 @@ font-size: 12px; flex: none; } + .scope { flex: none; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; } .query { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--light); } ul { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 41f7b48..4ce3463 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -77,6 +77,20 @@ export interface Card { updated_at: number } +/** An open card as the agenda lists it, with the board it lives on. */ +export interface AgendaCard extends Card { + space_id: number + space_slug: string + list_name: string + role: 'inbox' | 'doing' +} + +export interface Agenda { + cards: AgendaCard[] + /** Whether the diary has an entry today; null when there is no diary. */ + diary_today: boolean | null +} + export interface BoardList { id: number name: string @@ -213,6 +227,9 @@ export const api = { unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }), + /** Open cards across every board: due within a week, overdue, or in hand. */ + agenda: () => request('/agenda'), + /** The board of a space. Fails for a space that has none, such as the diary. */ board: (spaceId: number) => request(`/spaces/${spaceId}/board`), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 4fe57ff..3b3fc8f 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -121,7 +121,12 @@ export const commands: CommandSpec[] = [ aliases: ['q'], group: 'editing', help: 'close the editor, or the document when already reading', - run: () => { + run: async () => { + // A document `o` made and nothing was written in is not kept. + if (await workspace.abandonIfBlank()) { + if (workspace.returnTo === 'board') await workspace.guard(() => workspace.returnToBoard()) + return hooks.focusList() + } if (workspace.dirty) { workspace.say('unsaved changes — :w to write, :q! to discard', 'error') return @@ -331,6 +336,18 @@ export const commands: CommandSpec[] = [ hooks.focusEditor() }, }, + { + name: 'agenda', + aliases: ['ag'], + group: 'board', + help: 'what is late, due this week, or in hand — across every board', + run: () => + workspace.guard(async () => { + await workspace.loadAgenda() + workspace.agendaCursor = 0 + workspace.overlay = workspace.overlay === 'agenda' ? 'none' : 'agenda' + }), + }, { name: 'today', aliases: ['t'], @@ -616,8 +633,12 @@ export const commands: CommandSpec[] = [ aliases: ['se'], args: '[text]', group: 'documents', - help: 'full-text search (empty clears)', - run: (arg) => workspace.guard(() => workspace.search(arg)), + help: 'full-text search in this space (empty clears)', + run: (arg) => workspace.guard(() => workspace.search(arg, false)), + bang: { + help: 'full-text search across every space — Ctrl-P', + run: (arg) => workspace.guard(() => workspace.search(arg, true)), + }, }, { name: 'clear', diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 04d67a7..90fdb42 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -1,6 +1,8 @@ import { api, ApiError, + type Agenda, + type AgendaCard, type Board, type Card, type Crumb, @@ -92,7 +94,23 @@ class Workspace { vimMode = $state('normal') flash = $state(null) - overlay = $state<'none' | 'help' | 'media' | 'overview'>('none') + overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda'>('none') + + /* + * What is due or in hand on every board, kept loaded all the time: the bar + * counts from it, so what is late is in sight without going to look. + */ + agenda = $state({ cards: [], diary_today: null }) + agendaCursor = $state(0) + + /** A search across every space rather than the one being browsed — Ctrl-P. */ + globalSearch = $state(false) + + /* + * The document `o` just made. If it is left with neither a name nor a word + * in it, it is removed on the way out rather than left as an empty row. + */ + #fresh: number | null = null /* * A `[y/N]` question in the command line's place, instead of the browser's @@ -153,6 +171,31 @@ class Workspace { return this.list?.cards[this.cardCursor] } + /** The agenda in the order it is drawn and walked, bucketed by urgency. */ + get agendaGroups(): { key: 'overdue' | 'today' | 'week' | 'doing'; cards: AgendaCard[] }[] { + const midnight = new Date() + midnight.setHours(0, 0, 0, 0) + const start = Math.floor(midnight.getTime() / 1000) + const groups = { overdue: [] as AgendaCard[], today: [] as AgendaCard[], week: [] as AgendaCard[], doing: [] as AgendaCard[] } + for (const card of this.agenda.cards) { + if (card.due_at !== null && card.due_at < start) groups.overdue.push(card) + else if (card.due_at !== null && card.due_at < start + 86400) groups.today.push(card) + else if (card.due_at !== null && card.due_at < start + 8 * 86400) groups.week.push(card) + else if (card.role === 'doing') groups.doing.push(card) + } + return (['overdue', 'today', 'week', 'doing'] as const).map((key) => ({ key, cards: groups[key] })) + } + + get agendaCards(): AgendaCard[] { + return this.agendaGroups.flatMap((group) => group.cards) + } + + /** `late 1 · today 2` — what the bar shows. */ + get dueCounts(): { late: number; today: number } { + const [overdue, today] = this.agendaGroups + return { late: overdue.cards.length, today: today.cards.length } + } + ask(text: string): Promise { this.prompt?.resolve(false) return new Promise((resolve) => { @@ -242,14 +285,44 @@ class Workspace { clearTimeout(this.#autosave) this.#autosave = null } + if (await this.abandonIfBlank()) return if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true })) } + /** + * Drop the document `o` made if nothing was ever put in it. True when it + * went, so the caller knows there is nothing open any more. + */ + async abandonIfBlank(): Promise { + const open = this.open + if (!open || open.id !== this.#fresh) return false + if (this.draft.name.trim() || this.draft.body.trim()) return false + this.#fresh = null + if (this.#autosave) { + clearTimeout(this.#autosave) + this.#autosave = null + } + this.dropStash(open.id) + this.open = null + this.editing = false + this.dirty = false + this.pane = 'list' + try { + await api.deleteNode(open.id) + } catch { + /* already gone, or the server is away; an empty row is no great loss */ + } + await this.refresh() + return true + } + async boot() { try { const { user } = await api.me() this.user = user await this.loadSpaces(Workspace.addressedPath()) + await this.loadAgenda() + await this.openAgendaIfDue() } catch { this.user = null } finally { @@ -261,6 +334,8 @@ class Workspace { const { user } = await api.login(username, password) this.user = user await this.loadSpaces(Workspace.addressedPath()) + await this.loadAgenda() + await this.openAgendaIfDue() } async logout() { @@ -385,6 +460,7 @@ class Workspace { if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot() this.parentId = id this.query = '' + this.globalSearch = false this.path = folder.path this.spaceId = folder.path[0]?.id ?? id this.nodes = await api.listNodes({ parent: id }) @@ -405,14 +481,15 @@ class Workspace { async refresh(keep?: number) { const keepId = keep ?? this.selected?.id this.nodes = this.query - ? await api.listNodes({ q: this.query, space: this.spaceId ?? undefined }) + ? await api.listNodes({ q: this.query, space: this.globalSearch ? undefined : (this.spaceId ?? undefined) }) : await api.listNodes({ parent: this.parentId ?? undefined }) const index = this.nodes.findIndex((n) => n.id === keepId) this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.nodes.length - 1, 0)) } - async search(query: string) { + async search(query: string, global = this.globalSearch) { this.query = query + this.globalSearch = global && query !== '' this.cursor = 0 await this.refresh() } @@ -456,6 +533,11 @@ class Workspace { async openSelected(edit = false) { const selected = this.selected if (!selected) return + // A match from anywhere is gone to, not opened beside a list it is not in. + if (this.globalSearch) { + const node = await api.getNode(selected.id) + return this.openPath(node.path.map((crumb) => crumb.slug).join('/')) + } if (selected.kind === 'document') await this.openNode(selected.id, edit) else await this.goTo(selected.id) } @@ -466,8 +548,10 @@ class Workspace { if (parent === null) return const node = await api.createNode({ parent_id: parent, name, body: '' }) this.query = '' + this.globalSearch = false await this.refresh() await this.openNode(node.id, true) + this.#fresh = node.id this.enterInsert = true this.say(`new document ${this.here}/${node.slug}`) } @@ -639,6 +723,47 @@ class Workspace { this.say(`moved ${node.name.trim() || node.slug} → ${index + 1} ${space.slug}`) } + async loadAgenda() { + try { + this.agenda = await api.agenda() + } catch { + /* the counter is a convenience; a failed refresh leaves the last one up */ + } + this.agendaCursor = Math.min(this.agendaCursor, Math.max(this.agendaCards.length - 1, 0)) + } + + /** + * The agenda opens itself once a day when something is late or due — the + * point is to be told, not to have to remember to ask. + */ + async openAgendaIfDue() { + const today = new Date().toDateString() + if (localStorage.getItem('diary:agenda-seen') === today) return + const { late, today: due } = this.dueCounts + if (late + due === 0) return + localStorage.setItem('diary:agenda-seen', today) + this.agendaCursor = 0 + this.overlay = 'agenda' + } + + /** Enter in the agenda: to the card, on its own board. */ + async goToCard(card: AgendaCard) { + this.overlay = 'none' + if (this.spaceId !== card.space_id) await this.enterSpace(card.space_id) + await this.reloadBoard(card.space_id, card.id) + this.view = 'board' + } + + /** Space in the agenda: into that board's done list, from wherever it is. */ + 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) + await this.loadAgenda() + if (this.view === 'board' && this.spaceId === card.space_id) await this.reloadBoard() + this.say(`done: ${card.title}`) + } + async loadOverview() { this.overview = await api.overview() } @@ -703,6 +828,7 @@ class Workspace { async reloadBoard(spaceId = this.spaceId, keep = this.card?.id) { if (spaceId === null) return this.rawBoard = await api.board(spaceId) + void this.loadAgenda() // Follow the card that was just moved rather than whatever now sits under // the cursor, so J J J walks one card down instead of shuffling three. if (keep !== undefined) { @@ -799,6 +925,7 @@ class Workspace { const inbox = (await api.board(space.id)).lists[0] if (!inbox) return this.say(`${space.slug} has no lists — :board, then :list `, 'error') const card = await api.createCard(inbox.id, { title, node_id: nodeId }) + await this.loadAgenda() this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`) }