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
@@ -0,0 +1,33 @@
|
||||
-- Foldable groups over the projects in the sidebar.
|
||||
--
|
||||
-- A table rather than a `group_name` column on the project: a group has to be
|
||||
-- renamable in one write, has to survive being emptied, and has to remember
|
||||
-- whether it is folded. A repeated string on every project row can do none of
|
||||
-- those — renaming would touch N rows, and an empty group would simply cease to
|
||||
-- exist the moment its last project left it.
|
||||
CREATE TABLE project_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- 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 stored per browser. A folded group is not: it is a
|
||||
-- statement about that group — "this is the part of my work I am not in right
|
||||
-- now" — and it is true on the laptop and the desktop alike. A group is also
|
||||
-- owned by exactly one person, so there is nobody else's answer to disagree
|
||||
-- with.
|
||||
collapsed INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order REAL NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_groups_owner ON project_groups (owner_id);
|
||||
|
||||
-- ON DELETE SET NULL: deleting a group is a decision about the heading, not about
|
||||
-- the projects underneath it. They fall back to ungrouped, in the order they
|
||||
-- already had.
|
||||
ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_projects_group ON projects (group_id) WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,205 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A ProjectGroup is a heading in the sidebar with projects filed under it.
|
||||
//
|
||||
// Groups belong to one person. They are not shared and never travel with a
|
||||
// project: somebody who has a project shared with them files it where they like,
|
||||
// which is possible precisely because the group is theirs and the membership is
|
||||
// not. That is also why a shared project can never be in a group — see
|
||||
// SetProjectGroup.
|
||||
type ProjectGroup struct {
|
||||
ID string
|
||||
OwnerID string
|
||||
Name string
|
||||
Collapsed bool
|
||||
SortOrder float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at
|
||||
FROM project_groups WHERE owner_id = ?
|
||||
ORDER BY sort_order, created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := []ProjectGroup{}
|
||||
for rows.Next() {
|
||||
var g ProjectGroup
|
||||
var collapsed int
|
||||
var created, updated int64
|
||||
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder,
|
||||
&created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Collapsed = collapsed == 1
|
||||
g.CreatedAt = time.Unix(created, 0).UTC()
|
||||
g.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// GetProjectGroup is scoped to the owner rather than looked up by id alone, so a
|
||||
// handler cannot accidentally hand somebody else's group back by forgetting a
|
||||
// check the query could have made itself.
|
||||
func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*ProjectGroup, error) {
|
||||
var g ProjectGroup
|
||||
var collapsed int
|
||||
var created, updated int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at
|
||||
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
|
||||
Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder, &created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Collapsed = collapsed == 1
|
||||
g.CreatedAt = time.Unix(created, 0).UTC()
|
||||
g.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
|
||||
if g.ID == "" {
|
||||
g.ID = NewID()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
g.CreatedAt, g.UpdatedAt = now, now
|
||||
if g.SortOrder == 0 {
|
||||
var max sql.NullFloat64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT max(sort_order) FROM project_groups WHERE owner_id = ?`,
|
||||
g.OwnerID).Scan(&max); err != nil {
|
||||
return err
|
||||
}
|
||||
g.SortOrder = max.Float64 + 1024
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO project_groups (id, owner_id, name, collapsed, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
g.ID, g.OwnerID, g.Name, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
type ProjectGroupUpdate struct {
|
||||
Name *string
|
||||
Collapsed *bool
|
||||
SortOrder *float64
|
||||
}
|
||||
|
||||
func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error {
|
||||
set, args := buildUpdate(map[string]any{
|
||||
"name": u.Name,
|
||||
"collapsed": u.Collapsed,
|
||||
"sort_order": u.SortOrder,
|
||||
})
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
set = append(set, "updated_at = ?")
|
||||
args = append(args, time.Now().Unix(), groupID, userID)
|
||||
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE project_groups SET `+joinComma(set)+` WHERE id = ? AND owner_id = ?`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProjectGroup removes the heading and leaves the projects.
|
||||
//
|
||||
// The projects are ungrouped explicitly rather than left to the foreign key's
|
||||
// ON DELETE SET NULL: the pragma is set per connection, and a schema that quietly
|
||||
// depends on it would keep the rows pointing at a group that no longer exists on
|
||||
// any connection that forgot. Hard deleted rather than soft: a group holds no
|
||||
// work of its own, so there is nothing in it to recover.
|
||||
func (db *DB) DeleteProjectGroup(ctx context.Context, groupID, userID string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE projects SET group_id = NULL, updated_at = ? WHERE group_id = ?`,
|
||||
time.Now().Unix(), groupID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ReorderProjectGroups writes the given order, and only for groups the caller
|
||||
// owns — the same whole-list write the projects use, for the same reason: a
|
||||
// handful of rows, one transaction, no precision to run out of.
|
||||
func (db *DB) ReorderProjectGroups(ctx context.Context, userID string, ids []string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
now := time.Now().Unix()
|
||||
for i, id := range ids {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE project_groups SET sort_order = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_id = ?`,
|
||||
float64(i), now, id, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetProjectGroup files a project under a group, or takes it out of one when
|
||||
// groupID is empty.
|
||||
//
|
||||
// Both ends are checked against the caller: the group must be theirs, and so must
|
||||
// the project. Ownership rather than edit rights, and for the same reason
|
||||
// sort_order is owner-scoped — the column lives on the project row, so a member
|
||||
// filing a shared project into a group of their own would move it in the owner's
|
||||
// sidebar as well. Until grouping is per viewer, a shared project stays where its
|
||||
// owner has it.
|
||||
func (db *DB) SetProjectGroup(ctx context.Context, projectID, groupID, userID string) error {
|
||||
var group any
|
||||
if groupID != "" {
|
||||
var exists int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group = groupID
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE projects SET group_id = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_id = ? AND is_inbox = 0 AND deleted_at IS NULL`,
|
||||
group, time.Now().Unix(), projectID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -20,6 +20,11 @@ type Project struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// GroupID is which sidebar group the *asking* user has filed it under, and is
|
||||
// empty for a project shared with them: the column is on the project row, so
|
||||
// the owner's filing is not an answer anybody else asked for.
|
||||
GroupID string
|
||||
|
||||
// Role is the asking user's standing, filled in by the list and get calls so
|
||||
// the frontend can grey out what they cannot do without a second request.
|
||||
Role Role
|
||||
@@ -44,7 +49,8 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
|
||||
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||
p.archived, p.sort_order, p.created_at, p.updated_at,
|
||||
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
|
||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count
|
||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count,
|
||||
CASE WHEN p.owner_id = ? THEN p.group_id END AS group_id
|
||||
FROM projects p
|
||||
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
|
||||
WHERE p.deleted_at IS NULL
|
||||
@@ -54,7 +60,7 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
|
||||
}
|
||||
query += ` ORDER BY p.is_inbox DESC, p.sort_order, p.created_at`
|
||||
|
||||
rows, err := db.QueryContext(ctx, query, userID, userID, userID)
|
||||
rows, err := db.QueryContext(ctx, query, userID, userID, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,8 +87,9 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
|
||||
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||
p.archived, p.sort_order, p.created_at, p.updated_at,
|
||||
? AS role,
|
||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1
|
||||
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), projectID)
|
||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1,
|
||||
CASE WHEN p.owner_id = ? THEN p.group_id END
|
||||
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), userID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,17 +107,18 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
|
||||
|
||||
func scanProject(rows *sql.Rows) (Project, error) {
|
||||
var p Project
|
||||
var icon sql.NullString
|
||||
var icon, groupID sql.NullString
|
||||
var isInbox, archived int
|
||||
var created, updated int64
|
||||
var role string
|
||||
|
||||
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
|
||||
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount)
|
||||
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &groupID)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.Icon = icon.String
|
||||
p.GroupID = groupID.String
|
||||
p.IsInbox = isInbox == 1
|
||||
p.Archived = archived == 1
|
||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||
|
||||
Reference in New Issue
Block a user