2026-08-17 00:03:20 +02:00
|
|
|
/**
|
|
|
|
|
* Application state.
|
|
|
|
|
*
|
|
|
|
|
* Svelte 5 runes rather than stores: the task list is read by half the components
|
|
|
|
|
* in the app, and `$state` gives fine-grained reactivity without every one of them
|
|
|
|
|
* subscribing and unsubscribing by hand.
|
|
|
|
|
*
|
|
|
|
|
* The rule this file exists to enforce: **every local action is applied
|
|
|
|
|
* immediately and reconciled afterwards**. Ticking a checkbox must not wait for a
|
|
|
|
|
* round trip. When the server disagrees, the change is rolled back and the person
|
|
|
|
|
* is told — which is rare, and much better than a spinner on every click.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { api, ApiError, humanMessage } from './api.js';
|
2026-08-18 11:18:01 +02:00
|
|
|
import { i18n, t } from './i18n.svelte.js';
|
2026-08-17 00:03:20 +02:00
|
|
|
|
|
|
|
|
class AppState {
|
|
|
|
|
user = $state(null);
|
|
|
|
|
projects = $state([]);
|
2026-08-17 19:25:10 +02:00
|
|
|
/** The foldable headings over the projects in the sidebar. */
|
|
|
|
|
groups = $state([]);
|
2026-08-17 22:31:43 +02:00
|
|
|
/**
|
|
|
|
|
* Everybody you share a project with, and yourself.
|
|
|
|
|
*
|
|
|
|
|
* Loaded once so a task row can put a name on an `assignee_id` without a
|
|
|
|
|
* request per row — and without every list having to know which project each
|
|
|
|
|
* task belongs to in order to ask for its members.
|
|
|
|
|
*/
|
|
|
|
|
people = $state([]);
|
2026-08-17 00:03:20 +02:00
|
|
|
tasks = $state([]);
|
|
|
|
|
loading = $state(true);
|
2026-09-17 12:23:39 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* True when the boot never reached the server — as opposed to reaching it and
|
|
|
|
|
* being told the session is gone.
|
|
|
|
|
*
|
|
|
|
|
* The two are indistinguishable from `user === null`, and they are opposite
|
|
|
|
|
* things to the person looking at the screen: one means "log ind", the other
|
|
|
|
|
* means "der er intet at logge ind på herfra, og et kodeord hjælper ikke". A
|
|
|
|
|
* total outage used to show the login screen, so the app blamed the user's
|
|
|
|
|
* session for the network being down — and a login page that cannot possibly
|
|
|
|
|
* work is worse than an error, because it looks like it might.
|
|
|
|
|
*/
|
|
|
|
|
unreachable = $state(false);
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
/** Transient messages: a failed save, a rolled-back change. */
|
|
|
|
|
toasts = $state([]);
|
|
|
|
|
connected = $state(false);
|
|
|
|
|
|
2026-08-17 10:39:34 +02:00
|
|
|
/**
|
|
|
|
|
* The task whose detail drawer is open, as an id rather than a copy — so an
|
|
|
|
|
* edit made here, or arriving over the socket from somebody else, is reflected
|
|
|
|
|
* in the drawer without a second source of truth to keep in step.
|
|
|
|
|
*/
|
|
|
|
|
detailId = $state(null);
|
|
|
|
|
|
2026-08-17 17:12:53 +02:00
|
|
|
/**
|
|
|
|
|
* Bumped whenever a label changes anywhere.
|
|
|
|
|
*
|
|
|
|
|
* A counter rather than the labels themselves: the sidebar wants each label's
|
|
|
|
|
* task count, which no single event carries and which changes whenever a task
|
|
|
|
|
* gains or loses one. Whoever is showing labels re-reads them; that is one
|
|
|
|
|
* small request against modelling a count in two places.
|
|
|
|
|
*/
|
|
|
|
|
labelsChanged = $state(0);
|
2026-08-19 20:46:05 +02:00
|
|
|
/** Bumped when a note moves, so a list showing them knows to look again. */
|
|
|
|
|
notesChanged = $state(0);
|
2026-08-17 17:12:53 +02:00
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
#socket = null;
|
|
|
|
|
#reconnectDelay = 1000;
|
|
|
|
|
|
|
|
|
|
get inbox() {
|
|
|
|
|
return this.projects.find((p) => p.is_inbox);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
projectById(id) {
|
|
|
|
|
return this.projects.find((p) => p.id === id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 22:31:43 +02:00
|
|
|
personById(id) {
|
|
|
|
|
return this.people.find((p) => p.id === id) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The person a task is waiting on, or null when it is nobody or yourself.
|
|
|
|
|
*
|
|
|
|
|
* Yourself is nobody, on purpose: every row in your own list would otherwise
|
|
|
|
|
* carry your own face, which is a column of the same initial telling you
|
|
|
|
|
* nothing. The point of showing an assignee is that it is *not* you.
|
|
|
|
|
*/
|
|
|
|
|
assigneeOf(task) {
|
|
|
|
|
const id = task?.assignee_id;
|
|
|
|
|
if (!id || id === this.user?.id) return null;
|
|
|
|
|
return this.personById(id) ?? { id, name: 'Ukendt', avatar_color: '#8a8f98' };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:39:34 +02:00
|
|
|
get detailTask() {
|
|
|
|
|
return this.tasks.find((t) => t.id === this.detailId) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
openDetail(id) {
|
|
|
|
|
this.detailId = id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
closeDetail() {
|
|
|
|
|
this.detailId = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
async load() {
|
|
|
|
|
this.loading = true;
|
|
|
|
|
try {
|
|
|
|
|
this.user = await api.me();
|
2026-09-17 12:23:39 +02:00
|
|
|
this.unreachable = false;
|
2026-08-18 09:46:08 +02:00
|
|
|
// The interface's language follows the account, so it is set the moment
|
|
|
|
|
// the session is known — before anything has drawn a string.
|
|
|
|
|
i18n.set(this.user.locale);
|
2026-08-17 22:31:43 +02:00
|
|
|
const [{ projects }, { groups }, { people }] = await Promise.all([
|
2026-08-17 19:25:10 +02:00
|
|
|
api.listProjects(),
|
2026-08-17 22:31:43 +02:00
|
|
|
api.listProjectGroups(),
|
|
|
|
|
api.people()
|
2026-08-17 19:25:10 +02:00
|
|
|
]);
|
2026-08-17 00:03:20 +02:00
|
|
|
this.projects = projects;
|
2026-08-17 19:25:10 +02:00
|
|
|
this.groups = groups;
|
2026-08-17 22:31:43 +02:00
|
|
|
this.people = people;
|
2026-08-17 00:03:20 +02:00
|
|
|
this.connect();
|
2026-09-08 11:40:43 +02:00
|
|
|
this.loadNotifications();
|
2026-08-17 00:03:20 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
this.user = null;
|
2026-09-17 12:23:39 +02:00
|
|
|
// `offline` is the one code the request layer makes up itself: it is what
|
|
|
|
|
// is thrown when the retry window ran out without any status ever
|
|
|
|
|
// arriving. A 401 is the server answering, and that is a real sign-out.
|
|
|
|
|
this.unreachable = e?.code === 'offline';
|
2026-08-17 00:03:20 +02:00
|
|
|
} finally {
|
|
|
|
|
this.loading = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- realtime ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
|
if (this.#socket) return;
|
|
|
|
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
|
|
|
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
|
|
|
|
|
this.#socket = socket;
|
|
|
|
|
|
|
|
|
|
socket.onopen = () => {
|
|
|
|
|
this.connected = true;
|
|
|
|
|
this.#reconnectDelay = 1000;
|
|
|
|
|
};
|
|
|
|
|
socket.onmessage = (event) => {
|
|
|
|
|
try {
|
|
|
|
|
this.#applyRemote(JSON.parse(event.data));
|
|
|
|
|
} catch {
|
|
|
|
|
// A message this build does not understand is not worth a crash.
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
socket.onclose = () => {
|
|
|
|
|
this.connected = false;
|
|
|
|
|
this.#socket = null;
|
|
|
|
|
// Backing off matters: a server that is restarting should not be met
|
|
|
|
|
// with a reconnect every 50ms from every open tab.
|
|
|
|
|
setTimeout(() => this.connect(), this.#reconnectDelay);
|
|
|
|
|
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
|
|
|
|
|
};
|
|
|
|
|
socket.onerror = () => socket.close();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 11:40:43 +02:00
|
|
|
// --- klokken -----------------------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// Beskederne ligger i butikken frem for i komponenten, fordi de kommer to veje:
|
|
|
|
|
// hentet, når man lander, og skubbet over websocket'en, mens man sidder der. En
|
|
|
|
|
// klokke, der kun kendte den første, ville stå på nul, indtil man genindlæste —
|
|
|
|
|
// og en besked, man skal genindlæse for at se, er ikke en besked.
|
|
|
|
|
|
|
|
|
|
notifications = $state([]);
|
|
|
|
|
unread = $state(0);
|
|
|
|
|
|
|
|
|
|
async loadNotifications() {
|
|
|
|
|
try {
|
|
|
|
|
const r = await api.listNotifications();
|
|
|
|
|
this.notifications = r.notifications ?? [];
|
|
|
|
|
this.unread = r.unread ?? 0;
|
|
|
|
|
} catch {
|
|
|
|
|
// En klokke, der ikke kunne hentes, er ikke værd at afbryde nogen for.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Marks one read, or all of them when no id is given. */
|
|
|
|
|
async markRead(id) {
|
|
|
|
|
const previous = this.notifications;
|
|
|
|
|
const wasUnread = this.unread;
|
|
|
|
|
this.notifications = this.notifications.map((n) =>
|
|
|
|
|
!id || n.id === id ? { ...n, read: true } : n
|
|
|
|
|
);
|
|
|
|
|
this.unread = this.notifications.filter((n) => !n.read).length;
|
|
|
|
|
try {
|
|
|
|
|
await api.markNotificationsRead(id);
|
|
|
|
|
} catch {
|
|
|
|
|
this.notifications = previous;
|
|
|
|
|
this.unread = wasUnread;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
/** Applies a change made by somebody else. */
|
|
|
|
|
#applyRemote(event) {
|
|
|
|
|
const task = event.payload;
|
|
|
|
|
switch (event.type) {
|
2026-09-08 11:40:43 +02:00
|
|
|
// Beskeder kommer på personens egen kanal, ikke projektets: en note delt
|
|
|
|
|
// med én person hører ikke til noget projekt, og der er intet rum at
|
|
|
|
|
// udsende den i.
|
|
|
|
|
case 'notification':
|
|
|
|
|
this.notifications = [
|
|
|
|
|
{ ...event.payload, read: false },
|
|
|
|
|
...this.notifications.filter((n) => n.id !== event.payload?.id)
|
|
|
|
|
].slice(0, 30);
|
|
|
|
|
this.unread = this.notifications.filter((n) => !n.read).length;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// En rettelse, der er lagt oven i en besked, man allerede har. Tallet er
|
|
|
|
|
// uændret; linjen er en anden. Hentet frem for flettet, fordi serveren
|
|
|
|
|
// har det rigtige svar og listen er tredive rækker.
|
|
|
|
|
case 'notification.folded':
|
|
|
|
|
this.loadNotifications();
|
|
|
|
|
break;
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
case 'task.created':
|
|
|
|
|
case 'task.updated':
|
|
|
|
|
case 'task.completed':
|
|
|
|
|
case 'task.reopened':
|
2026-08-17 10:39:34 +02:00
|
|
|
case 'task.moved':
|
|
|
|
|
this.upsert(task);
|
2026-08-17 00:03:20 +02:00
|
|
|
break;
|
|
|
|
|
case 'task.deleted':
|
|
|
|
|
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
|
2026-08-19 12:28:43 +02:00
|
|
|
this.refreshCounts();
|
2026-08-17 00:03:20 +02:00
|
|
|
break;
|
2026-08-17 17:12:53 +02:00
|
|
|
|
|
|
|
|
// Projects and labels belong to a person rather than to a project, so
|
|
|
|
|
// they arrive on the user's own channel — a project that has just been
|
|
|
|
|
// created has no subscribers, because nobody was watching a thing that
|
|
|
|
|
// did not exist a moment ago.
|
|
|
|
|
case 'project.created':
|
|
|
|
|
case 'project.updated':
|
|
|
|
|
this.upsertProject(task);
|
|
|
|
|
break;
|
|
|
|
|
case 'project.deleted':
|
|
|
|
|
this.projects = this.projects.filter((p) => p.id !== task?.id);
|
|
|
|
|
break;
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
case 'project_group.created':
|
|
|
|
|
case 'project_group.updated':
|
|
|
|
|
this.upsertGroup(task);
|
|
|
|
|
break;
|
|
|
|
|
case 'project_group.deleted':
|
|
|
|
|
// The projects filed under it are not deleted with it; they come back
|
|
|
|
|
// out as ungrouped, which the sidebar works out from the group being
|
|
|
|
|
// gone rather than from a second event per project.
|
|
|
|
|
this.groups = this.groups.filter((g) => g.id !== task?.id);
|
|
|
|
|
break;
|
|
|
|
|
|
2026-08-17 17:12:53 +02:00
|
|
|
// The payload is not always the label — a rename says only that
|
|
|
|
|
// something changed — and the sidebar wants counts anyway, which no
|
|
|
|
|
// single event carries. Asking is cheaper than modelling it.
|
|
|
|
|
case 'label.changed':
|
|
|
|
|
this.labelsChanged++;
|
|
|
|
|
break;
|
2026-08-17 00:03:20 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- tasks ------------------------------------------------------------------
|
|
|
|
|
|
2026-08-18 09:49:58 +02:00
|
|
|
/**
|
|
|
|
|
* Loads a slice of tasks into the store.
|
|
|
|
|
*
|
|
|
|
|
* Closed ones come along when the view is showing them — asked for here rather
|
|
|
|
|
* than at every call site, so a view added later cannot forget and quietly
|
|
|
|
|
* ignore the setting. A caller that genuinely wants only what is open passes
|
|
|
|
|
* `completed: 'exclude'`.
|
|
|
|
|
*/
|
2026-08-17 00:03:20 +02:00
|
|
|
async loadTasks(params) {
|
2026-08-18 09:49:58 +02:00
|
|
|
const { tasks } = await api.listTasks({
|
|
|
|
|
completed: completedView.shown ? 'include' : undefined,
|
|
|
|
|
...params
|
|
|
|
|
});
|
2026-08-17 00:03:20 +02:00
|
|
|
this.tasks = tasks;
|
|
|
|
|
return tasks;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:39:34 +02:00
|
|
|
/**
|
|
|
|
|
* Swaps one task for a new version of it. **The single place a task in the list
|
|
|
|
|
* is changed** — the optimistic updates below, the reconciliation after them,
|
|
|
|
|
* the rollbacks, the websocket, and the drag handlers in BoardView and TaskList
|
|
|
|
|
* all come through here.
|
|
|
|
|
*
|
|
|
|
|
* It builds a new array rather than writing `tasks[i] = next`. In-place index
|
|
|
|
|
* assignment did not reach the views: a ticked-off task kept its row, and a
|
|
|
|
|
* change arriving over the socket did not show at all, while `push` and whole
|
|
|
|
|
* -array assignment both worked. Rather than depend on which mutations the
|
|
|
|
|
* reactivity happens to see through, every write here produces a new array,
|
|
|
|
|
* which it certainly does see. The lists are at most a few hundred rows; the
|
|
|
|
|
* copy is not worth a moment's thought next to the class of bug it removes.
|
|
|
|
|
*/
|
|
|
|
|
replace(id, next) {
|
2026-08-17 17:12:53 +02:00
|
|
|
// A task can invent a label just by mentioning one — "@regnskab" in quick
|
|
|
|
|
// add creates it — and no label event is sent for that, because it happened
|
|
|
|
|
// inside a task write. Comparing the two lists is what keeps the sidebar
|
|
|
|
|
// honest without re-reading the labels on every completed checkbox.
|
|
|
|
|
const before = this.tasks.find((t) => t.id === id)?.labels ?? [];
|
|
|
|
|
if ((next?.labels ?? []).join() !== before.join()) this.labelsChanged++;
|
|
|
|
|
|
2026-08-17 10:39:34 +02:00
|
|
|
this.tasks = this.tasks.map((t) => (t.id === id ? next : t));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Adds a task, or replaces it if the list already has it.
|
|
|
|
|
*
|
|
|
|
|
* Adding is never unconditional, because the same task arrives twice: once as
|
|
|
|
|
* the response to the request that created it, and once over the socket, which
|
|
|
|
|
* publishes to the whole project including whoever did it. Either can land
|
|
|
|
|
* first. Appending both puts two rows with one id into an `{#each}` keyed by
|
|
|
|
|
* id, which is not a duplicated row but a thrown error that stops the view
|
|
|
|
|
* rendering.
|
|
|
|
|
*/
|
|
|
|
|
upsert(task) {
|
|
|
|
|
if (!task?.id) return;
|
2026-08-19 12:28:43 +02:00
|
|
|
// Before the branch, not after it. A task that is already in the list can
|
|
|
|
|
// still change the counts — moved to another project, finished in another
|
|
|
|
|
// tab, a recurring one that closed and opened itself — and the branch below
|
|
|
|
|
// returns early for exactly those.
|
|
|
|
|
this.refreshCounts();
|
2026-08-17 17:12:53 +02:00
|
|
|
if (this.tasks.some((t) => t.id === task.id)) {
|
|
|
|
|
this.replace(task.id, task);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (task.labels?.length) this.labelsChanged++;
|
|
|
|
|
this.tasks = [...this.tasks, task];
|
2026-08-17 10:39:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get(id) {
|
|
|
|
|
return this.tasks.find((t) => t.id === id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 17:12:53 +02:00
|
|
|
/**
|
|
|
|
|
* Adds a project, or replaces it if the list already has it.
|
|
|
|
|
*
|
|
|
|
|
* The same guard the tasks need, and for the same reason: a project arrives
|
|
|
|
|
* twice, once as the response to the request that created it and once over
|
|
|
|
|
* the socket. Appending both puts two rows with one id into a keyed `{#each}`,
|
|
|
|
|
* which throws and stops the sidebar rendering.
|
|
|
|
|
*/
|
|
|
|
|
upsertProject(project) {
|
|
|
|
|
if (!project?.id) return;
|
|
|
|
|
this.projects = this.projects.some((p) => p.id === project.id)
|
|
|
|
|
? this.projects.map((p) => (p.id === project.id ? project : p))
|
|
|
|
|
: [...this.projects, project];
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
/**
|
|
|
|
|
* Ticks a task off. The row is struck through and gone before the request
|
|
|
|
|
* leaves — which is the single most-repeated action in the app, and the one
|
|
|
|
|
* place latency would be felt all day.
|
|
|
|
|
*/
|
2026-08-18 11:18:01 +02:00
|
|
|
/**
|
|
|
|
|
* Closes a task, and offers to put it back.
|
|
|
|
|
*
|
|
|
|
|
* Completing is one click on a small circle next to a row you were only
|
|
|
|
|
* reading, and it takes the row off the screen — so the mistake and the
|
|
|
|
|
* evidence of it leave together. Somebody who clicks the wrong one is left with
|
|
|
|
|
* no name to search for and no list to find it in.
|
|
|
|
|
*
|
|
|
|
|
* The undo is offered where the mistake happened rather than filed somewhere to
|
|
|
|
|
* be looked up. It is the same call `reopen` makes; the only new thing is that
|
|
|
|
|
* the interface says it is available.
|
|
|
|
|
*
|
|
|
|
|
* A recurring task is deliberately not offered one. Completing it moves it to
|
|
|
|
|
* its next date rather than closing it, so there is nothing to undo — and an
|
|
|
|
|
* "undo" that silently rolled the date back would be a different act from the
|
|
|
|
|
* one it claims to reverse.
|
|
|
|
|
*/
|
2026-08-17 00:03:20 +02:00
|
|
|
async complete(id) {
|
2026-08-18 11:18:01 +02:00
|
|
|
const task = this.get(id);
|
|
|
|
|
const repeats = Boolean(task?.recurrence_rule);
|
|
|
|
|
const what = task?.content ?? '';
|
|
|
|
|
|
2026-08-17 10:39:34 +02:00
|
|
|
await this.#optimistic(id, { completed: true }, () => api.completeTask(id));
|
2026-08-19 12:28:43 +02:00
|
|
|
this.refreshCounts();
|
2026-08-18 11:18:01 +02:00
|
|
|
|
|
|
|
|
if (!repeats) {
|
|
|
|
|
this.toast(t('task.completedUndo', { what: truncate(what) }), {
|
|
|
|
|
action: t('task.undo'),
|
|
|
|
|
onaction: () => this.reopen(id)
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-08-17 00:03:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async reopen(id) {
|
2026-08-17 10:39:34 +02:00
|
|
|
await this.#optimistic(id, { completed: false }, () => api.reopenTask(id));
|
2026-08-19 12:28:43 +02:00
|
|
|
this.refreshCounts();
|
2026-08-17 00:03:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async update(id, patch) {
|
2026-08-17 10:39:34 +02:00
|
|
|
await this.#optimistic(id, patch, () => api.updateTask(id, patch));
|
|
|
|
|
}
|
2026-08-17 00:03:20 +02:00
|
|
|
|
2026-08-17 10:39:34 +02:00
|
|
|
/**
|
|
|
|
|
* Applies `patch` at once, then reconciles with whatever the server says.
|
|
|
|
|
*
|
|
|
|
|
* The rollback restores the version captured before the change rather than
|
|
|
|
|
* inverting the patch: the server may have declined for a reason that has
|
|
|
|
|
* nothing to do with the fields sent, and putting back what was there is the
|
|
|
|
|
* only correction that is right in every case.
|
|
|
|
|
*/
|
|
|
|
|
async #optimistic(id, patch, request) {
|
|
|
|
|
const previous = this.get(id);
|
|
|
|
|
if (!previous) return;
|
|
|
|
|
|
|
|
|
|
this.replace(id, { ...previous, ...patch });
|
2026-08-17 00:03:20 +02:00
|
|
|
try {
|
2026-08-17 10:39:34 +02:00
|
|
|
this.replace(id, await request());
|
2026-08-17 00:03:20 +02:00
|
|
|
} catch (e) {
|
2026-08-17 10:39:34 +02:00
|
|
|
this.replace(id, previous);
|
2026-08-17 00:03:20 +02:00
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/**
|
|
|
|
|
* Moves a task into another project, which is what dropping it on the sidebar
|
|
|
|
|
* means.
|
|
|
|
|
*
|
|
|
|
|
* Through `move` rather than a `project_id` on an update: `sort_order` belongs
|
|
|
|
|
* to the project, so a task arriving in a new one needs a place among its
|
|
|
|
|
* tasks. The section goes too — sections belong to the project it is leaving,
|
|
|
|
|
* and carrying the id across would file it under a heading that is not there.
|
|
|
|
|
*/
|
|
|
|
|
async moveToProject(id, projectId) {
|
|
|
|
|
const previous = this.get(id);
|
|
|
|
|
if (!previous || previous.project_id === projectId) return;
|
|
|
|
|
|
|
|
|
|
this.replace(id, { ...previous, project_id: projectId, section_id: '' });
|
|
|
|
|
try {
|
|
|
|
|
this.replace(id, await api.moveTask(id, { project_id: projectId, section_id: '' }));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.replace(id, previous);
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */
|
2026-08-20 20:29:03 +02:00
|
|
|
/**
|
|
|
|
|
* Flytter en opgave til en dag — og afgør, hvad der sker med klokkeslættet.
|
|
|
|
|
*
|
|
|
|
|
* Serveren læser `due_date` og `due_time` sammen: kommer datoen uden en tid,
|
|
|
|
|
* *ryddes* tiden. Det er den rigtige regel for et felt, man skriver i, og den
|
|
|
|
|
* forkerte for et træk i en kalender — en opgave, der stod klokken 14, mistede
|
|
|
|
|
* sit klokkeslæt ved at blive trukket til dagen efter, uden at nogen bad om
|
|
|
|
|
* det, og uden at noget sagde det.
|
|
|
|
|
*
|
|
|
|
|
* Så tiden siges nu højt, tre måder:
|
|
|
|
|
*
|
|
|
|
|
* time === undefined behold den, opgaven har
|
|
|
|
|
* time === '' ryd den — det er heldagsbåndet
|
|
|
|
|
* time === 'HH:MM' sæt den — det er et sted på døgnet
|
|
|
|
|
*
|
|
|
|
|
* `undefined` og `''` er med vilje to forskellige ting her. De ligner hinanden
|
|
|
|
|
* i JavaScript, og det er præcis derfor de skal skilles ad ét sted frem for at
|
|
|
|
|
* blive forvekslet fire steder.
|
|
|
|
|
*/
|
|
|
|
|
async reschedule(id, date, time) {
|
2026-08-17 19:25:10 +02:00
|
|
|
const previous = this.get(id);
|
2026-08-20 20:29:03 +02:00
|
|
|
if (!previous) return;
|
|
|
|
|
|
|
|
|
|
const clock =
|
|
|
|
|
time === undefined ? (previous.due_datetime ? clockOf(previous.due_datetime) : '') : time;
|
|
|
|
|
if (previous.due_date === date && (previous.due_datetime ? clockOf(previous.due_datetime) : '') === clock) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await this.update(id, { due_date: date, due_time: clock });
|
2026-08-17 19:25:10 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 10:14:39 +02:00
|
|
|
/**
|
|
|
|
|
* Sets how long a task is, in minutes. Nothing else about it moves — the date
|
|
|
|
|
* and time are left alone, so making a task longer on the calendar does not
|
|
|
|
|
* also reschedule it.
|
|
|
|
|
*/
|
2026-08-25 10:59:34 +02:00
|
|
|
/**
|
|
|
|
|
* Snoozes a task until an ISO time, or wakes it when `until` is empty. Nothing
|
|
|
|
|
* else about the task moves — a snooze is about when you want to see it, not when
|
|
|
|
|
* it is due. Optimistic, so it greys and sinks at once.
|
|
|
|
|
*/
|
|
|
|
|
async snooze(id, until) {
|
|
|
|
|
await this.#optimistic(
|
|
|
|
|
id,
|
|
|
|
|
{ snoozed_until: until || undefined },
|
|
|
|
|
() => api.snoozeTask(id, until || '')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 10:14:39 +02:00
|
|
|
async resize(id, minutes) {
|
|
|
|
|
const previous = this.get(id);
|
|
|
|
|
if (!previous) return;
|
|
|
|
|
const rounded = Math.max(15, Math.round(minutes / 15) * 15);
|
|
|
|
|
if (previous.duration_min === rounded) return;
|
|
|
|
|
await this.update(id, { duration_min: rounded });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
async remove(id) {
|
|
|
|
|
const previous = [...this.tasks];
|
|
|
|
|
this.tasks = this.tasks.filter((t) => t.id !== id);
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteTask(id);
|
2026-08-19 12:28:43 +02:00
|
|
|
this.refreshCounts();
|
2026-08-17 00:03:20 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
this.tasks = previous;
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Adds a task from the quick-add box.
|
|
|
|
|
*
|
|
|
|
|
* This one is *not* fully optimistic: the parse that turns one line into a
|
|
|
|
|
* date, a project and a priority happens on the server, so inventing a row
|
|
|
|
|
* here would show a task that visibly rearranges itself a moment later. The
|
|
|
|
|
* request is fast and the box clears immediately, which is where the
|
|
|
|
|
* responsiveness actually needs to be.
|
|
|
|
|
*/
|
|
|
|
|
async quickAdd(text, projectId) {
|
|
|
|
|
try {
|
|
|
|
|
const task = await api.quickAdd(text, projectId);
|
2026-08-17 10:39:34 +02:00
|
|
|
this.upsert(task);
|
2026-08-17 00:03:20 +02:00
|
|
|
return task;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- projects ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async createProject(name) {
|
|
|
|
|
try {
|
|
|
|
|
const project = await api.createProject({ name });
|
2026-08-17 17:12:53 +02:00
|
|
|
this.upsertProject(project);
|
2026-08-17 00:03:20 +02:00
|
|
|
return project;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 20:46:05 +02:00
|
|
|
/** Files a note under a project, which is what sharing it means. */
|
|
|
|
|
async moveNoteToProject(noteID, projectID) {
|
|
|
|
|
try {
|
|
|
|
|
await api.updateNote(noteID, { project_id: projectID });
|
|
|
|
|
this.notesChanged++;
|
|
|
|
|
this.toast(t('notes.filed'));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
async refreshProjects() {
|
|
|
|
|
const { projects } = await api.listProjects();
|
|
|
|
|
this.projects = projects;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 12:28:43 +02:00
|
|
|
/**
|
|
|
|
|
* Brings the sidebar's per-project counts back in line after tasks have moved.
|
|
|
|
|
*
|
|
|
|
|
* The count is open tasks now, which changes on every add, finish and delete —
|
|
|
|
|
* where the old one, how many people are in it, changed about twice a year. It
|
|
|
|
|
* would be quicker to add and subtract locally, and it would be wrong within a
|
|
|
|
|
* day: a task moved between projects, one finished in another tab, a recurring
|
|
|
|
|
* one that closed and reopened itself. Asking the server is one request and
|
|
|
|
|
* cannot drift.
|
|
|
|
|
*
|
|
|
|
|
* Coalesced, because a paste of five tasks is five calls in the same tick and
|
|
|
|
|
* the answer to all of them is the same.
|
|
|
|
|
*/
|
|
|
|
|
#countsRunning = false;
|
|
|
|
|
#countsAgain = false;
|
|
|
|
|
#countsTimer = null;
|
|
|
|
|
|
|
|
|
|
refreshCounts() {
|
|
|
|
|
// Two windows to coalesce over, not one. Collapsing calls that arrive within
|
|
|
|
|
// a tick is the easy half; the half that matters is a change arriving while
|
|
|
|
|
// the request is already in flight. Dropping those left the sidebar showing
|
|
|
|
|
// the count as it was one task ago, which is the state the test caught and
|
|
|
|
|
// which nothing but a reload would have corrected.
|
|
|
|
|
if (this.#countsRunning) {
|
|
|
|
|
this.#countsAgain = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
clearTimeout(this.#countsTimer);
|
|
|
|
|
this.#countsTimer = setTimeout(async () => {
|
|
|
|
|
this.#countsRunning = true;
|
|
|
|
|
try {
|
|
|
|
|
const { projects } = await api.listProjects();
|
|
|
|
|
const counts = new Map(projects.map((p) => [p.id, p.open_count]));
|
|
|
|
|
this.projects = this.projects.map((p) =>
|
|
|
|
|
counts.has(p.id) ? { ...p, open_count: counts.get(p.id) } : p
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
// The number is a convenience; failing to update it must not put an
|
|
|
|
|
// error in front of somebody who was only ticking something off.
|
|
|
|
|
} finally {
|
|
|
|
|
this.#countsRunning = false;
|
|
|
|
|
}
|
|
|
|
|
if (this.#countsAgain) {
|
|
|
|
|
this.#countsAgain = false;
|
|
|
|
|
this.refreshCounts();
|
|
|
|
|
}
|
|
|
|
|
}, 120);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 15:49:52 +02:00
|
|
|
/**
|
|
|
|
|
* Puts the projects in the given order.
|
|
|
|
|
*
|
|
|
|
|
* The whole list rather than one moved item: it is a handful of rows, and
|
|
|
|
|
* sending the order you want cannot land in the half-applied state a sequence
|
|
|
|
|
* of individual moves can. `sort_order` is set here as well as on the server,
|
|
|
|
|
* so the sidebar settles immediately rather than after a round trip.
|
|
|
|
|
*/
|
|
|
|
|
async reorderProjects(ids) {
|
|
|
|
|
const previous = this.projects;
|
|
|
|
|
const rank = new Map(ids.map((id, i) => [id, i]));
|
|
|
|
|
|
|
|
|
|
this.projects = this.projects.map((p) =>
|
|
|
|
|
rank.has(p.id) ? { ...p, sort_order: rank.get(p.id) } : p
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
await api.reorderProjects(ids);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.projects = previous;
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
// --- project groups -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/** The same new-array upsert the projects need, and for the same reason. */
|
|
|
|
|
upsertGroup(group) {
|
|
|
|
|
if (!group?.id) return;
|
|
|
|
|
this.groups = this.groups.some((g) => g.id === group.id)
|
|
|
|
|
? this.groups.map((g) => (g.id === group.id ? group : g))
|
|
|
|
|
: [...this.groups, group];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async createGroup(name) {
|
|
|
|
|
try {
|
|
|
|
|
const group = await api.createProjectGroup(name);
|
|
|
|
|
this.upsertGroup(group);
|
|
|
|
|
return group;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Folds or unfolds a group.
|
|
|
|
|
*
|
|
|
|
|
* Applied first and saved after, like everything else here — a fold that waits
|
|
|
|
|
* for a round trip feels like a click that did not land. The rollback matters
|
|
|
|
|
* more than usual: this is stored on the account rather than in the browser, so
|
|
|
|
|
* a failed save that left the arrow turned would be a lie that survives a
|
|
|
|
|
* reload.
|
|
|
|
|
*/
|
|
|
|
|
async toggleGroup(id) {
|
|
|
|
|
const group = this.groups.find((g) => g.id === id);
|
|
|
|
|
if (!group) return;
|
|
|
|
|
await this.#patchGroup(id, { collapsed: !group.collapsed });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 09:04:30 +02:00
|
|
|
/**
|
|
|
|
|
* 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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 17:19:46 +02:00
|
|
|
/**
|
|
|
|
|
* The order of the fixed views, as keys.
|
|
|
|
|
*
|
|
|
|
|
* Unknown keys are dropped and missing ones appended, so the stored order never
|
|
|
|
|
* has to be migrated when a view is added or taken away — which has already
|
|
|
|
|
* happened once, when Færdige left the sidebar.
|
|
|
|
|
*/
|
|
|
|
|
navOrder(keys) {
|
|
|
|
|
const saved = this.user?.nav_order ?? [];
|
|
|
|
|
const known = new Set(keys);
|
|
|
|
|
const ordered = saved.filter((k) => known.has(k));
|
|
|
|
|
return [...ordered, ...keys.filter((k) => !ordered.includes(k))];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async setNavOrder(order) {
|
|
|
|
|
const before = this.user?.nav_order ?? [];
|
|
|
|
|
this.user = { ...this.user, nav_order: order };
|
|
|
|
|
try {
|
|
|
|
|
await api.setNavOrder(order);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.user = { ...this.user, nav_order: before };
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 09:04:30 +02:00
|
|
|
sectionCollapsed(key) {
|
|
|
|
|
return (this.user?.sidebar_collapsed ?? []).includes(key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
async renameGroup(id, name) {
|
|
|
|
|
await this.#patchGroup(id, { name });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 21:50:20 +02:00
|
|
|
async setGroupColor(id, color) {
|
|
|
|
|
await this.#patchGroup(id, { color });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
async #patchGroup(id, patch) {
|
|
|
|
|
const previous = this.groups.find((g) => g.id === id);
|
|
|
|
|
if (!previous) return;
|
|
|
|
|
|
|
|
|
|
this.upsertGroup({ ...previous, ...patch });
|
|
|
|
|
try {
|
|
|
|
|
this.upsertGroup(await api.updateProjectGroup(id, patch));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.upsertGroup(previous);
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Deletes the heading. The projects come back out as ungrouped, which is done
|
|
|
|
|
* here too rather than waiting for a re-read: the sidebar reads `group_id` to
|
|
|
|
|
* decide where a project sits, and a project pointing at a group that is gone
|
|
|
|
|
* would otherwise vanish from the list entirely until the next load.
|
|
|
|
|
*/
|
|
|
|
|
async deleteGroup(id) {
|
|
|
|
|
const previousGroups = this.groups;
|
|
|
|
|
const previousProjects = this.projects;
|
|
|
|
|
|
|
|
|
|
this.groups = this.groups.filter((g) => g.id !== id);
|
|
|
|
|
this.projects = this.projects.map((p) => (p.group_id === id ? { ...p, group_id: '' } : p));
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteProjectGroup(id);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.groups = previousGroups;
|
|
|
|
|
this.projects = previousProjects;
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async reorderGroups(ids) {
|
|
|
|
|
const previous = this.groups;
|
|
|
|
|
const rank = new Map(ids.map((id, i) => [id, i]));
|
|
|
|
|
|
|
|
|
|
this.groups = this.groups.map((g) =>
|
|
|
|
|
rank.has(g.id) ? { ...g, sort_order: rank.get(g.id) } : g
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
await api.reorderProjectGroups(ids);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.groups = previous;
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 21:50:20 +02:00
|
|
|
/** Marks a project with one of the palette's colours. */
|
|
|
|
|
async setProjectColor(projectId, color) {
|
|
|
|
|
const previous = this.projects.find((p) => p.id === projectId);
|
|
|
|
|
if (!previous || previous.color === color) return;
|
|
|
|
|
|
|
|
|
|
this.upsertProject({ ...previous, color });
|
|
|
|
|
try {
|
|
|
|
|
this.upsertProject(await api.updateProject(projectId, { color }));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.upsertProject(previous);
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/** Files a project under a group, or takes it out of one when groupId is ''. */
|
|
|
|
|
async setProjectGroup(projectId, groupId) {
|
|
|
|
|
const previous = this.projects.find((p) => p.id === projectId);
|
|
|
|
|
// `?? ''` because the server omits the field when there is no group, so
|
|
|
|
|
// "none" arrives as undefined and is compared against the '' that means the
|
|
|
|
|
// same thing.
|
|
|
|
|
if (!previous || (previous.group_id ?? '') === groupId) return;
|
|
|
|
|
|
|
|
|
|
this.upsertProject({ ...previous, group_id: groupId });
|
|
|
|
|
try {
|
|
|
|
|
this.upsertProject(await api.updateProject(projectId, { group_id: groupId }));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.upsertProject(previous);
|
|
|
|
|
this.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
// --- toasts -------------------------------------------------------------------
|
|
|
|
|
|
2026-08-18 11:18:01 +02:00
|
|
|
/**
|
|
|
|
|
* Says something, and optionally offers one thing to do about it.
|
|
|
|
|
*
|
|
|
|
|
* An action gets longer on screen than a plain message: five seconds is enough
|
|
|
|
|
* to read "could not save", and not enough to notice you closed the wrong task,
|
|
|
|
|
* decide, and reach the mouse.
|
|
|
|
|
*/
|
|
|
|
|
toast(message, { action, onaction } = {}) {
|
2026-08-17 00:03:20 +02:00
|
|
|
const id = Math.random().toString(36).slice(2);
|
2026-08-18 11:18:01 +02:00
|
|
|
this.toasts.push({ id, message, action, onaction });
|
|
|
|
|
setTimeout(
|
|
|
|
|
() => {
|
|
|
|
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
|
|
|
|
},
|
|
|
|
|
action ? 12000 : 5000
|
|
|
|
|
);
|
2026-08-17 00:03:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dismissToast(id) {
|
|
|
|
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 12:02:56 +02:00
|
|
|
/**
|
|
|
|
|
* What to call a project on screen.
|
|
|
|
|
*
|
|
|
|
|
* Almost always its own name — somebody wrote it and it is theirs. The Inbox is the
|
|
|
|
|
* exception: the server names it at account creation, before anybody has seen a
|
|
|
|
|
* settings page, so it is the one project name nobody chose. Leaving it as stored
|
|
|
|
|
* meant an account created in Danish said "Indbakke" in an English interface, which
|
|
|
|
|
* reads as a string somebody forgot to translate. It was.
|
|
|
|
|
*
|
|
|
|
|
* Only while it still carries a name the server gave it. Rename it and that is your
|
|
|
|
|
* name for it, in every language — the same rule as any other project.
|
|
|
|
|
*/
|
2026-08-20 20:29:03 +02:00
|
|
|
/**
|
|
|
|
|
* Klokkeslættet på en opgave, som "HH:MM" i den maskine, der ser på det.
|
|
|
|
|
*
|
|
|
|
|
* `Date` og ikke en strengudskæring: `due_datetime` kommer som UTC med et Z på,
|
|
|
|
|
* og de fjorten på et stempel er ikke klokken fjorten for den, der læser det.
|
|
|
|
|
* TaskRow viser tiden på nøjagtig samme måde, og de to steder skal vise det
|
|
|
|
|
* samme tal.
|
|
|
|
|
*/
|
|
|
|
|
export function clockOf(stamp) {
|
|
|
|
|
const at = new Date(stamp);
|
|
|
|
|
if (Number.isNaN(at.getTime())) return '';
|
|
|
|
|
return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 12:02:56 +02:00
|
|
|
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
|
|
|
|
|
|
|
|
|
|
export function projectName(project) {
|
|
|
|
|
if (!project) return '';
|
|
|
|
|
if (project.is_inbox && SYSTEM_INBOX_NAMES.has(project.name.trim().toLowerCase())) {
|
|
|
|
|
return t('nav.inbox');
|
|
|
|
|
}
|
|
|
|
|
return project.name;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 11:18:01 +02:00
|
|
|
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
|
|
|
|
function truncate(text, max = 40) {
|
|
|
|
|
const trimmed = text.trim();
|
|
|
|
|
return trimmed.length > max ? trimmed.slice(0, max - 1).trimEnd() + '…' : trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
export const app = new AppState();
|
|
|
|
|
|
2026-08-17 16:06:33 +02:00
|
|
|
/**
|
|
|
|
|
* How wide the sidebar is, and whether it is showing at all.
|
|
|
|
|
*
|
|
|
|
|
* Kept in localStorage rather than on the account: it is a property of the
|
|
|
|
|
* screen you are sitting at, not of you. The same person on a laptop and on a
|
|
|
|
|
* wide monitor wants different answers, and syncing it would make one of those
|
|
|
|
|
* two wrong every time they switch.
|
|
|
|
|
*/
|
|
|
|
|
const SIDEBAR_MIN = 200;
|
|
|
|
|
const SIDEBAR_MAX = 480;
|
|
|
|
|
const SIDEBAR_DEFAULT = 268;
|
|
|
|
|
|
|
|
|
|
function storedNumber(key, fallback) {
|
|
|
|
|
if (typeof localStorage === 'undefined') return fallback;
|
|
|
|
|
const raw = Number(localStorage.getItem(key));
|
|
|
|
|
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class SidebarLayout {
|
|
|
|
|
width = $state(storedNumber('verdande:sidebar-width', SIDEBAR_DEFAULT));
|
|
|
|
|
collapsed = $state(
|
|
|
|
|
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:sidebar-collapsed') === '1'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setWidth(px) {
|
|
|
|
|
// Clamped here rather than in the drag handler, so every caller gets the
|
|
|
|
|
// same rule and a stored value from an older build cannot make the
|
|
|
|
|
// sidebar unusably narrow.
|
|
|
|
|
this.width = Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px)));
|
|
|
|
|
this.#save('verdande:sidebar-width', String(this.width));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggle() {
|
|
|
|
|
this.collapsed = !this.collapsed;
|
|
|
|
|
this.#save('verdande:sidebar-collapsed', this.collapsed ? '1' : '0');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reset() {
|
|
|
|
|
this.setWidth(SIDEBAR_DEFAULT);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#save(key, value) {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(key, value);
|
|
|
|
|
} catch {
|
|
|
|
|
// Private browsing; the choice simply will not persist.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const sidebar = new SidebarLayout();
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/**
|
2026-09-07 14:46:49 +02:00
|
|
|
* Whether Kommende shows a run of days as a list, one week as a grid, or the whole
|
|
|
|
|
* month.
|
2026-08-17 19:25:10 +02:00
|
|
|
*
|
|
|
|
|
* In localStorage rather than on the account, and for the same reason as the
|
2026-08-18 09:04:30 +02:00
|
|
|
* 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.
|
2026-08-17 19:25:10 +02:00
|
|
|
*/
|
2026-08-18 09:04:30 +02:00
|
|
|
const UPCOMING_MODES = ['list', 'week', 'calendar'];
|
|
|
|
|
|
2026-09-07 14:46:49 +02:00
|
|
|
/**
|
|
|
|
|
* How far the list looks ahead, in days.
|
|
|
|
|
*
|
|
|
|
|
* Seven days is a week's horizon and answers "what is coming"; thirty answers
|
|
|
|
|
* "when does this month get busy", which is the question a month grid answers by
|
|
|
|
|
* shape but not in order. The list is the only mode that takes a horizon — the
|
|
|
|
|
* grids are anchored to a week and a month, and a fortnight is neither.
|
|
|
|
|
*
|
|
|
|
|
* The server caps the window at 31 days, so nothing here may exceed that.
|
|
|
|
|
*/
|
|
|
|
|
export const UPCOMING_DAYS = [7, 14, 30];
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
class UpcomingView {
|
2026-09-07 14:46:49 +02:00
|
|
|
mode = $state(readChoice('verdande:upcoming', UPCOMING_MODES, 'list'));
|
|
|
|
|
days = $state(readChoice('verdande:upcoming-days', UPCOMING_DAYS, 7, Number));
|
2026-08-17 19:25:10 +02:00
|
|
|
|
|
|
|
|
set(next) {
|
2026-08-18 09:04:30 +02:00
|
|
|
if (!UPCOMING_MODES.includes(next)) return;
|
2026-08-17 19:25:10 +02:00
|
|
|
this.mode = next;
|
2026-09-07 14:46:49 +02:00
|
|
|
writeChoice('verdande:upcoming', next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setDays(next) {
|
|
|
|
|
const n = Number(next);
|
|
|
|
|
if (!UPCOMING_DAYS.includes(n)) return;
|
|
|
|
|
this.days = n;
|
|
|
|
|
writeChoice('verdande:upcoming-days', n);
|
2026-08-17 19:25:10 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 14:46:49 +02:00
|
|
|
function readChoice(key, allowed, fallback, parse = String) {
|
|
|
|
|
if (typeof localStorage === 'undefined') return fallback;
|
|
|
|
|
const stored = parse(localStorage.getItem(key));
|
|
|
|
|
return allowed.includes(stored) ? stored : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeChoice(key, value) {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(key, String(value));
|
|
|
|
|
} catch {
|
|
|
|
|
// Private browsing; the choice simply will not persist.
|
|
|
|
|
}
|
2026-08-18 09:04:30 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
export const upcomingView = new UpcomingView();
|
|
|
|
|
|
2026-08-20 07:22:25 +02:00
|
|
|
/**
|
|
|
|
|
* Month or week on Kalender.
|
|
|
|
|
*
|
|
|
|
|
* Its own key rather than Kommende's, even though the two switchers look alike.
|
|
|
|
|
* They are not the same choice: Kommende opens on a list of the next seven days
|
|
|
|
|
* and offers a grid, and Kalender is the grid — sharing the value would mean
|
|
|
|
|
* choosing a week on one and finding the other had changed, and one of them would
|
|
|
|
|
* have to answer for the `list` mode the other does not have.
|
|
|
|
|
*
|
|
|
|
|
* In localStorage rather than on the account, and for the same reason as the
|
|
|
|
|
* sidebar's width: a grid of seven columns needs room, and the answer on a phone
|
|
|
|
|
* is not the answer on a wide monitor.
|
|
|
|
|
*/
|
|
|
|
|
const CALENDAR_MODES = ['week', 'month'];
|
|
|
|
|
|
|
|
|
|
class CalendarViewMode {
|
2026-08-20 21:45:38 +02:00
|
|
|
mode = $state('month');
|
|
|
|
|
|
|
|
|
|
// Nøglen som argument frem for som felt på en underklasse.
|
|
|
|
|
//
|
|
|
|
|
// Den vej blev prøvet og virkede ikke: en underklasses felter sættes *efter*
|
|
|
|
|
// basens konstruktør er kørt, så en læsning i konstruktøren brugte basens nøgle
|
|
|
|
|
// og skrivningen bagefter underklassens. Valget blev gemt ét sted og læst et
|
|
|
|
|
// andet, og en uge, man havde valgt, var en måned igen efter en genindlæsning.
|
|
|
|
|
// Et argument har ingen rækkefølge at tage fejl af.
|
|
|
|
|
constructor(storageKey = 'verdande:calendar') {
|
|
|
|
|
this.storageKey = storageKey;
|
|
|
|
|
this.mode = readCalendarMode(storageKey);
|
|
|
|
|
}
|
2026-08-20 07:22:25 +02:00
|
|
|
|
|
|
|
|
set(next) {
|
|
|
|
|
if (!CALENDAR_MODES.includes(next)) return;
|
|
|
|
|
this.mode = next;
|
|
|
|
|
try {
|
2026-08-20 21:45:38 +02:00
|
|
|
localStorage.setItem(this.storageKey, next);
|
2026-08-20 07:22:25 +02:00
|
|
|
} catch {
|
|
|
|
|
// Private browsing; the choice simply will not persist.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 21:45:38 +02:00
|
|
|
function readCalendarMode(key) {
|
2026-08-20 07:22:25 +02:00
|
|
|
if (typeof localStorage === 'undefined') return 'month';
|
2026-08-20 21:45:38 +02:00
|
|
|
const stored = localStorage.getItem(key);
|
2026-08-20 07:22:25 +02:00
|
|
|
return CALENDAR_MODES.includes(stored) ? stored : 'month';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const calendarView = new CalendarViewMode();
|
|
|
|
|
|
2026-08-20 21:45:38 +02:00
|
|
|
/**
|
|
|
|
|
* Måned eller uge på et projekts kalender.
|
|
|
|
|
*
|
|
|
|
|
* Sin egen nøgle, af samme grund som Kalenders er sin egen: det er tre steder med
|
|
|
|
|
* hver sit spørgsmål. Kommende åbner på en liste over de næste syv dage, Kalender
|
|
|
|
|
* *er* gitteret, og et projekts kalender er en del af en side, der også kan være
|
|
|
|
|
* en liste og et board. At dele værdien ville betyde, at man valgte en uge ét sted
|
|
|
|
|
* og fandt et andet ændret.
|
|
|
|
|
*
|
|
|
|
|
* Og ikke på projektet. `view_mode` er projektets — den er delt med alle, der kan
|
|
|
|
|
* se det — mens uge eller måned er et spørgsmål om, hvor bred skærmen er, foran
|
|
|
|
|
* hvilken der sidder én person. Det er den samme grund, som sidebjælkens bredde
|
|
|
|
|
* ligger i localStorage af.
|
|
|
|
|
*/
|
|
|
|
|
export const projectCalendarView = new CalendarViewMode('verdande:project-calendar');
|
|
|
|
|
|
2026-08-18 09:49:58 +02:00
|
|
|
/**
|
|
|
|
|
* Whether the lists show what has already been finished.
|
|
|
|
|
*
|
|
|
|
|
* In localStorage rather than on the account, like the sidebar's width and unlike
|
|
|
|
|
* a folded group. It is a way of *looking* rather than a statement about the work:
|
|
|
|
|
* somebody reviewing what they got done this week wants it on for ten minutes, and
|
|
|
|
|
* a setting that followed them to every device would be a setting they had to turn
|
|
|
|
|
* off twice.
|
|
|
|
|
*
|
|
|
|
|
* Off by default, and deliberately. A task list is what is left to do; a list that
|
|
|
|
|
* opens full of things already done is a list that has to be read past before it
|
|
|
|
|
* can be used.
|
|
|
|
|
*/
|
|
|
|
|
class CompletedView {
|
|
|
|
|
shown = $state(
|
|
|
|
|
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:completed') === 'show'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
toggle() {
|
|
|
|
|
this.shown = !this.shown;
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem('verdande:completed', this.shown ? 'show' : 'hide');
|
|
|
|
|
} catch {
|
|
|
|
|
// Private browsing; the choice simply will not persist.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const completedView = new CompletedView();
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 17:21:57 +02:00
|
|
|
/**
|
|
|
|
|
* The themes on offer.
|
|
|
|
|
*
|
|
|
|
|
* `dark` is the tokens on bare :root; the rest are [data-theme] blocks in
|
|
|
|
|
* app.css. The list lives here so the picker and the stylesheet cannot drift —
|
|
|
|
|
* an entry with no CSS behind it renders as the default and looks like a bug in
|
|
|
|
|
* the picker rather than a missing block.
|
|
|
|
|
*/
|
|
|
|
|
export const THEMES = [
|
2026-08-18 09:46:08 +02:00
|
|
|
{ id: 'dark', name: 'theme.dark', note: 'theme.darkNote', dark: true },
|
2026-09-03 16:27:55 +02:00
|
|
|
{ id: 'charcoal', name: 'theme.charcoal', note: 'theme.charcoalNote', dark: true },
|
2026-08-18 09:46:08 +02:00
|
|
|
{ id: 'dusk', name: 'theme.dusk', note: 'theme.duskNote', dark: true },
|
|
|
|
|
{ id: 'light', name: 'theme.light', note: 'theme.lightNote', dark: false },
|
|
|
|
|
{ id: 'paper', name: 'theme.paper', note: 'theme.paperNote', dark: false },
|
|
|
|
|
{ id: 'contrast', name: 'theme.contrast', note: 'theme.contrastNote', dark: false }
|
2026-08-17 17:21:57 +02:00
|
|
|
];
|
|
|
|
|
|
2026-08-19 17:07:39 +02:00
|
|
|
/**
|
|
|
|
|
* The looks: how it reads, as opposed to how bright it is.
|
|
|
|
|
*
|
|
|
|
|
* Separate from the themes because they are separate questions. Somebody who
|
|
|
|
|
* wants a serif face at night should not have to give up the dark palette to get
|
|
|
|
|
* one, and the two multiply out to fifteen appearances from eight small blocks of
|
|
|
|
|
* CSS rather than needing one block each.
|
|
|
|
|
*/
|
|
|
|
|
export const LOOKS = [
|
|
|
|
|
{ id: 'verdande', name: 'look.verdande', note: 'look.verdandeNote' },
|
|
|
|
|
{ id: 'rolig', name: 'look.rolig', note: 'look.roligNote' },
|
|
|
|
|
{ id: 'taet', name: 'look.taet', note: 'look.taetNote' },
|
|
|
|
|
{ id: 'terminal', name: 'look.terminal', note: 'look.terminalNote' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
class Look {
|
|
|
|
|
current = $state(
|
|
|
|
|
typeof document === 'undefined'
|
|
|
|
|
? 'verdande'
|
|
|
|
|
: (document.documentElement.dataset.look ?? 'verdande')
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
set(id) {
|
|
|
|
|
if (!LOOKS.some((l) => l.id === id)) return;
|
|
|
|
|
this.current = id;
|
|
|
|
|
// The default carries no attribute, so the plain tokens are the plain tokens
|
|
|
|
|
// and there is nothing to override them back to.
|
|
|
|
|
if (id === 'verdande') delete document.documentElement.dataset.look;
|
|
|
|
|
else document.documentElement.dataset.look = id;
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem('verdande:look', id);
|
|
|
|
|
} catch {
|
|
|
|
|
// Private browsing; the choice simply will not persist.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const look = new Look();
|
|
|
|
|
|
2026-09-02 15:30:30 +02:00
|
|
|
/**
|
|
|
|
|
* The font-size steps, shared by the menu and the body-text controls. `default`
|
|
|
|
|
* carries no attribute, so the base tokens in app.css stand unchanged.
|
|
|
|
|
*/
|
|
|
|
|
export const SIZES = [
|
|
|
|
|
{ id: 'small', name: 'size.small' },
|
|
|
|
|
{ id: 'default', name: 'size.default' },
|
|
|
|
|
{ id: 'large', name: 'size.large' },
|
|
|
|
|
{ id: 'xl', name: 'size.xl' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A font-size preference — the same shape as Look: kept in localStorage and
|
|
|
|
|
* mirrored onto <html>, where app.html sets it before first paint so a chosen size
|
|
|
|
|
* does not flash in at the default and resize. Two instances below, because the
|
|
|
|
|
* menu and the body text are separate questions.
|
|
|
|
|
*/
|
|
|
|
|
class Sizing {
|
|
|
|
|
current = $state('default');
|
|
|
|
|
#key;
|
|
|
|
|
#attr;
|
|
|
|
|
|
|
|
|
|
constructor(key, attr) {
|
|
|
|
|
this.#key = key;
|
|
|
|
|
this.#attr = attr;
|
|
|
|
|
if (typeof document !== 'undefined') {
|
|
|
|
|
this.current = document.documentElement.dataset[attr] ?? 'default';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set(id) {
|
|
|
|
|
if (!SIZES.some((s) => s.id === id)) return;
|
|
|
|
|
this.current = id;
|
|
|
|
|
if (id === 'default') delete document.documentElement.dataset[this.#attr];
|
|
|
|
|
else document.documentElement.dataset[this.#attr] = id;
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(this.#key, id);
|
|
|
|
|
} catch {
|
|
|
|
|
// Private browsing; the choice simply will not persist.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const menuSize = new Sizing('verdande:menu-size', 'menuSize');
|
|
|
|
|
export const textSize = new Sizing('verdande:text-size', 'textSize');
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
/** Theme, kept in localStorage and applied to the document element. */
|
2026-08-17 17:21:57 +02:00
|
|
|
class Theme {
|
|
|
|
|
/**
|
|
|
|
|
* Mirrors the attribute on <html>, which app.html sets before first paint —
|
|
|
|
|
* that is the only way to avoid a white flash on load, and it means the
|
|
|
|
|
* document, not this object, is where the truth lives.
|
|
|
|
|
*/
|
|
|
|
|
current = $state(
|
|
|
|
|
typeof document === 'undefined' ? 'dark' : (document.documentElement.dataset.theme ?? 'dark')
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
set(id) {
|
|
|
|
|
if (!THEMES.some((t) => t.id === id)) return;
|
|
|
|
|
this.current = id;
|
|
|
|
|
document.documentElement.dataset.theme = id;
|
|
|
|
|
|
|
|
|
|
// The browser paints its own chrome from this — the address bar on a
|
|
|
|
|
// phone, the frame of an installed PWA. Left alone it stays the colour of
|
|
|
|
|
// whichever theme shipped in the markup.
|
|
|
|
|
const ground = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim();
|
|
|
|
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', ground);
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
try {
|
2026-08-17 17:21:57 +02:00
|
|
|
localStorage.setItem('verdande:theme', id);
|
2026-08-17 00:03:20 +02:00
|
|
|
} catch {
|
|
|
|
|
// Private browsing; the choice simply will not persist.
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 17:21:57 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The topbar button, which flips between light and dark rather than walking
|
|
|
|
|
* all five. Somebody who has chosen Skumring wants the light one to be Papir,
|
|
|
|
|
* not to be marched back to the default — so it moves to the other side of
|
|
|
|
|
* the list and stays warm or stays cool.
|
|
|
|
|
*/
|
|
|
|
|
toggle() {
|
|
|
|
|
const now = THEMES.find((t) => t.id === this.current) ?? THEMES[0];
|
|
|
|
|
const opposite = THEMES.filter((t) => t.dark !== now.dark);
|
|
|
|
|
const index = THEMES.filter((t) => t.dark === now.dark).indexOf(now);
|
|
|
|
|
this.set((opposite[index] ?? opposite[0]).id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const theme = new Theme();
|
2026-08-17 00:03:20 +02:00
|
|
|
|
|
|
|
|
export { ApiError, humanMessage };
|