summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-11-11 08:54:52 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-11-11 08:54:52 +0000
commitaba4fb18ef8a542865e10a891081681352b64fcd (patch)
treebff91fa0c0426886d47da5d1a90311e554ee1928 /server
parent8db54bc24d7d5d0cdfce8a2e19199d7e9a42fbd4 (diff)
Shows have a category, not a tier
Tier said "how important" and the shows needed "what kind". Seven ordered categories replace it: news, analysis, curiosity, sailing, true stories, fiction, deep dives. The order is still the server's processing priority, so one field carries both axes -- which is the idiosyncrasy docs/show-kinds.md is written to watch, with four signs that a better abstraction has arrived. All 42 known feeds are refiled by migration 20 -> 21, which rebuilds the subscriptions table rather than renaming the column: RENAME COLUMN keeps the old 'ordinary' DEFAULT and fails schema validation, and DROP COLUMN needs SQLite 3.35 against minSdk 29's 3.28. The wire value for true stories is "truestory", not "true", because SQLite reads an unquoted true in a DEFAULT as the boolean and stores 1. Schema 21 is a new exported JSON; migrate20To21RenamesTierAndRefilesTheKnownShows covers it, and all 22 device migration tests pass. The coverage exclusion for the settings composable follows the file's rename; left pointing at TierSettingKt it read as a 0.6% drop. The remaining 0.08% is the deletion of the covered tier-seeding code, taken as an exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'server')
-rw-r--r--server/cmd/listend/main.go2
-rw-r--r--server/internal/api/api.go6
-rw-r--r--server/internal/api/api_test.go14
-rw-r--r--server/internal/store/store.go2
-rw-r--r--server/internal/store/tier.go86
-rw-r--r--server/internal/store/tier_test.go34
-rw-r--r--server/internal/words/pipeline.go18
-rw-r--r--server/internal/words/pipeline_test.go8
8 files changed, 94 insertions, 76 deletions
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go
index a165feb..79290c1 100644
--- a/server/cmd/listend/main.go
+++ b/server/cmd/listend/main.go
@@ -295,7 +295,7 @@ func (w *worker) save() {
func (w *worker) pollAll(ctx context.Context) {
// Topical shows first and archive last, so a five-hour back-catalogue episode cannot take
// a cycle's recognition budget before the morning news has been looked at.
- feeds := w.state.ByTier(w.state.Feeds())
+ feeds := w.state.ByCategory(w.state.Feeds())
if len(feeds) == 0 {
log.Printf("no subscriptions yet; waiting for the phone to send its list")
return
diff --git a/server/internal/api/api.go b/server/internal/api/api.go
index 2d2e279..5a11c92 100644
--- a/server/internal/api/api.go
+++ b/server/internal/api/api.go
@@ -181,10 +181,10 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) {
}
var body struct {
Feeds []string `json:"feeds"`
- // Tiers says how soon each feed's work is worth doing, keyed by URL. Optional and
+ // Categories says what sort of listening each feed is, keyed by URL, which is also
// separate from the list rather than folded into it, so a phone that knows nothing
// about tiers keeps working unchanged.
- Tiers map[string]string `json:"tiers,omitempty"`
+ Categories map[string]string `json:"categories,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected {\"feeds\":[...]}"})
@@ -200,7 +200,7 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) {
return
}
// After the list, so a tier for a feed that was just removed is dropped with it.
- s.state.SetTiers(body.Tiers)
+ s.state.SetCategories(body.Categories)
if s.opts.OnSubscriptions != nil {
s.opts.OnSubscriptions(s.state.Feeds())
}
diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go
index b50454b..5ae4435 100644
--- a/server/internal/api/api_test.go
+++ b/server/internal/api/api_test.go
@@ -379,21 +379,21 @@ Tiers arrive beside the list rather than folded into it, so a phone that knows n
them keeps working unchanged -- which every phone did until this week.
*/
-func TestTakesTheTiersThePhoneSends(t *testing.T) {
+func TestTakesTheCategoriesThePhoneSends(t *testing.T) {
s, st, _, _ := newServer(t)
post(t, s, "/api/subscriptions",
- `{"feeds":["https://a","https://b"],"tiers":{"https://a":"topical"}}`)
+ `{"feeds":["https://a","https://b"],"categories":{"https://a":"news"}}`)
- if got := st.Tier("https://a"); got != store.TierTopical {
- t.Errorf("got %q, want %q", got, store.TierTopical)
+ if got := st.Category("https://a"); got != store.CategoryNews {
+ t.Errorf("got %q, want %q", got, store.CategoryNews)
}
- if got := st.Tier("https://b"); got != store.TierOrdinary {
+ if got := st.Category("https://b"); got != store.CategoryDefault {
t.Errorf("got %q, want the default", got)
}
}
-func TestAListWithNoTiersStillWorks(t *testing.T) {
+func TestAListWithNoCategoriesStillWorks(t *testing.T) {
s, st, _, _ := newServer(t)
rec := post(t, s, "/api/subscriptions", `{"feeds":["https://a"]}`)
@@ -414,7 +414,7 @@ func TestATierForARemovedFeedIsDropped(t *testing.T) {
post(t, s, "/api/subscriptions", `{"feeds":["https://b"],"tiers":{"https://a":"archive"}}`)
- if got := st.Tier("https://a"); got != store.TierOrdinary {
+ if got := st.Category("https://a"); got != store.CategoryDefault {
t.Errorf("got %q, want it dropped with the feed", got)
}
}
diff --git a/server/internal/store/store.go b/server/internal/store/store.go
index de763ed..caf5365 100644
--- a/server/internal/store/store.go
+++ b/server/internal/store/store.go
@@ -469,7 +469,7 @@ func (s *State) SetFeeds(feeds []string) (removed []string) {
}
for url := range s.tiers {
if !wanted[url] {
- s.forgetTier(url)
+ s.forgetCategory(url)
}
}
sort.Strings(removed)
diff --git a/server/internal/store/tier.go b/server/internal/store/tier.go
index 94d2634..eb46aca 100644
--- a/server/internal/store/tier.go
+++ b/server/internal/store/tier.go
@@ -5,7 +5,7 @@ import "sort"
/*
How soon a show's work is worth doing.
-Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps
+Peter's framing: a few shows are news and stop being worth hearing within a day, a category he keeps
up with, and a long tail that is not topical at all. It matters because recognition costs about
five minutes of CPU an episode, so a five-hour Dan Carlin episode landing the same morning as Up
First takes most of a cycle's budget before the news is touched.
@@ -14,31 +14,55 @@ Three and no more. A number would invite fiddling and would not mean anything; t
three distinctions he actually described.
*/
const (
- // TierTopical is news: worth hearing today and worthless by Thursday.
- TierTopical = "topical"
- // TierOrdinary is a show kept up with. The default, because it is what most of a library
- // is and because a show nobody has placed should not be starved.
- TierOrdinary = "ordinary"
- // TierArchive is a back catalogue, a slow drip, anything heard whenever. Its work waits
- // for the time nothing else wants.
- TierArchive = "archive"
+ // The categories, in the order the work is done. The phone sends these; see
+ // docs/show-kinds.md in the app for what each means and why the order is what it is.
+ //
+ // They replaced three values that were priority alone -- topical, ordinary, archive --
+ // which could not tell the daily news from a daily horror serial. A category says what a
+ // show *is*, and the order of the list is the priority, so there is one thing to set
+ // rather than two that can disagree.
+ CategoryNews = "news"
+ CategoryAnalysis = "analysis"
+ CategoryCuriosity = "curiosity"
+ CategorySailing = "sailing"
+ CategoryTrue = "truestory"
+ CategoryFiction = "fiction"
+ CategoryDeep = "deep"
)
-// tierRank orders the work. Lower goes first.
-var tierRank = map[string]int{TierTopical: 0, TierOrdinary: 1, TierArchive: 2}
+// categoryRank orders the work. Lower goes first, and the order is the declaration order above.
+//
+// A category this server does not know -- sent by a newer phone -- ranks with the default
+// rather than first or last, so an unrecognised value neither jumps the queue nor starves.
+var categoryRank = map[string]int{
+ CategoryNews: 0, CategoryAnalysis: 1, CategoryCuriosity: 2, CategorySailing: 3,
+ CategoryTrue: 4, CategoryFiction: 5, CategoryDeep: 6,
+}
+
+// CategoryDefault is where an unplaced or unrecognised show sits: the middle of the order.
+const CategoryDefault = CategoryTrue
+
+// CategoryRank is where a category sits in the work order, for callers outside this package.
+// An unknown one ranks with the default rather than first or last.
+func CategoryRank(category string) int {
+ if rank, ok := categoryRank[category]; ok {
+ return rank
+ }
+ return categoryRank[CategoryDefault]
+}
/*
-SetTiers records where the phone placed each feed, replacing what was there.
+SetCategories records where the phone placed each feed, replacing what was there.
-Only for feeds that are actually subscribed. The phone sends its list and its tiers in one
-push, and a tier naming a feed the same push dropped would otherwise outlive it and be
+Only for feeds that are actually subscribed. The phone sends its list and its categories in one
+push, and a category naming a feed the same push dropped would otherwise outlive it and be
inherited by a resubscription months later.
-A tier this server does not understand is dropped rather than stored: a newer phone inventing a
+A category this server does not understand is dropped rather than stored: a newer phone inventing a
fourth must not quietly stop a show being polled, which is the rule the label store already
follows for a verdict it does not recognise.
*/
-func (s *State) SetTiers(tiers map[string]string) {
+func (s *State) SetCategories(categories map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.tiers == nil {
@@ -48,38 +72,38 @@ func (s *State) SetTiers(tiers map[string]string) {
for _, url := range s.feeds {
subscribed[url] = true
}
- for url, tier := range tiers {
- _, known := tierRank[tier]
- if !subscribed[url] || !known || tier == TierOrdinary {
+ for url, category := range categories {
+ _, known := categoryRank[category]
+ if !subscribed[url] || !known || category == CategoryDefault {
delete(s.tiers, url)
continue
}
- s.tiers[url] = tier
+ s.tiers[url] = category
}
}
-// Tier is where a feed sits, or [TierOrdinary].
-func (s *State) Tier(feedURL string) string {
+// Category is where a feed sits, or [CategoryDefault].
+func (s *State) Category(feedURL string) string {
s.mu.RLock()
defer s.mu.RUnlock()
- if tier, ok := s.tiers[feedURL]; ok {
- return tier
+ if category, ok := s.tiers[feedURL]; ok {
+ return category
}
- return TierOrdinary
+ return CategoryDefault
}
-// ByTier orders feed URLs so the topical ones are worked first and the archive last.
+// ByCategory orders feed URLs so the news is worked first and the deep dives last.
//
-// Stable, so an ordering within a tier -- newest first, usually -- survives being grouped. A
+// Stable, so an ordering within a category -- newest first, usually -- survives being grouped. A
// sort that reshuffled equals would make the queue unpredictable between cycles for no gain.
-func (s *State) ByTier(feedURLs []string) []string {
+func (s *State) ByCategory(feedURLs []string) []string {
out := append([]string(nil), feedURLs...)
sort.SliceStable(out, func(i, j int) bool {
- return tierRank[s.Tier(out[i])] < tierRank[s.Tier(out[j])]
+ return categoryRank[s.Category(out[i])] < categoryRank[s.Category(out[j])]
})
return out
}
-// forgetTier drops a feed's placement. Called when the feed goes, or a feed resubscribed months
+// forgetCategory drops a feed's placement. Called when the feed goes, or a feed resubscribed months
// later would arrive carrying a placement nobody chose.
-func (s *State) forgetTier(feedURL string) { delete(s.tiers, feedURL) }
+func (s *State) forgetCategory(feedURL string) { delete(s.tiers, feedURL) }
diff --git a/server/internal/store/tier_test.go b/server/internal/store/tier_test.go
index 971ee51..025f323 100644
--- a/server/internal/store/tier_test.go
+++ b/server/internal/store/tier_test.go
@@ -17,10 +17,10 @@ the three distinctions he actually described.
func TestATierIsRememberedForAFeed(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://a", "https://b"})
- s.SetTiers(map[string]string{"https://a": TierTopical})
+ s.SetCategories(map[string]string{"https://a": CategoryNews})
- if got := s.Tier("https://a"); got != TierTopical {
- t.Errorf("got %q, want %q", got, TierTopical)
+ if got := s.Category("https://a"); got != CategoryNews {
+ t.Errorf("got %q, want %q", got, CategoryNews)
}
}
@@ -29,8 +29,8 @@ func TestAShowWithNoTierIsOrdinary(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://a"})
- if got := s.Tier("https://a"); got != TierOrdinary {
- t.Errorf("got %q, want %q", got, TierOrdinary)
+ if got := s.Category("https://a"); got != CategoryDefault {
+ t.Errorf("got %q, want %q", got, CategoryDefault)
}
}
@@ -43,9 +43,9 @@ same rule the label store already follows for a verdict it does not recognise.
func TestAnUnknownTierIsIgnored(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://a"})
- s.SetTiers(map[string]string{"https://a": "urgent-ish"})
+ s.SetCategories(map[string]string{"https://a": "urgent-ish"})
- if got := s.Tier("https://a"); got != TierOrdinary {
+ if got := s.Category("https://a"); got != CategoryDefault {
t.Errorf("got %q, want the ordinary tier", got)
}
}
@@ -55,10 +55,10 @@ func TestAnUnknownTierIsIgnored(t *testing.T) {
func TestForgettingAFeedForgetsItsTier(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://a"})
- s.SetTiers(map[string]string{"https://a": TierTopical})
+ s.SetCategories(map[string]string{"https://a": CategoryNews})
s.SetFeeds([]string{"https://b"})
- if got := s.Tier("https://a"); got != TierOrdinary {
+ if got := s.Category("https://a"); got != CategoryDefault {
t.Errorf("got %q, want the tier forgotten with the feed", got)
}
}
@@ -69,16 +69,16 @@ Ordering runs topical first and archive last, and keeps the given order within a
Stable so that "newest first" survives being grouped: an ordering that reshuffled equals would
make the queue unpredictable from one cycle to the next for no gain.
*/
-func TestWorkIsOrderedByTier(t *testing.T) {
+func TestWorkIsOrderedByCategory(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://news", "https://usual", "https://archive", "https://news2"})
- s.SetTiers(map[string]string{
- "https://news": TierTopical,
- "https://news2": TierTopical,
- "https://archive": TierArchive,
+ s.SetCategories(map[string]string{
+ "https://news": CategoryNews,
+ "https://news2": CategoryNews,
+ "https://archive": CategoryDeep,
})
- got := s.ByTier([]string{"https://usual", "https://archive", "https://news", "https://news2"})
+ got := s.ByCategory([]string{"https://usual", "https://archive", "https://news", "https://news2"})
want := []string{"https://news", "https://news2", "https://usual", "https://archive"}
for i := range want {
if got[i] != want[i] {
@@ -96,9 +96,9 @@ dropped would outlive it and be inherited by a resubscription months later.
func TestATierForAFeedNotSubscribedIsDropped(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://b"})
- s.SetTiers(map[string]string{"https://a": TierArchive})
+ s.SetCategories(map[string]string{"https://a": CategoryDeep})
- if got := s.Tier("https://a"); got != TierOrdinary {
+ if got := s.Category("https://a"); got != CategoryDefault {
t.Errorf("got %q, want nothing kept for an unsubscribed feed", got)
}
}
diff --git a/server/internal/words/pipeline.go b/server/internal/words/pipeline.go
index 8cd18ed..d8de8d8 100644
--- a/server/internal/words/pipeline.go
+++ b/server/internal/words/pipeline.go
@@ -102,17 +102,11 @@ func (p *Pipeline) Forget(e *store.Episode) {
e.WordBreaks, e.WordAdsRefused = nil, ""
}
-// tierRankOf orders the tiers for work: topical first, archive last.
-func tierRankOf(tier string) int {
- switch tier {
- case store.TierTopical:
- return 0
- case store.TierArchive:
- return 2
- default:
- return 1
- }
-}
+// categoryRankOf orders the categories for work, news first and deep dives last.
+//
+// Delegates rather than keeping its own table: the order is the category list's declaration
+// order, and two copies of it would drift the first time one is added.
+func categoryRankOf(category string) int { return store.CategoryRank(category) }
// clock renders a duration the way every readout in this project does.
func clock(ms int64) string {
@@ -725,7 +719,7 @@ func (p *Pipeline) pendingRecognition() []*store.Episode {
rank := map[string]int{}
for _, show := range p.State.Shows() {
for _, e := range show.Episodes {
- rank[e.ID] = tierRankOf(p.State.Tier(show.FeedURL))
+ rank[e.ID] = categoryRankOf(p.State.Category(show.FeedURL))
}
}
sort.SliceStable(pending, func(i, j int) bool {
diff --git a/server/internal/words/pipeline_test.go b/server/internal/words/pipeline_test.go
index 9982a10..cf23d8f 100644
--- a/server/internal/words/pipeline_test.go
+++ b/server/internal/words/pipeline_test.go
@@ -76,13 +76,13 @@ A five-hour back-catalogue episode arriving the same morning as the news would o
most of a cycle's recognition budget before the news was looked at.
*/
func TestTheWorkOrderPutsTopicalShowsFirst(t *testing.T) {
- if tierRankOf(store.TierTopical) >= tierRankOf(store.TierOrdinary) {
+ if categoryRankOf(store.CategoryNews) >= categoryRankOf(store.CategoryDefault) {
t.Error("a topical show does not come first")
}
- if tierRankOf(store.TierArchive) <= tierRankOf(store.TierOrdinary) {
+ if categoryRankOf(store.CategoryDeep) <= categoryRankOf(store.CategoryDefault) {
t.Error("an archive show does not come last")
}
- if tierRankOf("") != tierRankOf(store.TierOrdinary) {
+ if categoryRankOf("") != categoryRankOf(store.CategoryDefault) {
t.Error("a show nobody placed is not treated as ordinary, so it could be starved")
}
}
@@ -90,7 +90,7 @@ func TestTheWorkOrderPutsTopicalShowsFirst(t *testing.T) {
func TestRecognitionIsOrderedByTierThenByWhatChecksItself(t *testing.T) {
state := store.New()
state.SetFeeds([]string{"https://news", "https://usual"})
- state.SetTiers(map[string]string{"https://news": store.TierTopical})
+ state.SetCategories(map[string]string{"https://news": store.CategoryNews})
state.Merge("https://usual", "Usual", nil)
state.Merge("https://news", "News", nil)