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:
nvrl committed 2026-10-19 14:51:52 +02:00
1 parent e1c8e22ea5
commit 9255d36b61
14 files changed
+686 -40

No files matched your search

+18 -2
View File
@@ -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 |
+24 -18
View File
@@ -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
View File
@@ -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() {
+1
View File
@@ -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))
+93 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { hooks, runCommand } from './lib/commands'
import { embedSnippet } from './lib/markdown'
import Agenda from './lib/Agenda.svelte'
import Bar from './lib/Bar.svelte'
import Board from './lib/Board.svelte'
import CommandLine from './lib/CommandLine.svelte'
@@ -26,6 +27,7 @@
const shareKey = shareToken ? decodeURIComponent(location.hash.slice(1)) : ''
let cmdline = $state<string | null>(null)
let cmdGlobal = $state(false)
let fileInput = $state<HTMLInputElement>()
let folderInput = $state<HTMLInputElement>()
let importInput = $state<HTMLInputElement>()
@@ -44,7 +46,10 @@
hooks.pickFolder = () => folderInput?.click()
hooks.pickArchive = () => importInput?.click()
hooks.focusList = () => shell?.focus()
hooks.openCommandLine = (initial) => (cmdline = initial)
hooks.openCommandLine = (initial) => {
cmdGlobal = false
cmdline = initial
}
hooks.openOverview = () => void openOverview()
})
@@ -76,6 +81,24 @@
return () => window.removeEventListener('keydown', answer, true)
})
/*
* The agenda is what the bar counts from, so it is kept fresh without being
* asked: on coming back to the tab, and every few minutes while it is open
* — which is also when a new day can make it open itself.
*/
$effect(() => {
if (!workspace.user) return
const refresh = () => {
if (document.visibilityState === 'visible') void workspace.loadAgenda().then(() => workspace.openAgendaIfDue())
}
const timer = setInterval(refresh, 5 * 60 * 1000)
document.addEventListener('visibilitychange', refresh)
return () => {
clearInterval(timer)
document.removeEventListener('visibilitychange', refresh)
}
})
// Guard against losing a half-written entry to a stray refresh.
$effect(() => {
if (!workspace.dirty) return
@@ -117,6 +140,9 @@
[['1…9'], 'switch space, Tab cycles'],
[['w'], 'overview of every space'],
[['b'], "this space's board"],
[['t'], 'agenda — late, due, in hand'],
[['c'], 'capture a card to the inbox'],
[['⌃ P'], 'find in every space'],
[['/'], 'search this space'],
[['?'], 'help'],
]
@@ -186,6 +212,38 @@
return false
}
/** The agenda's keys: j/k down the list, Enter to the card, Space finishes it. */
function agendaKey(event: KeyboardEvent): boolean {
const cards = workspace.agendaCards
const card = cards[workspace.agendaCursor]
switch (event.key) {
case 'j':
case 'ArrowDown':
workspace.agendaCursor = Math.min(workspace.agendaCursor + 1, Math.max(cards.length - 1, 0))
return true
case 'k':
case 'ArrowUp':
workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)
return true
case 'Enter':
if (card) void workspace.guard(() => workspace.goToCard(card))
return true
case ' ':
if (card) void workspace.guard(() => workspace.finishCard(card))
return true
case 'T':
workspace.overlay = 'none'
void runCommand('today')
return true
case 't':
case 'q':
workspace.overlay = 'none'
return true
default:
return false
}
}
/** Alt+h / Alt+l: focus moves between the panes, out of the editor too. */
function focusPane(pane: 'list' | 'document') {
if (pane === 'document' && !workspace.open) return
@@ -298,6 +356,12 @@
return
}
}
if (workspace.overlay === 'agenda' && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (agendaKey(event)) {
event.preventDefault()
return
}
}
if (event.key === 'Escape') {
if (workspace.overlay !== 'none') {
@@ -325,6 +389,15 @@
if (cmdline !== null || isTypingTarget(event.target)) return
// Ctrl-P: find anything, in any space — the one search that is not
// confined to where you are.
if (event.ctrlKey && !event.metaKey && !event.altKey && event.key === 'p') {
event.preventDefault()
cmdGlobal = true
cmdline = '/'
return
}
// Ctrl-D / Ctrl-U page the open document, as they do in a pager.
if (event.ctrlKey && !event.metaKey && !event.altKey && (event.key === 'd' || event.key === 'u')) {
if (workspace.open && !workspace.editing) {
@@ -349,6 +422,18 @@
workspace.fullscreen = !workspace.fullscreen
return
}
// The agenda and today's entry are a key away from anywhere, the board
// included: they are the two questions asked most.
if (key === 't') {
event.preventDefault()
void runCommand('agenda')
return
}
if (key === 'T') {
event.preventDefault()
void runCommand('today')
return
}
if (workspace.view === 'board') {
if (previous === 'g' && key === 'g') {
@@ -371,7 +456,10 @@
}
if (key === ':' || key === '?') {
event.preventDefault()
if (key === ':') cmdline = ':'
if (key === ':') {
cmdGlobal = false
cmdline = ':'
}
else void runCommand('help')
}
return
@@ -497,6 +585,7 @@
break
case '/':
event.preventDefault()
cmdGlobal = false
cmdline = '/'
break
case ':':
@@ -581,7 +670,7 @@
<StatusBar />
{#if cmdline !== null}
<CommandLine initial={cmdline} close={() => (cmdline = null)} />
<CommandLine initial={cmdline} global={cmdGlobal} close={() => ((cmdline = null), (cmdGlobal = false))} />
{/if}
<Prompt />
@@ -590,7 +679,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
>
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
+180
View File
@@ -0,0 +1,180 @@
<script lang="ts">
/*
* `t`: what needs you, from every board at once — what is late, what is due
* today and this week, what is already in hand — and whether the diary has
* had today yet. The keys live in App.svelte with the others; this draws
* and lets a pointer do the same.
*/
import type { AgendaCard } from './api'
import { workspace } from './store.svelte'
import { formatDay, formatWeekday } from './util'
let sheet = $state<HTMLDivElement>()
const titles = { overdue: 'late', today: 'today', week: 'this week', doing: 'in hand' } as const
const groups = $derived(workspace.agendaGroups.filter((group) => group.cards.length > 0))
const total = $derived(workspace.agendaCards.length)
$effect(() => {
const index = workspace.agendaCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
/** Where a card sits in the walked order, across the groups. */
function indexOf(card: AgendaCard) {
return workspace.agendaCards.findIndex((c) => c.id === card.id)
}
</script>
<div class="sheet" bind:this={sheet}>
<header>
<span class="label rule"><span class="marker"></span><span class="title">agenda</span> {total} open</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
{#if workspace.agenda.diary_today === false}
<p class="diary">
<span class="dot"></span> nothing in the diary today
<span class="kbd">T</span> writes it
</p>
{/if}
{#each groups as group (group.key)}
<section class={group.key}>
<h2>{titles[group.key]} <span class="n">{group.cards.length}</span></h2>
<ul>
{#each group.cards as card (card.id)}
{@const index = indexOf(card)}
<li>
<button
class="row"
class:at={index === workspace.agendaCursor}
data-index={index}
onclick={() => workspace.guard(() => workspace.goToCard(card))}
onpointerenter={() => (workspace.agendaCursor = index)}
>
<span class="text">{card.title}</span>
<span class="where">{card.space_slug}/{card.list_name}</span>
{#if card.due_at !== null}
<span class="due">{formatWeekday(card.due_at)} {formatDay(card.due_at).slice(5)}</span>
{/if}
</button>
</li>
{/each}
</ul>
</section>
{:else}
<p class="clear">nothing late, nothing due this week, nothing in hand. <span class="kbd">c</span> captures the next thing.</p>
{/each}
<footer class="label">
<span class="kbd">j</span><span class="kbd">k</span> move
<span class="gap"></span>
<span class="kbd">↵</span> to its board
<span class="gap"></span>
<span class="kbd">␣</span> done
<span class="gap"></span>
<span class="kbd">T</span> diary
<span class="gap"></span>
<span class="kbd">t</span><span class="kbd">esc</span> close
</footer>
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
width: min(100%, 84ch);
max-height: 100%;
overflow-y: auto;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.diary {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 8px 12px;
border: 1px solid var(--rim);
border-radius: var(--radius-m);
font-size: 13px;
color: var(--fg);
}
.diary .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--light); }
section { min-width: 0; }
h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
font-size: 10px;
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
h2::after { content: ''; height: 1px; flex: 1; background: var(--overlay); }
.overdue h2 { color: var(--alarm); }
.today h2 { color: var(--light); }
.n { font-variant-numeric: tabular-nums; letter-spacing: 0; }
ul { list-style: none; margin: 0; padding: 0; }
.row {
display: flex;
align-items: baseline;
gap: 12px;
width: 100%;
margin: 1px 0;
padding: 6px 10px;
border: 0;
border-radius: var(--radius-s);
text-align: left;
font-size: 13px;
letter-spacing: 0;
text-transform: none;
color: var(--fg);
}
.row:hover { background: var(--surface-alt); border-color: transparent; }
.row:active { transform: none; }
.row.at { background: var(--light); color: var(--ink); }
.row.at :is(.where, .due) { color: var(--ink); }
.text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-sans);
font-size: 14px;
}
.where, .due { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.overdue .due { color: var(--alarm); }
.today .due { color: var(--light); }
.clear { margin: 0; font-size: 13px; color: var(--subtle); }
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
footer .kbd { margin-right: 2px; }
footer .gap { width: 14px; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
@media (max-width: 767px) {
.sheet { padding: 14px; gap: 14px; }
.where { display: none; }
}
</style>
+30 -5
View File
@@ -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)
</script>
@@ -62,10 +63,19 @@
<span class="spacer"></span>
<span class="readout user">
<span class="key">user</span>
<span class="value">{workspace.user}</span>
</span>
<!-- What is late or due today, always in sight: the point is not having to
remember to look. Quiet when there is nothing. -->
{#if due.late + due.today > 0}
<button class="readout due" title="agenda (t)" onclick={() => runCommand('agenda')}>
{#if due.late > 0}<span class="key">late</span><span class="value late">{due.late}</span>{/if}
{#if due.today > 0}<span class="key">today</span><span class="value now">{due.today}</span>{/if}
</button>
{:else}
<span class="readout user">
<span class="key">user</span>
<span class="value">{workspace.user}</span>
</span>
{/if}
<span class="divider wide"></span>
<Clock />
</header>
@@ -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; }
+7 -3
View File
@@ -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<HTMLInputElement>()
@@ -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}
<form onsubmit={submit}>
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.space?.slug ?? '')}</span>{/if}
<input bind:this={input} bind:value {onkeydown} spellcheck="false" autocomplete="off" />
</form>
</div>
@@ -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;
+3
View File
@@ -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'],
+5 -1
View File
@@ -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() },
{
+2
View File
@@ -36,6 +36,7 @@
{#if workspace.query}
<div class="filter">
<span class="muted">/</span><span class="query">{workspace.query}</span>
{#if workspace.globalSearch}<span class="muted scope">all spaces</span>{/if}
<button title="clear search (n)" onclick={() => workspace.guard(() => workspace.search(''))}>esc</button>
</div>
{/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 {
+17
View File
@@ -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>('/agenda'),
/** The board of a space. Fails for a space that has none, such as the diary. */
board: (spaceId: number) => request<Board>(`/spaces/${spaceId}/board`),
+24 -3
View File
@@ -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',
+130 -3
View File
@@ -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<Flash>(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<Agenda>({ 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<boolean> {
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<boolean> {
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 <name>`, 'error')
const card = await api.createCard(inbox.id, { title, node_id: nodeId })
await this.loadAgenda()
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
}