Add the calendar view (M10)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 22:39:39 +02:00
co-authored by Claude Sonnet 5.5
parent 7582504268
commit cd5786ba78
13 changed files with 756 additions and 30 deletions
+41 -7
View File
@@ -41,6 +41,8 @@ type App struct {
overlays []overlay // dialogs and full-screen views stacked above the pages
services *tview.TextView
topBar *tview.Flex // tab bar and connection indicators
narrow bool // terminal too narrow for full service names
state map[string]serviceState // keyed by service name
probes []probe
@@ -79,7 +81,7 @@ type overlay struct {
page string
focus tview.Primitive
hints string
full bool // full-screen view (can be left with F1-F5), as opposed to a dialog
full bool // full-screen view (can be left with F1-F6), as opposed to a dialog
close func() // optional cleanup when the overlay is removed
}
@@ -117,6 +119,7 @@ func New(cfg *config.Config) *App {
}},
{"SABnzbd", sabClient.Version},
}
calendar := newCalendarView(a, radarrClient, sonarrClient)
library := newLibraryView(a, radarrClient, sonarrClient)
downloads := newDownloadsView(a, sabClient,
[]arrSource{
@@ -129,16 +132,18 @@ func New(cfg *config.Config) *App {
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints},
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
{key: '5', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
{key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
{key: '6', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
}
for i, v := range a.views {
a.pages.AddPage(v.name, v.body, true, i == 0)
}
a.topBar = tview.NewFlex().
AddItem(a.tabs, 0, 1, false).
AddItem(a.services, servicesWidth(false), 0, false)
layout := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewFlex().
AddItem(a.tabs, 0, 1, false).
AddItem(a.services, 25, 0, false), 1, 0, false).
AddItem(a.topBar, 1, 0, false).
AddItem(a.pages, 0, 1, true).
AddItem(a.status, 1, 0, false)
@@ -171,10 +176,35 @@ func (a *App) SetService(name, version string, err error) {
a.renderServices()
}
// servicesWidth is the width of the connection indicators.
func servicesWidth(narrow bool) int {
if narrow {
return 14
}
return 25
}
// setNarrow switches the indicators between full names and initials; called
// from the draw handler when the terminal width crosses the threshold.
func (a *App) setNarrow(narrow bool) {
if narrow == a.narrow {
return
}
a.narrow = narrow
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
a.renderServices()
}
func (a *App) renderServices() {
var s string
for _, n := range serviceNames {
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
if a.narrow {
short = n[:1]
if n == "SABnzbd" {
short = "SAB"
}
}
st := a.state[n]
switch {
case !st.known:
@@ -254,7 +284,7 @@ func (a *App) renderStatus() {
if h := a.views[a.active].hints; h != nil {
hints = h() + " · "
}
hints += "? help · F1-F5 · q quit"
hints += "? help · F1-F6 · q quit"
}
a.status.SetText("[gray]" + hints)
}
@@ -381,7 +411,7 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
return ev
}
// viewShortcut returns the view index for F1-F5, or -1.
// viewShortcut returns the view index for F1-F6, or -1.
func (a *App) viewShortcut(ev *tcell.EventKey) int {
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
return int(ev.Key() - tcell.KeyF1)
@@ -422,4 +452,8 @@ func libraryHints() string {
return "Tab list · f status · F props · / text · t monitor · s search · d remove"
}
func calendarHints() string {
return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload"
}
func logHints() string { return "↑↓ scroll · G follow · c clear" }
+2 -2
View File
@@ -22,7 +22,7 @@ func newTestApp(t *testing.T) *App {
func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
a := newTestApp(t)
for _, r := range "12345\t" {
for _, r := range "123456\t" {
key := tcell.NewEventKey(tcell.KeyRune, r, 0)
if r == '\t' {
key = tcell.NewEventKey(tcell.KeyTab, 0, 0)
@@ -32,7 +32,7 @@ func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
t.Fatalf("key %q switched to view %d", r, a.active)
}
}
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5} {
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5, tcell.KeyF6} {
if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i {
t.Errorf("F%d: active view %d, want %d", i+1, a.active, i)
}
+466
View File
@@ -0,0 +1,466 @@
package ui
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
const (
calendarDays = 14 // length of the window
calendarStep = 7 // `[` and `]` move by this many days
)
// calEntry is one line of the calendar: a movie release or an episode airing.
type calEntry struct {
day time.Time // local midnight of the day
at time.Time // time of day, zero for date-only entries (movie releases)
movie bool
title string // plain title, for sorting
label string // escaped title for display
sub string // "S02E05 Welcome" or "Digital release"
have bool
monitored bool
missing bool // aired already and not downloaded
details detailText
cover coverRef
movieID, episodeID int
}
func (e calEntry) key() string {
if e.movie {
return fmt.Sprintf("m%d/%s/%s", e.movieID, e.sub, e.day.Format("2006-01-02"))
}
return fmt.Sprintf("e%d", e.episodeID)
}
// localDay returns midnight of the date of t in the local zone.
func localDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
}
// releaseDay reads a movie release date. Radarr sends these as midnight UTC
// timestamps meaning "this date", so the date is taken as written and not
// shifted into the local zone.
func releaseDay(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, false
}
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), true
}
// movieEntries returns an entry for every release date of the movie that falls
// into [from, to] (local days).
func movieEntries(m radarr.Movie, from, to time.Time, cover coverRef, now time.Time) []calEntry {
var out []calEntry
for _, rel := range []struct{ name, date string }{
{"In cinemas", m.InCinemas}, {"Digital release", m.DigitalRelease}, {"Physical release", m.PhysicalRelease},
} {
day, ok := releaseDay(rel.date)
if !ok || day.Before(from) || day.After(to) {
continue
}
label := tview.Escape(m.Title)
if m.Year > 0 {
label += fmt.Sprintf(" (%d)", m.Year)
}
d := movieDetails(m)
d.head += "Release: " + rel.name + ", " + day.Format("Mon 2 Jan 2006") + "\n"
out = append(out, calEntry{
day: day, movie: true, title: m.Title, label: label, sub: rel.name,
have: m.HasFile, monitored: m.Monitored, missing: m.Monitored && !m.HasFile && !day.After(now),
details: d, cover: cover, movieID: m.ID,
})
}
return out
}
// episodeEntry converts an airing episode, or reports false if it has no usable date.
func episodeEntry(e sonarr.Episode, from, to time.Time, cover coverRef, now time.Time) (calEntry, bool) {
var day, at time.Time
if t, err := time.Parse(time.RFC3339, e.AirDateUTC); err == nil {
at = t.Local()
day = localDay(at)
} else if t, err := time.Parse("2006-01-02", e.AirDate); err == nil {
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
} else {
return calEntry{}, false
}
if day.Before(from) || day.After(to) {
return calEntry{}, false
}
series := "Unknown series"
var overview string
if e.Series != nil {
series = e.Series.Title
overview = e.Series.Overview
}
if e.Overview != "" {
overview = e.Overview
}
sub := fmt.Sprintf("S%02dE%02d %s", e.SeasonNumber, e.EpisodeNumber, e.Title)
head := "[::b]" + tview.Escape(series) + "[::-]\n" + tview.Escape(sub) + "\n"
if !at.IsZero() {
head += "Airs " + at.Format("Mon 2 Jan 15:04") + "\n"
} else {
head += "Airs " + day.Format("Mon 2 Jan") + "\n"
}
switch {
case e.HasFile:
head += "[green]Downloaded[-]\n"
case !e.Monitored:
head += "[gray]Not monitored[-]\n"
default:
head += "[yellow]Not downloaded[-]\n"
}
aired := !at.IsZero() && at.Before(now) || at.IsZero() && day.Before(localDay(now))
return calEntry{
day: day, at: at, title: series, label: tview.Escape(series), sub: sub,
have: e.HasFile, monitored: e.Monitored, missing: e.Monitored && !e.HasFile && aired,
details: detailText{head: head, plot: tview.Escape(overview)}, cover: cover,
episodeID: e.ID,
}, true
}
// sortEntries orders by day, then time of day (date-only first), then title.
func sortEntries(es []calEntry) {
slices.SortStableFunc(es, func(a, b calEntry) int {
return cmp.Or(
a.day.Compare(b.day),
a.at.Compare(b.at),
strings.Compare(strings.ToLower(a.title), strings.ToLower(b.title)),
strings.Compare(a.sub, b.sub),
)
})
}
// dayLabel returns the date of a day and, for days near today, its relative
// name: ("Wed 21 Oct", "Tomorrow").
func dayLabel(day, today time.Time) (date, relative string) {
date = day.Format("Mon 2 Jan")
switch int(day.Sub(today).Round(24*time.Hour).Hours() / 24) { // Round: days are 23 or 25 hours long when the clocks change
case 0:
return date, "Today"
case 1:
return date, "Tomorrow"
case -1:
return date, "Yesterday"
}
return date, ""
}
// calendarView lists the movies and episodes coming up in a two-week window.
type calendarView struct {
app *App
radarr *radarr.Client
sonarr *sonarr.Client
root *tview.Flex
header *tview.TextView
table *tview.Table
details *detailPane
// UI-goroutine state.
offset int // days from today to the first day shown; moved with [ and ]
unmonitored bool
entries []calEntry
rows []calRow
loaded bool
gen int
}
// calRow is a table row: a day header (entry == nil) or an entry.
type calRow struct {
day time.Time
entry *calEntry
}
func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView {
v := &calendarView{
app: app, radarr: r, sonarr: s,
header: tview.NewTextView().SetDynamicColors(true),
table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0),
details: newDetailPane(app),
}
v.table.SetBorder(true).SetTitle(" Upcoming ")
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(tview.NewFlex().
AddItem(v.table, 0, 3, true).
AddItem(v.details, 0, 2, false), 0, 1, true)
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
v.table.SetSelectedFunc(func(int, int) { v.open() })
v.table.SetInputCapture(v.handleKey)
v.render()
return v
}
func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() != tcell.KeyRune {
return ev
}
switch ev.Rune() {
case '[':
v.offset -= calendarStep
v.reload()
case ']':
v.offset += calendarStep
v.reload()
case 't':
v.offset = 0
v.reload()
case 'u':
v.unmonitored = !v.unmonitored
v.reload()
case 'r':
v.reload()
case 'i':
v.open()
case 's':
v.search()
default:
return ev
}
return nil
}
// window returns the first and last day shown.
func (v *calendarView) window(now time.Time) (from, to time.Time) {
from = localDay(now).AddDate(0, 0, v.offset)
return from, from.AddDate(0, 0, calendarDays-1)
}
func (v *calendarView) activate() { v.reload() }
// reload fetches both calendars in parallel.
func (v *calendarView) reload() {
v.gen++
gen, unmonitored := v.gen, v.unmonitored
now := time.Now()
from, to := v.window(now)
v.render()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var (
wg sync.WaitGroup
movies []radarr.Movie
episodes []sonarr.Episode
movieErr, tvErr error
)
wg.Add(2)
go func() { defer wg.Done(); movies, movieErr = v.radarr.Calendar(ctx, from, to, unmonitored) }()
go func() { defer wg.Done(); episodes, tvErr = v.sonarr.Calendar(ctx, from, to, unmonitored) }()
wg.Wait()
var entries []calEntry
for _, m := range movies {
entries = append(entries, movieEntries(m, from, to, coverFor(v.radarr.Client, m.Images, true), now)...)
}
for _, e := range episodes {
var cover coverRef
if e.Series != nil {
cover = coverFor(v.sonarr.Client, e.Series.Images, true)
}
if entry, ok := episodeEntry(e, from, to, cover, now); ok {
entries = append(entries, entry)
}
}
sortEntries(entries)
v.app.post(func() {
v.app.SetService("Radarr", "", movieErr)
v.app.SetService("Sonarr", "", tvErr)
if gen != v.gen {
return
}
for _, err := range []error{movieErr, tvErr} {
if err != nil {
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
}
}
if movieErr == nil || tvErr == nil { // show what we got
v.entries, v.loaded = entries, true
}
v.render()
})
}()
}
func (v *calendarView) selected() (*calEntry, bool) {
r, _ := v.table.GetSelection()
if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil {
return nil, false
}
return v.rows[r].entry, true
}
func (v *calendarView) render() {
selKey := ""
if e, ok := v.selected(); ok {
selKey = e.key()
}
now := time.Now()
today := localDay(now)
from, to := v.window(now)
v.rows = v.rows[:0]
var lastDay time.Time
for i := range v.entries {
e := &v.entries[i]
if !e.day.Equal(lastDay) {
v.rows = append(v.rows, calRow{day: e.day})
lastDay = e.day
}
v.rows = append(v.rows, calRow{day: e.day, entry: e})
}
v.table.Clear()
selRow, firstEntry := 0, -1
for i, r := range v.rows {
if r.entry == nil {
date, relative := dayLabel(r.day, today)
v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false))
v.table.SetCell(i, 1, tview.NewTableCell("").SetSelectable(false))
v.table.SetCell(i, 2, tview.NewTableCell("[yellow]"+relative).SetSelectable(false))
v.table.SetCell(i, 3, tview.NewTableCell("").SetSelectable(false))
continue
}
e := r.entry
if firstEntry < 0 {
firstEntry = i
}
when := " "
if !e.at.IsZero() {
when = e.at.Format("15:04") + " "
}
kind := "[blue]TV "
if e.movie {
kind = "[purple]Movie"
}
v.table.SetCell(i, 0, tview.NewTableCell(" "+when).SetSelectable(true))
v.table.SetCell(i, 1, tview.NewTableCell(kind+"[-]"))
v.table.SetCell(i, 2, tview.NewTableCell(e.label+" [gray]"+tview.Escape(e.sub)).SetExpansion(1))
v.table.SetCell(i, 3, tview.NewTableCell(calStatus(*e)))
if selKey != "" && e.key() == selKey {
selRow = i
}
}
title := " Upcoming · " + from.Format("2 Jan") + " – " + to.Format("2 Jan") + " "
if v.unmonitored {
title += "· incl. unmonitored "
}
v.table.SetTitle(title)
switch {
case len(v.entries) == 0:
msg := "Loading…"
if v.loaded {
msg = "Nothing in this period"
}
v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
v.table.SetSelectable(false, false)
v.table.Select(0, 0)
default:
v.table.SetSelectable(true, false)
if selRow == 0 {
selRow = firstEntry
}
v.table.Select(selRow, 0)
if selRow == firstEntry {
v.table.ScrollToBeginning()
}
}
v.header.SetText("[::b]Calendar[::-] · [ ] move by a week · t today · u unmonitored · Enter releases · s search")
v.renderDetails()
}
// calStatus is the coloured status text of an entry.
func calStatus(e calEntry) string {
switch {
case e.have:
return "[green]✓ downloaded"
case !e.monitored:
return "[gray]unmonitored"
case e.missing:
return "[yellow]missing"
}
return "[gray]upcoming"
}
func (v *calendarView) renderDetails() {
if e, ok := v.selected(); ok {
v.details.Show(e.details, e.cover)
return
}
v.details.Clear()
}
// open starts the interactive release search for the selected entry.
func (v *calendarView) open() {
e, ok := v.selected()
if !ok {
return
}
if e.movie {
id, title := e.movieID, tview.Escape(e.title)
v.app.openReleases("Releases for "+title,
func(ctx context.Context) ([]arr.Release, error) { return v.radarr.Releases(ctx, id) },
v.radarr.Grab)
return
}
id := e.episodeID
v.app.openReleases("Releases for "+e.label+" "+tview.Escape(e.sub),
func(ctx context.Context) ([]arr.Release, error) { return v.sonarr.EpisodeReleases(ctx, id) },
v.sonarr.Grab)
}
// search starts an automatic search for the selected entry.
func (v *calendarView) search() {
e, ok := v.selected()
if !ok {
return
}
what := e.label
if !e.movie {
what += " " + tview.Escape(e.sub)
}
v.app.SetMessage("Searching for " + what + "…")
movie, movieID, episodeID := e.movie, e.movieID, e.episodeID
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var err error
if movie {
err = v.radarr.SearchMovie(ctx, movieID)
} else {
err = v.sonarr.SearchEpisode(ctx, episodeID)
}
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]" + tview.Escape(err.Error()))
return
}
v.app.SetMessage("[green]Search started for " + what)
})
}()
}
+126
View File
@@ -0,0 +1,126 @@
package ui
import (
"strings"
"testing"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.Local) }
func TestMovieEntriesPerReleaseDate(t *testing.T) {
from, to := day(2026, 10, 20), day(2026, 11, 2)
now := day(2026, 10, 21).Add(10 * time.Hour)
m := radarr.Movie{
ID: 4, Title: "Dune [Part 2]", Year: 2024, Monitored: true,
InCinemas: "2026-10-22T00:00:00Z", // inside
DigitalRelease: "2026-11-01T00:00:00Z", // inside
// physical release is after the window
PhysicalRelease: "2026-12-01T00:00:00Z",
}
es := movieEntries(m, from, to, coverRef{}, now)
if len(es) != 2 {
t.Fatalf("%d entries, want 2: %+v", len(es), es)
}
if es[0].sub != "In cinemas" || !es[0].day.Equal(day(2026, 10, 22)) || es[1].sub != "Digital release" || !es[1].day.Equal(day(2026, 11, 1)) {
t.Errorf("entries = %+v", es)
}
if !es[0].movie || es[0].missing || !strings.Contains(es[0].label, "Dune [Part 2[]") {
t.Errorf("flags/label wrong: %+v", es[0])
}
if es[0].key() == es[1].key() {
t.Error("the same movie on two dates must have distinct keys")
}
if !strings.Contains(es[0].details.head, "Release: In cinemas, Thu 22 Oct 2026") {
t.Errorf("details: %q", es[0].details.head)
}
// A release that already happened and is not downloaded counts as missing.
past := movieEntries(radarr.Movie{ID: 5, Title: "Old", Monitored: true, DigitalRelease: "2026-10-20T00:00:00Z"}, from, to, coverRef{}, now)
if len(past) != 1 || !past[0].missing {
t.Errorf("past release: %+v", past)
}
// Dates are taken as written: midnight UTC stays on that calendar day in every zone.
if es := movieEntries(radarr.Movie{ID: 6, Title: "Z", InCinemas: "2026-10-25T00:00:00Z"}, from, to, coverRef{}, now); len(es) != 1 || es[0].day.Day() != 25 {
t.Errorf("timezone shifted the release date: %+v", es)
}
if es := movieEntries(radarr.Movie{ID: 7, Title: "NoDates"}, from, to, coverRef{}, now); len(es) != 0 {
t.Errorf("movie without dates: %+v", es)
}
}
func TestEpisodeEntry(t *testing.T) {
from, to := day(2026, 10, 20), day(2026, 11, 2)
now := day(2026, 10, 21).Add(12 * time.Hour)
at := time.Date(2026, 10, 22, 19, 30, 0, 0, time.UTC)
e := sonarr.Episode{
ID: 9, SeasonNumber: 2, EpisodeNumber: 5, Title: "Welcome", Monitored: true,
AirDateUTC: at.Format(time.RFC3339), Overview: "Episode plot",
Series: &sonarr.Series{Title: "Andor", Overview: "Series plot"},
}
got, ok := episodeEntry(e, from, to, coverRef{}, now)
if !ok {
t.Fatal("episode in the window rejected")
}
if got.sub != "S02E05 Welcome" || got.title != "Andor" || got.movie || got.episodeID != 9 || got.at.IsZero() {
t.Errorf("entry = %+v", got)
}
if !got.day.Equal(localDay(at.Local())) {
t.Errorf("day = %v, want the local date of the airing", got.day)
}
if got.details.plot != "Episode plot" || !strings.Contains(got.details.head, "Andor") || !strings.Contains(got.details.head, "S02E05") {
t.Errorf("details = %+v", got.details)
}
if got.missing {
t.Error("an episode that has not aired yet is not missing")
}
// Falls back to the series plot, and to the date-only air date.
e2 := sonarr.Episode{ID: 10, Title: "X", AirDate: "2026-10-19", Monitored: true, Series: &sonarr.Series{Title: "S", Overview: "Series plot"}}
if _, ok := episodeEntry(e2, from, to, coverRef{}, now); ok {
t.Error("an episode before the window must be left out")
}
e2.AirDate = "2026-10-20"
got, ok = episodeEntry(e2, from, to, coverRef{}, now)
if !ok || got.details.plot != "Series plot" || !got.at.IsZero() || !got.missing {
t.Errorf("date-only episode: ok=%v %+v", ok, got)
}
if _, ok := episodeEntry(sonarr.Episode{ID: 11}, from, to, coverRef{}, now); ok {
t.Error("an episode without any date has no place in the calendar")
}
}
func TestSortEntriesAndDayLabel(t *testing.T) {
d1, d2 := day(2026, 10, 21), day(2026, 10, 22)
es := []calEntry{
{day: d2, title: "b"},
{day: d1, at: d1.Add(20 * time.Hour), title: "late"},
{day: d1, title: "dateonly"},
{day: d1, at: d1.Add(9 * time.Hour), title: "early"},
{day: d1, at: d1.Add(9 * time.Hour), title: "Alpha"},
}
sortEntries(es)
var got []string
for _, e := range es {
got = append(got, e.title)
}
if strings.Join(got, ",") != "dateonly,Alpha,early,late,b" {
t.Errorf("order = %v", got)
}
today := day(2026, 10, 21)
type label struct{ date, rel string }
for d, want := range map[time.Time]label{
today: {"Wed 21 Oct", "Today"},
today.AddDate(0, 0, 1): {"Thu 22 Oct", "Tomorrow"},
today.AddDate(0, 0, -1): {"Tue 20 Oct", "Yesterday"},
today.AddDate(0, 0, 5): {"Mon 26 Oct", ""},
} {
if date, rel := dayLabel(d, today); (label{date, rel}) != want {
t.Errorf("dayLabel(%v) = %q, %q, want %v", d, date, rel, want)
}
}
}
+9 -2
View File
@@ -6,7 +6,7 @@ import (
)
const helpText = `[yellow]Global[-]
F1-F5 switch view (also from the release and episode lists)
F1-F6 switch view (also from the release and episode lists)
Tab, Shift+Tab switch panel within a view
?, F9 this help (F9 works while typing)
q, Ctrl+C quit
@@ -37,6 +37,13 @@ const helpText = `[yellow]Global[-]
i, Enter movie: release search; series: seasons and episodes
r reload
[yellow]Calendar (F5)[-]
[ ] move the two-week window back / forward by a week
t back to today
u include unmonitored items
Enter, i release search for the selected movie / episode
s automatic search r reload
[yellow]Seasons and episodes[-]
Enter season: expand/collapse; episode: release search
i, s, t release search, automatic search, toggle monitored
@@ -56,7 +63,7 @@ const helpText = `[yellow]Global[-]
t retry a failed download (history)
r refresh now
[yellow]Log (F5)[-]
[yellow]Log (F6)[-]
Popups fade away by themselves; everything they said is kept here.
↑↓, PgUp/PgDn scroll G follow new entries c clear
+6
View File
@@ -115,7 +115,13 @@ type toastRoot struct {
app *App
}
// narrowWidth is the terminal width below which the six tabs and the full
// service names no longer fit on one line.
const narrowWidth = 92
func (r *toastRoot) Draw(screen tcell.Screen) {
w, _ := screen.Size()
r.app.setNarrow(w < narrowWidth)
r.Flex.Draw(screen)
a := r.app
now := time.Now()