feat(upcoming): a week at a time, so a drag can cross a month boundary

A month grid is anchored to a month, so the two days on either side of its edge
sit in different grids — and you cannot page mid-drag, because a drag in flight
swallows the click that would page. A week that straddles the 31st and the 1st
has both days in the same row.

The same component as the month, with `span="week"`, rather than a second one:
the part that is easy to get almost right is the same in both. A drop target has
to decide from `dataTransfer.types` alone whether it accepts, and two copies of
that are two places to be almost right.

Seven cells instead of forty-two, so the height a month spends on six rows goes
into one and the cells hold a real list rather than a hint. On a phone the row
stacks and each day carries its own weekday name, because a column of bare
numbers is not a week.

`calendar` still means the month in localStorage. It is the value already stored
for everybody who chose it, and renaming it would silently reset them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:04:30 +02:00
co-authored by Claude Opus 5
parent 85f7b2488d
commit b169591209
3 changed files with 277 additions and 48 deletions
+49 -11
View File
@@ -433,6 +433,37 @@ class AppState {
await this.#patchGroup(id, { collapsed: !group.collapsed });
}
/**
* Folds or unfolds one of the sidebar's fixed headings — Projekter, Delt med
* mig, Filtre, Etiketter.
*
* Kept on the account, exactly like a project group's `collapsed`, and for the
* same reason: folding a heading says "this is the part of my work I am not in
* right now", which is true on the laptop and on the desktop both. The
* sidebar's *width* is the opposite case and stays in localStorage.
*
* Optimistic, with a rollback, for the same reason the group toggle is: waiting
* on a round trip to turn an arrow feels like a click that did not land — and
* because this is on the account, a failed save that left it turned would be a
* lie that survives a reload.
*/
async toggleSection(key) {
const before = this.user?.sidebar_collapsed ?? [];
const after = before.includes(key) ? before.filter((k) => k !== key) : [...before, key];
this.user = { ...this.user, sidebar_collapsed: after };
try {
await api.setSidebarSections(after);
} catch (e) {
this.user = { ...this.user, sidebar_collapsed: before };
this.toast(humanMessage(e));
}
}
sectionCollapsed(key) {
return (this.user?.sidebar_collapsed ?? []).includes(key);
}
async renameGroup(id, name) {
await this.#patchGroup(id, { name });
}
@@ -591,23 +622,24 @@ class SidebarLayout {
export const sidebar = new SidebarLayout();
/**
* Whether Kommende shows the next seven days as a list, or the month as a grid.
* Whether Kommende shows the next seven days as a list, one week as a grid, or the
* whole month.
*
* In localStorage rather than on the account, and for the same reason as the
* sidebar's width: a month grid of seven columns needs room, and the answer on a
* phone is not the answer on a wide monitor. A project keeps its `view_mode` on
* the row because that is a fact about the project — this is a fact about the
* screen.
* sidebar's width: a grid of seven columns needs room, and the answer on a phone is
* not the answer on a wide monitor. A project keeps its `view_mode` on the row
* because that is a fact about the project — this is a fact about the screen.
*
* `calendar` still means the month. It is the value already in people's
* localStorage, and renaming it would silently reset everybody who had chosen it.
*/
const UPCOMING_MODES = ['list', 'week', 'calendar'];
class UpcomingView {
mode = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:upcoming') === 'calendar'
? 'calendar'
: 'list'
);
mode = $state(read());
set(next) {
if (next !== 'list' && next !== 'calendar') return;
if (!UPCOMING_MODES.includes(next)) return;
this.mode = next;
try {
localStorage.setItem('verdande:upcoming', next);
@@ -617,6 +649,12 @@ class UpcomingView {
}
}
function read() {
if (typeof localStorage === 'undefined') return 'list';
const stored = localStorage.getItem('verdande:upcoming');
return UPCOMING_MODES.includes(stored) ? stored : 'list';
}
export const upcomingView = new UpcomingView();
/**