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:
cdnutterandClaude Opus 5.5 committed 2026-10-08 14:24:00 -07:00
1 parent bf915c04db
commit 88373176f2
15 files changed
+1090 -233

No files matched your search

+131 -4
View File
@@ -79,11 +79,11 @@ func TestRanking(t *testing.T) {
}{
{"A", 5, "E,B,C,D,A"},
{"D", 1, "D,E,B,C,A"},
{"B", 99, "D,E,C,A,B"}, // clamped to the end
{"C", 0, "C,D,E,A,B"}, // clamped to the top
{"B", 5, "D,E,C,A,B"},
{"C", 1, "C,D,E,A,B"},
}
for _, st := range steps {
if err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil {
if _, err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil {
t.Fatal(err)
}
if got := titles(t, s, l.ID); got != st.want {
@@ -91,7 +91,12 @@ func TestRanking(t *testing.T) {
}
}
if _, err := s.DeleteItem(ctx, ids["E"]); err != nil {
for _, bad := range []int{0, 6} {
if _, err := s.MoveItem(ctx, ids["A"], bad); !errors.As(err, &ve) {
t.Fatalf("move to %d: expected an error, got %v", bad, err)
}
}
if _, err := s.DeleteItem(ctx, ids["E"], true); err != nil {
t.Fatal(err)
}
if got := titles(t, s, l.ID); got != "C,D,A,B" {
@@ -207,3 +212,125 @@ func TestAdoptLegacyDB(t *testing.T) {
t.Errorf("topdeck.db should have been renamed")
}
}
// layout shows a list's places, "-" for an empty one: "A,-,B".
func layout(t *testing.T, s *Store, listID int64) string {
t.Helper()
items, err := s.Items(context.Background(), listID, 0, 0)
if err != nil {
t.Fatal(err)
}
var out []string
next := 1
for _, it := range items {
for ; next < it.Rank; next++ {
out = append(out, "-")
}
out = append(out, it.Title)
next = it.Rank + 1
}
return strings.Join(out, ",")
}
func TestPlaces(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
l, err := s.CreateList(ctx, List{Title: "Games", Category: "game", MaxItems: 8})
if err != nil {
t.Fatal(err)
}
ids := map[string]int64{}
add := func(title string, rank int) {
t.Helper()
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: title}, rank)
if err != nil {
t.Fatal(err)
}
ids[title] = it.ID
}
check := func(what, want string) {
t.Helper()
if got := layout(t, s, l.ID); got != want {
t.Fatalf("%s: got %s, want %s", what, got, want)
}
}
add("A", 0)
add("B", 0)
add("C", 4) // an empty place
check("add at an empty place", "A,B,-,C")
add("D", 0)
check("append goes after the last", "A,B,-,C,D")
add("E", 1)
check("insert pushes down to the gap only", "E,A,B,C,D")
mustMove := func(title string, to int, want string) {
t.Helper()
if _, err := s.MoveItem(ctx, ids[title], to); err != nil {
t.Fatal(err)
}
check(fmt.Sprintf("move %s to %d", title, to), want)
}
mustMove("D", 7, "E,A,B,C,-,-,D") // onto an empty place
mustMove("E", 2, "A,E,B,C,-,-,D") // ordinary move down
mustMove("C", 1, "C,A,E,B,-,-,D") // ordinary move up
mustMove("B", 7, "C,A,E,-,-,D,B") // onto a taken place: D steps up into the gap
mustMove("D", 2, "C,D,A,E,-,-,B") // move up: shifts stop where D left
mustMove("A", 6, "C,D,-,E,-,A,B") // empty place: A's old place stays empty
if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil {
t.Fatal(err)
}
check("insert an empty place", "C,-,D,E,-,A,B") // D moves into the gap; E stays
if _, err := s.RemovePlace(ctx, l.ID, 2, 1); err != nil {
t.Fatal(err)
}
check("remove an empty place", "C,D,E,-,A,B")
var ve ValidationError
if _, err := s.RemovePlace(ctx, l.ID, 1, 1); !errors.As(err, &ve) {
t.Fatalf("removing a taken place: %v", err)
}
if _, err := s.DeleteItem(ctx, ids["E"], false); err != nil {
t.Fatal(err)
}
check("delete, keep the place", "C,D,-,-,A,B")
if _, err := s.DeleteItem(ctx, ids["C"], true); err != nil {
t.Fatal(err)
}
check("delete, close up to the gap", "D,-,-,-,A,B")
if _, err := s.RemovePlace(ctx, l.ID, 2, 2); err != nil {
t.Fatal(err)
}
check("remove two empty places", "D,-,A,B")
if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil {
t.Fatal(err)
}
if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil {
t.Fatal(err)
}
check("insert into a gap does nothing to the entries", "D,-,A,B")
if _, err := s.InsertPlace(ctx, l.ID, 3); err != nil { // above A at #3
t.Fatal(err)
}
if _, err := s.InsertPlace(ctx, l.ID, 4); err != nil { // above A, now at #4
t.Fatal(err)
}
check("insert above an entry", "D,-,-,-,A,B")
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "Games", Category: "game", MaxItems: 5}); !errors.As(err, &ve) {
t.Fatalf("shrinking below the last place: %v", err)
}
add("F", 6)
add("G", 0)
check("fill to the end", "D,-,-,-,A,F,B,G")
add("H", 0)
check("append into the first gap once the end is reached", "D,H,-,-,A,F,B,G")
if _, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "J"}, 5); !errors.As(err, &ve) {
t.Fatalf("no room to push down: %v", err)
}
if _, err := s.CloseGaps(ctx, l.ID); err != nil {
t.Fatal(err)
}
check("close gaps", "D,H,A,F,B,G")
}