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
+682
-36
No files matched your search
@@ -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
@@ -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))
|
||||
|
||||
+93
-4
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+26
-1
@@ -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>
|
||||
|
||||
<!-- 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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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() },
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user