agenda: today is the workspace's day, not the browser's

The browser grouped the agenda by its own midnight and remembered that it had
opened it by its own date, while the server counts due dates, mails and the
diary in `WORKSPACE_TIMEZONE`. A laptop on a trip, or a browser set to UTC,
showed a card as late that the morning mail called due today, and opened the
agenda a second time on one workspace day or not at all. A day was also taken
to be 86400 seconds, which it is not when the clocks change.

The agenda answer now carries `today`: the date in the configured timezone,
when it starts and ends, and when the seventh day after it ends, all from the
same `day_bounds` the mails use. The browser groups by those and keys the
once-a-day opening on that date. The screenshot run marks the agenda as seen
under the same date.
This commit is contained in:
nvrl committed 2026-10-21 12:24:37 +02:00
1 parent a1f6f6025f
commit 0e0d21506e
7 files changed
+77 -13

No files matched your search

+3 -1
View File
@@ -302,6 +302,8 @@ the agenda kept up so `:repeat` acts on the card, `H` / `L` send it to
the list left / right on its board, and `T` writes today's entry. The bar shows `late` and `today` counts the list left / right on its board, and `T` writes today's entry. The bar shows `late` and `today` counts
whenever either is above zero, and the agenda opens by itself once a day when 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. something is late or due, so nothing has to be remembered to be looked at.
The day is the one in `WORKSPACE_TIMEZONE`, as for the mails, so a browser on a
trip groups the cards the same way.
Below the cards it lists what the diary holds from this day in earlier years; Below the cards it lists what the diary holds from this day in earlier years;
`j` walks on into them and `Enter` opens one. A 29 February is remembered on `j` walks on into them and `Enter` opens one. A 29 February is remembered on
the 28th in the years that have none. the 28th in the years that have none.
@@ -846,7 +848,7 @@ sealed or not.
| `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good | | `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good |
| `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone | | `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone |
| `GET` | `/api/spaces/{id}/board` | the lists and cards of one space | | `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, whether the diary has today, and its entries `on_this_day` in earlier years | | `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, whether the diary has today, its entries `on_this_day` in earlier years, and `today` in `WORKSPACE_TIMEZONE`, with its bounds |
| `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` | | `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` |
| `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date | | `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date |
| `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in | | `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in |
+1 -1
View File
@@ -20,7 +20,7 @@ sealed or not.
| `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good | | `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good |
| `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone | | `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone |
| `GET` | `/api/spaces/{id}/board` | the lists and cards of one space | | `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, whether the diary has today, and its entries `on_this_day` in earlier years | | `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, whether the diary has today, its entries `on_this_day` in earlier years, and `today` in `WORKSPACE_TIMEZONE`, with its bounds |
| `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` | | `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` |
| `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date | | `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date |
| `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in | | `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in |
+3 -1
View File
@@ -107,7 +107,9 @@ does not make a second copy. The board and the agenda show the rule as
| `T` | today's diary entry | | `T` | today's diary entry |
| `t`, `q`, `Esc` | close | | `t`, `q`, `Esc` | close |
The agenda opens by itself once a day when something is late or due. The bar The agenda opens by itself once a day when something is late or due. "Today"
is the day in `WORKSPACE_TIMEZONE`, the same as for the mails, so a browser in
another timezone groups the cards the same way. The bar
shows the `late` and `today` counts whenever either is above zero. With mail shows the `late` and `today` counts whenever either is above zero. With mail
set up, cards due today are mailed in the morning too; see set up, cards due today are mailed in the morning too; see
[Backup and mail](Backup-and-Mail.md#mail). [Backup and mail](Backup-and-Mail.md#mail).
+1 -1
View File
@@ -356,7 +356,7 @@ await send('Page.enable')
await send('Network.enable') await send('Network.enable')
// The agenda opens itself once a day; here it opens when asked. // The agenda opens itself once a day; here it opens when asked.
await send('Page.addScriptToEvaluateOnNewDocument', { await send('Page.addScriptToEvaluateOnNewDocument', {
source: `localStorage.setItem('diary:agenda-seen', new Date().toDateString())`, source: `localStorage.setItem('diary:agenda-seen', new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date()))`,
}) })
await viewport(1440, 900) await viewport(1440, 900)
+56
View File
@@ -157,6 +157,21 @@ pub struct Agenda {
pub diary_today: Option<bool>, pub diary_today: Option<bool>,
/// Diary entries from this day in earlier years, the latest year first. /// Diary entries from this day in earlier years, the latest year first.
pub on_this_day: Vec<crate::routes::diary::Entry>, pub on_this_day: Vec<crate::routes::diary::Entry>,
/// Today in the configured timezone, which is the day the cards are due
/// on and the mails are sent by — not the browser's, which may be
/// somewhere else, and not 24 hours long when the clocks change.
pub today: AgendaDay,
}
#[derive(Serialize)]
pub struct AgendaDay {
/// `2026-10-21`.
pub date: String,
/// When today starts and ends, in Unix seconds.
pub start: i64,
pub end: i64,
/// The end of the seventh day after today: what is due before it is this week.
pub week_end: i64,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -189,10 +204,19 @@ pub async fn agenda(_: Session, State(state): State<AppState>) -> AppResult<Json
}; };
let on_this_day = crate::routes::diary::on_this_day(&state.db, tz, crate::now()).await?; let on_this_day = crate::routes::diary::on_this_day(&state.db, tz, crate::now()).await?;
let day = crate::notify::local_day(tz, crate::now());
let (start, end) = crate::notify::day_bounds(tz, day);
let (_, week_end) = crate::notify::day_bounds(tz, day + chrono::Duration::days(AGENDA_DAYS));
Ok(Json(Agenda { Ok(Json(Agenda {
cards, cards,
diary_today, diary_today,
on_this_day, on_this_day,
today: AgendaDay {
date: day.format("%Y-%m-%d").to_string(),
start,
end,
week_end,
},
})) }))
} }
@@ -1405,4 +1429,36 @@ mod tests {
let ids = [1, 2, 3]; let ids = [1, 2, 3];
assert_eq!(reordered(&ids, 2, 1), vec![1, 2, 3]); assert_eq!(reordered(&ids, 2, 1), vec![1, 2, 3]);
} }
/// The agenda says which day it counts as today, in the workspace's
/// timezone, with the bounds the browser groups the cards by.
#[tokio::test]
async fn the_agenda_says_what_today_is() {
let state = crate::routes::tests::state_in(".".into()).await;
let agenda = super::agenda(
crate::auth::Session { id: "test".into() },
axum::extract::State(state.clone()),
)
.await
.unwrap()
.0;
let tz = state.config.mail.timezone;
let now = crate::now();
let day = &agenda.today;
assert_eq!(
day.date,
crate::notify::local_day(tz, now)
.format("%Y-%m-%d")
.to_string()
);
assert!(
day.start <= now && now < day.end,
"{} {} {}",
day.start,
now,
day.end
);
// Seven more local days, each 23 to 25 hours long.
assert!((day.week_end - day.end - 7 * 86400).abs() <= 3600);
}
} }
+2
View File
@@ -194,6 +194,8 @@ export interface Agenda {
diary_today: boolean | null diary_today: boolean | null
/** Diary entries from this day in earlier years, the latest year first. */ /** Diary entries from this day in earlier years, the latest year first. */
on_this_day: DiaryEntry[] on_this_day: DiaryEntry[]
/** Today in the workspace's timezone, which is what due dates are counted in. */
today: { date: string; start: number; end: number; week_end: number } | null
} }
/** A diary entry, by the local day it is about. */ /** A diary entry, by the local day it is about. */
+11 -9
View File
@@ -299,7 +299,7 @@ class Workspace {
* What is due or in hand on every board, kept loaded all the time: the bar * 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. * counts from it, so what is late is in sight without going to look.
*/ */
agenda = $state<Agenda>({ cards: [], diary_today: null, on_this_day: [] }) agenda = $state<Agenda>({ cards: [], diary_today: null, on_this_day: [], today: null })
agendaCursor = $state(0) agendaCursor = $state(0)
/** `C`: the day the calendar's cursor is on, `2026-10-20`, and its month's entries. */ /** `C`: the day the calendar's cursor is on, `2026-10-20`, and its month's entries. */
@@ -386,16 +386,18 @@ class Workspace {
return this.list?.cards[this.cardCursor] return this.list?.cards[this.cardCursor]
} }
/** The agenda in the order it is drawn and walked, bucketed by urgency. */ /**
* The agenda in the order it is drawn and walked, bucketed by urgency. The
* day is the server's, in the workspace's timezone, so a browser on a trip
* groups the cards the way the morning mail does.
*/
get agendaGroups(): { key: 'overdue' | 'today' | 'week' | 'doing'; cards: AgendaCard[] }[] { get agendaGroups(): { key: 'overdue' | 'today' | 'week' | 'doing'; cards: AgendaCard[] }[] {
const midnight = new Date() const { start, end, week_end } = this.agenda.today ?? { start: 0, end: 0, week_end: 0 }
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[] } const groups = { overdue: [] as AgendaCard[], today: [] as AgendaCard[], week: [] as AgendaCard[], doing: [] as AgendaCard[] }
for (const card of this.agenda.cards) { for (const card of this.agenda.cards) {
if (card.due_at !== null && card.due_at < start) groups.overdue.push(card) 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 < end) groups.today.push(card)
else if (card.due_at !== null && card.due_at < start + 8 * 86400) groups.week.push(card) else if (card.due_at !== null && card.due_at < week_end) groups.week.push(card)
else if (card.role === 'doing') groups.doing.push(card) else if (card.role === 'doing') groups.doing.push(card)
} }
return (['overdue', 'today', 'week', 'doing'] as const).map((key) => ({ key, cards: groups[key] })) return (['overdue', 'today', 'week', 'doing'] as const).map((key) => ({ key, cards: groups[key] }))
@@ -1306,8 +1308,8 @@ class Workspace {
* point is to be told, not to have to remember to ask. * point is to be told, not to have to remember to ask.
*/ */
async openAgendaIfDue() { async openAgendaIfDue() {
const today = new Date().toDateString() const today = this.agenda.today?.date
if (localStorage.getItem('diary:agenda-seen') === today) return if (!today || localStorage.getItem('diary:agenda-seen') === today) return
const { late, today: due } = this.dueCounts const { late, today: due } = this.dueCounts
if (late + due === 0) return if (late + due === 0) return
localStorage.setItem('diary:agenda-seen', today) localStorage.setItem('diary:agenda-seen', today)