feat(projects): drag the sidebar into the order you want

Projects have carried sort_order since the first migration and nothing
ever set it, so the sidebar was ordered by creation date for good.

POST /projects/reorder takes the whole list rather than one move. A
person has a handful of projects, so sending the order you want is
simpler to reason about than midpoints between neighbours — and it cannot
run out of precision, which is the failure the task ordering has to
respace a section to recover from. It also cannot land half-applied the
way a sequence of individual moves can.

Projects you do not own are skipped rather than refused. sort_order is a
column on the project, not a preference per viewer, so a shared project
sits where its owner put it and a member rearranging their own sidebar
does not rearrange somebody else's. The sidebar only makes your own
projects draggable, so that rule never shows up as a row that springs
back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 15:59:31 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent 3f4af6c0ab
commit c4e9e1519b
8 changed files with 280 additions and 1 deletions
+27
View File
@@ -556,3 +556,30 @@ func joinComma(parts []string) string {
}
return out
}
// ReorderProjects writes the given order, and only for projects the user owns.
//
// The whole list at once, rather than the midpoint-between-neighbours dance the
// tasks do. A person has a handful of projects, not a thousand, so writing
// 0, 1, 2 … is one small transaction that cannot run out of precision — the
// failure the task ordering has to respace a section to recover from.
//
// Scoped to ownership because sort_order lives on the project row rather than
// per viewer: a shared project sits where its owner put it, and a member
// reordering their sidebar must not rearrange somebody else's. Ids that are not
// the caller's are skipped rather than refused, so one shared project in the
// list does not fail the whole drag.
func (db *DB) ReorderProjects(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 projects SET sort_order = ?, updated_at = ?
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`,
float64(i), now, id, userID); err != nil {
return err
}
}
return nil
})
}