Allow empty places in rankings; rebuild the list editor
Entries now sit at places 1…max with gaps allowed (#28, nothing at #29, #30). Moving onto an empty place just puts the entry there; onto a taken place, the entries in between shift one step, stopping at the first empty place, so lists without gaps reorder exactly as before. Inserts push down only to the next gap; deleting can close up or leave the place empty. New endpoint POST /api/lists/{id}/places inserts or removes empty places or closes every gap; move and delete reply with every entry's place. The podium shows an empty step for an empty #2 or #3, and a list can't shrink below its last used place. The editor loads every entry (no paging) and applies edits instantly with the same rules (places.js mirrors places.go), queueing saves in the background and reconciling with the server's reply. Type a place and press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or use the row menu. Empty runs show as rows with Add here and Remove, and a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an entry opens it for editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf915c04db
commit
88373176f2
15 files changed
+1090
-233
No files matched your search
@@ -0,0 +1,338 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// A list's entries sit at places 1…max_items, and places can be empty: an
|
||||
// entry can be #28 with nothing at #29 and the next at #30. The functions
|
||||
// here decide where entries go; every change is written in one transaction.
|
||||
//
|
||||
// The rules, so that lists without gaps behave like an ordinary ranking:
|
||||
// - Moving onto an empty place just puts the entry there. Its old place
|
||||
// becomes empty.
|
||||
// - Moving onto a taken place shifts the entries in between one step
|
||||
// toward where it came from, stopping at the first empty place.
|
||||
// - Inserting (a new entry, or an empty place) pushes entries down only as
|
||||
// far as the next empty place, which absorbs the shift. Entries placed
|
||||
// further down stay put.
|
||||
|
||||
// places maps place → entry id.
|
||||
type places map[int]int64
|
||||
|
||||
func (p places) last() int {
|
||||
n := 0
|
||||
for r := range p {
|
||||
n = max(n, r)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// shift moves the entries at places [from, to] by d (±1), walking in the
|
||||
// direction that never overwrites an entry before it's moved.
|
||||
func (p places) shift(from, to, d int) {
|
||||
if from > to {
|
||||
return
|
||||
}
|
||||
if d > 0 {
|
||||
for r := to; r >= from; r-- {
|
||||
if id, ok := p[r]; ok {
|
||||
delete(p, r)
|
||||
p[r+d] = id
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for r := from; r <= to; r++ {
|
||||
if id, ok := p[r]; ok {
|
||||
delete(p, r)
|
||||
p[r+d] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// makeRoom frees place at by pushing entries down to the next empty place.
|
||||
func (p places) makeRoom(at, limit int) error {
|
||||
if _, taken := p[at]; !taken {
|
||||
return nil
|
||||
}
|
||||
gap := at
|
||||
for {
|
||||
if _, taken := p[gap]; !taken {
|
||||
break
|
||||
}
|
||||
gap++
|
||||
}
|
||||
if gap > limit {
|
||||
return invalid("No room: this list ends at #%d. Raise its size in List settings, or remove an empty place first.", limit)
|
||||
}
|
||||
p.shift(at, gap-1, +1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// move takes the entry at from to place to.
|
||||
func (p places) move(from, to int) {
|
||||
id := p[from]
|
||||
delete(p, from)
|
||||
if _, taken := p[to]; taken {
|
||||
if to < from {
|
||||
gap := to + 1 // the nearest empty place below to; from is one
|
||||
for {
|
||||
if _, t := p[gap]; !t {
|
||||
break
|
||||
}
|
||||
gap++
|
||||
}
|
||||
p.shift(to, gap-1, +1)
|
||||
} else {
|
||||
gap := to - 1 // the nearest empty place above to
|
||||
for {
|
||||
if _, t := p[gap]; !t {
|
||||
break
|
||||
}
|
||||
gap--
|
||||
}
|
||||
p.shift(gap+1, to, -1)
|
||||
}
|
||||
}
|
||||
p[to] = id
|
||||
}
|
||||
|
||||
// closeUp pulls the entries right below an emptied place up by one, as far
|
||||
// as the next empty place.
|
||||
func (p places) closeUp(at int) {
|
||||
end := at + 1
|
||||
for {
|
||||
if _, t := p[end]; !t {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
p.shift(at+1, end-1, -1)
|
||||
}
|
||||
|
||||
// compact numbers the entries 1, 2, 3… in order, removing every gap.
|
||||
func (p places) compact() {
|
||||
ranks := make([]int, 0, len(p))
|
||||
for r := range p {
|
||||
ranks = append(ranks, r)
|
||||
}
|
||||
sort.Ints(ranks)
|
||||
ids := make([]int64, len(ranks))
|
||||
for i, r := range ranks {
|
||||
ids[i] = p[r]
|
||||
delete(p, r)
|
||||
}
|
||||
for i, id := range ids {
|
||||
p[i+1] = id
|
||||
}
|
||||
}
|
||||
|
||||
// Place is an entry's id and place, as returned after any change.
|
||||
type Place struct {
|
||||
ID int64 `json:"id"`
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
|
||||
// placeTx loads a list's places inside a transaction.
|
||||
type placeTx struct {
|
||||
tx *sql.Tx
|
||||
listID int64
|
||||
limit int // max_items
|
||||
before map[int64]int
|
||||
p places
|
||||
}
|
||||
|
||||
func (s *Store) beginPlaces(ctx context.Context, listID int64) (*placeTx, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pt := &placeTx{tx: tx, listID: listID, before: map[int64]int{}, p: places{}}
|
||||
err = tx.QueryRowContext(ctx, `SELECT max_items FROM lists WHERE id = ?`, listID).Scan(&pt.limit)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
tx.Rollback()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, rank FROM items WHERE list_id = ?`, listID)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var r int
|
||||
if err := rows.Scan(&id, &r); err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
pt.before[id] = r
|
||||
pt.p[r] = id
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
// write saves the places that changed, without committing.
|
||||
func (pt *placeTx) write(ctx context.Context) error {
|
||||
if last := pt.p.last(); last > pt.limit {
|
||||
return invalid("No room: this list ends at #%d. Raise its size in List settings.", pt.limit)
|
||||
}
|
||||
stmt, err := pt.tx.PrepareContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for r, id := range pt.p {
|
||||
if old, ok := pt.before[id]; ok && old != r {
|
||||
if _, err := stmt.ExecContext(ctx, r, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return touchList(ctx, pt.tx, pt.listID)
|
||||
}
|
||||
|
||||
// commit writes the places that changed and returns them all.
|
||||
func (pt *placeTx) commit(ctx context.Context) ([]Place, error) {
|
||||
defer pt.tx.Rollback()
|
||||
if err := pt.write(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pt.tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pt.list(), nil
|
||||
}
|
||||
|
||||
// appendPlace is where a new entry goes when no place is asked for: after
|
||||
// the last one, or the first empty place if the list ends there.
|
||||
func (p places) appendPlace(limit int) int {
|
||||
if last := p.last(); last < limit {
|
||||
return last + 1
|
||||
}
|
||||
for r := 1; r <= limit; r++ {
|
||||
if _, t := p[r]; !t {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return limit + 1
|
||||
}
|
||||
|
||||
func (pt *placeTx) list() []Place {
|
||||
out := make([]Place, 0, len(pt.p))
|
||||
for r, id := range pt.p {
|
||||
out = append(out, Place{ID: id, Rank: r})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Rank < out[j].Rank })
|
||||
return out
|
||||
}
|
||||
|
||||
func (pt *placeTx) checkPlace(at int) error {
|
||||
if at < 1 || at > pt.limit {
|
||||
return invalid("Pick a place from #1 to #%d.", pt.limit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Places returns where each of a list's entries sits.
|
||||
func (s *Store) Places(ctx context.Context, listID int64) ([]Place, error) {
|
||||
pt, err := s.beginPlaces(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer pt.tx.Rollback()
|
||||
return pt.list(), nil
|
||||
}
|
||||
|
||||
// MoveItem puts an entry at place to (see the rules above) and returns the
|
||||
// list's places.
|
||||
func (s *Store) MoveItem(ctx context.Context, id int64, to int) ([]Place, error) {
|
||||
var listID int64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT list_id FROM items WHERE id = ?`, id).Scan(&listID); errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pt, err := s.beginPlaces(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer pt.tx.Rollback()
|
||||
if err := pt.checkPlace(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if from := pt.before[id]; from != to {
|
||||
pt.p.move(from, to)
|
||||
}
|
||||
return pt.commit(ctx)
|
||||
}
|
||||
|
||||
// InsertPlace opens an empty place at at, pushing entries down to the next
|
||||
// empty place.
|
||||
func (s *Store) InsertPlace(ctx context.Context, listID int64, at int) ([]Place, error) {
|
||||
pt, err := s.beginPlaces(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer pt.tx.Rollback()
|
||||
if err := pt.checkPlace(at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pt.p.makeRoom(at, pt.limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pt.commit(ctx)
|
||||
}
|
||||
|
||||
// RemovePlace deletes count empty places starting at at: everything below
|
||||
// moves up by count.
|
||||
func (s *Store) RemovePlace(ctx context.Context, listID int64, at, count int) ([]Place, error) {
|
||||
pt, err := s.beginPlaces(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer pt.tx.Rollback()
|
||||
if err := pt.checkPlace(at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count = max(count, 1)
|
||||
for r := at; r < at+count; r++ {
|
||||
if _, taken := pt.p[r]; taken {
|
||||
return nil, invalid("#%d isn't empty.", r)
|
||||
}
|
||||
}
|
||||
for range count {
|
||||
pt.p.shift(at+1, pt.p.last(), -1)
|
||||
}
|
||||
return pt.commit(ctx)
|
||||
}
|
||||
|
||||
// CloseGaps renumbers a list's entries 1, 2, 3… keeping their order.
|
||||
func (s *Store) CloseGaps(ctx context.Context, listID int64) ([]Place, error) {
|
||||
pt, err := s.beginPlaces(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer pt.tx.Rollback()
|
||||
pt.p.compact()
|
||||
return pt.commit(ctx)
|
||||
}
|
||||
|
||||
// MaxRank is the highest place used in a list, 0 when it's empty.
|
||||
func (s *Store) MaxRank(ctx context.Context, listID int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(rank), 0) FROM items WHERE list_id = ?`, listID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
Reference in new issue
Block a user