`projects.color` has been in the schema since the first migration, has defaulted to 'graphite' on every row ever written, and nothing has ever read it. There was no palette, no name-to-colour mapping, and the dot in the sidebar was hardcoded to a border grey. So this is not "add a picker"; it is implementing colour for the first time. Names, not hex values. A hex in ten thousand rows is a decision nobody can revisit; a name is resolved when the interface paints and can be retuned for a new theme or a new display. It is also the difference between a database that stores a fact and one that stores a rendering. The set is closed and refused at the edge — an unknown name would be stored happily and painted as the default, which looks like a colour that did not save. One palette across all five themes, and this is the one place in app.css that is not redefined per theme. Everything else there is text, a ground or a border and has to be measured against what sits behind it. These are small solid marks beside a label that already carries the meaning: what they owe is to be told apart, and to be visible on both a near-black and a white ground, which is why all ten sit in the mid-tone band. It also keeps them out of the trap the themes are in — a colour added to four blocks and forgotten in the fifth looks finished and falls back silently. There is no fifth block to forget. The list is written down three times: Go for validation, colors.js for the picker, app.css for the tokens that paint. So a test reads the other two — the same small-scanner approach openapi_test.go takes to the spec — and fails if the server accepts a colour nothing can draw, or the picker offers one the server refuses. Groups get a colour of their own (migration 0006) and projects inside a group are indented. The indent is to the group's *name*, not to its dot: the eye follows the text, and lining the projects up under the dot leaves them looking a step short of where the heading starts. The drop line is indented with them, so a drop inside a group reads as landing in the group. The group's dot is the control as well as the preview — you press the thing you are about to change, as on the project page. That was not the first design: a third text action, "Farve", sat beside Omdøb and Slet, and those carry `opacity: 0` rather than `display: none`, so they take their width whether or not you can see them. A group called "Arbejde" rendered as "ARBEJD". Moving the control onto the dot gave the name its room back. The smoke test measures the indent at the content edge rather than the element's, because the indent is padding and the box does not move — which is how the first version of that assertion passed 12 against 12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
6.5 KiB
Go
212 lines
6.5 KiB
Go
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
|
|
Color 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, color, 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, &g.Color, &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, color, collapsed, sort_order, created_at, updated_at
|
|
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
|
|
Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &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.Color == "" {
|
|
g.Color = DefaultColor
|
|
}
|
|
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, color, collapsed, sort_order, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
g.ID, g.OwnerID, g.Name, g.Color, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
|
|
return err
|
|
}
|
|
|
|
type ProjectGroupUpdate struct {
|
|
Name *string
|
|
Color *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,
|
|
"color": u.Color,
|
|
"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
|
|
}
|