feat: project groups, a month on Kommende, and dragging things where they go
The three the handoff named as next. They are one commit because they are one mechanism: all three end in something being dragged, and a drop target cannot tell what is coming without the module they now share. Groups are foldable headings over the projects in the sidebar. A table rather than a `group_name` column on the project, because a group has to be renamable in one write, has to survive being emptied, and has to remember whether it is folded — none of which a string repeated on every project row can do. Folded state lives on the row, not in localStorage. The sidebar's *width* is a property of the screen you are sitting at, which is why that one is per browser; a folded group is a statement about the group — "this is the part of my work I am not in right now" — and it is true on the laptop and on the desktop. There is nobody else's answer to disagree with either: a group belongs to exactly one person. A shared project cannot be filed. `group_id` is a column on the project row, so a member grouping somebody else's project would move it in the owner's sidebar too — the same reason `sort_order` is owner-scoped. The listing reports the column only to the owner, so a viewer is never told where somebody else keeps it. Deleting a group takes the heading and leaves the projects, and the sidebar decides where a project renders by whether its group still exists rather than by the id alone. A group deleted in another tab arrives as one event and not one per project, so for a moment the projects still name a heading that is no longer drawn — filing by the raw id would drop them out of the sidebar entirely. Kommende gains the month grid the project pages already had, behind a Liste/Kalender toggle. The two views ask for different things — seven days from today against six whole weeks around an arbitrary month — so each loads its own, and the grid reports which dates it is showing rather than having the page guess. Which view is remembered in localStorage: a month of seven columns needs room, and the answer on a phone is not the answer on a wide monitor. And a task can now be dropped on a project in the sidebar, on a day in Kommende, or on a cell in any month grid. Onto a project it is a *move*, not a field: `sort_order` belongs to the project, so it has to find a place among that project's tasks and leave its old section behind. Each drag now carries its own MIME type. `getData` is deliberately unreadable while a drag is in the air — the browser hands the payload over on drop and not before, so a page cannot read what is being dragged across it — and `dataTransfer.types` is all a target has to decide with. Deciding is the point: calling preventDefault on a drag you cannot accept is what turns "no" into a silent, wrong drop. The sidebar takes a project and a task on the same row and has to tell them apart. Two things fell out of building it. The month grid has always had one cell narrower than the other six: `.today` styled the "I dag" button in the header, and the day cell for today wears the same class, so `margin-left: auto` was shrinking a grid item to its content. It is scoped to the header now. And a project page listed whatever was in the store rather than what belonged to that project, which did not matter until a task could leave a project while you were looking at it. Covered by eight new API tests and two new smoke tests, and the smoke tests drive the drags for real — the type check they exercise exists only while a drag is in flight, so nothing else can reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2300f1dc3b
commit
c14559ed0c
@@ -16,6 +16,8 @@ import { api, ApiError, humanMessage } from './api.js';
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
projects = $state([]);
|
||||
/** The foldable headings over the projects in the sidebar. */
|
||||
groups = $state([]);
|
||||
tasks = $state([]);
|
||||
loading = $state(true);
|
||||
/** Transient messages: a failed save, a rolled-back change. */
|
||||
@@ -66,8 +68,12 @@ class AppState {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.user = await api.me();
|
||||
const { projects } = await api.listProjects();
|
||||
const [{ projects }, { groups }] = await Promise.all([
|
||||
api.listProjects(),
|
||||
api.listProjectGroups()
|
||||
]);
|
||||
this.projects = projects;
|
||||
this.groups = groups;
|
||||
this.connect();
|
||||
} catch (e) {
|
||||
this.user = null;
|
||||
@@ -133,6 +139,17 @@ class AppState {
|
||||
this.projects = this.projects.filter((p) => p.id !== task?.id);
|
||||
break;
|
||||
|
||||
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;
|
||||
|
||||
// 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.
|
||||
@@ -252,6 +269,35 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
async reschedule(id, date) {
|
||||
const previous = this.get(id);
|
||||
if (!previous || previous.due_date === date) return;
|
||||
await this.update(id, { due_date: date });
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
const previous = [...this.tasks];
|
||||
this.tasks = this.tasks.filter((t) => t.id !== id);
|
||||
@@ -324,6 +370,112 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 });
|
||||
}
|
||||
|
||||
async renameGroup(id, name) {
|
||||
await this.#patchGroup(id, { name });
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
}
|
||||
|
||||
// --- toasts -------------------------------------------------------------------
|
||||
|
||||
toast(message) {
|
||||
@@ -393,6 +545,35 @@ class SidebarLayout {
|
||||
|
||||
export const sidebar = new SidebarLayout();
|
||||
|
||||
/**
|
||||
* Whether Kommende shows the next seven days as a list, or the month as a grid.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class UpcomingView {
|
||||
mode = $state(
|
||||
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:upcoming') === 'calendar'
|
||||
? 'calendar'
|
||||
: 'list'
|
||||
);
|
||||
|
||||
set(next) {
|
||||
if (next !== 'list' && next !== 'calendar') return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:upcoming', next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const upcomingView = new UpcomingView();
|
||||
|
||||
/**
|
||||
* The themes on offer.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user