feat: Stage 4 — on-call schedule calendar view

Adds a 14-day schedule list accessible via the Schedule tab.

Keybindings in schedule section:
  ←/→ (or h/l)  shift the 2-week window back/forward by one week
  j/k            navigate rows
  +              open user picker — select a user to assign to the date
  d              delete the selected day's assignment (y/N confirmation)
  r              refresh schedule from server

The user picker fetches the user list from the server on first open
(cached for the session). Selecting a user assigns them to the
highlighted date (POST /api/schedule all-or-nothing; 409 conflicts
surface as a status bar error).

The on-call header shows today's scheduled person and the current
window date range. On-call data refreshes on schedule actions.

API additions: GetSchedule, GetCurrentOnCall, AssignSchedule,
DeleteScheduleEntry, ListUsers. ScheduleEntry and User types added.
This commit is contained in:
Niklas Ye
2026-05-21 13:56:57 +02:00
parent b5ee62f145
commit ba7a862789
5 changed files with 597 additions and 63 deletions
+231 -30
View File
@@ -13,6 +13,8 @@ import (
"github.com/yeniklas/terdut-tui/internal/api"
)
// ── Enums ──────────────────────────────────────────────────────────────────
type section int
const (
@@ -29,10 +31,19 @@ const (
modeComment
modeConfirmDelete
modeStats
modeScheduleUserPicker
)
// tea.Msg types — dashboard
type confirmTarget int
const (
confirmDeleteComment confirmTarget = iota
confirmDeleteScheduleEntry
)
// ── Messages ───────────────────────────────────────────────────────────────
// dashboard
type connectedMsg struct{}
type connectErrMsg struct{ err error }
type alertsFetchedMsg struct{ alerts []api.Alert }
@@ -41,8 +52,7 @@ type fetchDataErrMsg struct{ err error }
type tickMsg time.Time
type clearStatusMsg struct{}
// tea.Msg types — detail
// detail
type alertDetailFetchedMsg struct {
alert api.Alert
comments []api.Comment
@@ -56,7 +66,21 @@ type detailStatsFetchedMsg struct {
}
type detailStatsErrMsg struct{ err error }
// Model holds all UI state.
// schedule
type scheduleFetchedMsg struct {
entries []api.ScheduleEntry
current *api.ScheduleEntry
}
type scheduleFetchErrMsg struct{ err error }
type scheduleActionErrMsg struct{ err error }
type usersFetchedMsg struct{ users []api.User }
// ── Model ──────────────────────────────────────────────────────────────────
type scheduleDay struct {
date time.Time
entry *api.ScheduleEntry
}
type Model struct {
client *api.Client
@@ -78,7 +102,7 @@ type Model struct {
filterStatus string
alertTable table.Model
// Detail view
// Detail
selectedAlert api.Alert
comments []api.Comment
commentCursor int
@@ -89,33 +113,53 @@ type Model struct {
commentInput textinput.Model
// Confirm delete
pendingDeleteID int64
confirmTarget confirmTarget
pendingDeleteID int64 // comment ID
pendingDeleteEntry *api.ScheduleEntry
// Stats view
topAlerts []api.TopAlert
hourStats []api.HourStat
dayStats []api.DayStat
statsLoading bool
// Stats
topAlerts []api.TopAlert
hourStats []api.HourStat
dayStats []api.DayStat
statsLoading bool
statsViewport viewport.Model
// Schedule
scheduleWindow time.Time
scheduleEntries []api.ScheduleEntry
scheduleDays []scheduleDay
currentOnCall *api.ScheduleEntry
scheduleLoading bool
scheduleTable table.Model
// User picker (schedule assignment)
users []api.User
usersLoading bool
userPickerTable table.Model
help help.Model
keys keyMap
}
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
t := table.New(table.WithFocused(true))
s := table.DefaultStyles()
s.Header = s.Header.Bold(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Bold(true)
t.SetStyles(s)
ts := defaultTableStyles()
alertT := table.New(table.WithFocused(true))
alertT.SetStyles(ts)
schedT := table.New(table.WithFocused(true))
schedT.SetStyles(ts)
pickerT := table.New(table.WithFocused(true))
pickerT.SetStyles(ts)
ti := textinput.New()
ti.Placeholder = "type your comment…"
ti.CharLimit = 1000
now := time.Now().UTC()
window := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
return Model{
client: client,
serverURL: serverURL,
@@ -125,8 +169,11 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
loading: true,
filterStatus: "firing",
commentCursor: -1,
alertTable: t,
alertTable: alertT,
commentInput: ti,
scheduleWindow: window,
scheduleTable: schedT,
userPickerTable: pickerT,
help: help.New(),
keys: keys,
}
@@ -136,7 +183,18 @@ func (m Model) Init() tea.Cmd {
return connectCmd(m.client)
}
// rebuildTable updates alert table columns, rows, and height from current state.
// ── Table rebuilders ───────────────────────────────────────────────────────
func defaultTableStyles() table.Styles {
s := table.DefaultStyles()
s.Header = s.Header.Bold(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Bold(true)
return s
}
func (m *Model) rebuildTable() {
m.alertTable.SetColumns(alertColumns(m.width))
m.alertTable.SetRows(alertRows(m.alerts))
@@ -147,26 +205,45 @@ func (m *Model) rebuildTable() {
m.alertTable.SetHeight(h)
}
// refreshDetailContent rebuilds the viewport content from selectedAlert + comments.
func (m *Model) rebuildScheduleTable() {
m.scheduleTable.SetColumns(scheduleColumns(m.width))
m.scheduleTable.SetRows(scheduleRows(m.scheduleDays))
h := m.height - 10
if h < 1 {
h = 1
}
m.scheduleTable.SetHeight(h)
}
func (m *Model) rebuildUserPickerTable() {
m.userPickerTable.SetColumns(userPickerColumns(m.width))
rows := make([]table.Row, len(m.users))
for i, u := range m.users {
rows[i] = table.Row{u.Username, u.Email}
}
m.userPickerTable.SetRows(rows)
h := m.height - 10
if h < 1 {
h = 1
}
m.userPickerTable.SetHeight(h)
}
func (m *Model) refreshDetailContent() {
if m.width == 0 {
return
}
content := buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width)
m.detailViewport.SetContent(content)
m.detailViewport.SetContent(buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width))
}
// refreshStatsContent rebuilds the stats viewport from loaded stats data.
func (m *Model) refreshStatsContent() {
content := buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width)
m.statsViewport.SetContent(content)
m.statsViewport.SetContent(buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width))
}
// detailViewportHeight returns the detail viewport height for the current mode.
func (m Model) detailViewportHeight() int {
h := m.height - 5
if m.mode == modeComment {
h -= 2 // separator + input line
h -= 2
}
if h < 1 {
h = 1
@@ -174,6 +251,8 @@ func (m Model) detailViewportHeight() int {
return h
}
// ── Column definitions ─────────────────────────────────────────────────────
func alertColumns(width int) []table.Column {
nameW := width/2 - 8
if nameW < 20 {
@@ -191,6 +270,32 @@ func alertColumns(width int) []table.Column {
}
}
func scheduleColumns(width int) []table.Column {
dateW := 16
onCallW := width - dateW - 6
if onCallW < 15 {
onCallW = 15
}
return []table.Column{
{Title: "Date", Width: dateW},
{Title: "On-Call", Width: onCallW},
}
}
func userPickerColumns(width int) []table.Column {
usernameW := 25
emailW := width - usernameW - 6
if emailW < 15 {
emailW = 15
}
return []table.Column{
{Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW},
}
}
// ── Row builders ───────────────────────────────────────────────────────────
func alertRows(alerts []api.Alert) []table.Row {
now := time.Now()
rows := make([]table.Row, len(alerts))
@@ -200,6 +305,44 @@ func alertRows(alerts []api.Alert) []table.Row {
return rows
}
func scheduleRows(days []scheduleDay) []table.Row {
today := time.Now().UTC().Format("2006-01-02")
rows := make([]table.Row, len(days))
for i, d := range days {
dateStr := d.date.Format("2006-01-02")
label := d.date.Format("Jan 02 Mon")
if dateStr == today {
label = "Today " + d.date.Format("Mon")
}
onCall := "—"
if d.entry != nil {
onCall = d.entry.Username
}
rows[i] = table.Row{label, onCall}
}
return rows
}
func buildScheduleDays(window time.Time, entries []api.ScheduleEntry) []scheduleDay {
entryMap := make(map[string]api.ScheduleEntry, len(entries))
for _, e := range entries {
entryMap[e.Date] = e
}
days := make([]scheduleDay, 14)
for i := range days {
date := window.AddDate(0, 0, i)
day := scheduleDay{date: date}
if e, ok := entryMap[date.Format("2006-01-02")]; ok {
e2 := e
day.entry = &e2
}
days[i] = day
}
return days
}
// ── Helpers ────────────────────────────────────────────────────────────────
func humanAgo(now, t time.Time) string {
d := now.Sub(t)
if d < 0 {
@@ -227,7 +370,7 @@ func humanAgo(now, t time.Time) string {
}
}
// Command constructors
// ── Commands ───────────────────────────────────────────────────────────────
func connectCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
@@ -355,6 +498,64 @@ func fetchDetailStatsCmd(client *api.Client) tea.Cmd {
}
}
func fetchScheduleCmd(client *api.Client, from, to time.Time) tea.Cmd {
return func() tea.Msg {
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
if err != nil {
return scheduleFetchErrMsg{err}
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleFetchErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
}
}
func assignScheduleCmd(client *api.Client, userID int64, date string, from, to time.Time) tea.Cmd {
return func() tea.Msg {
if _, err := client.AssignSchedule(userID, []string{date}); err != nil {
return scheduleActionErrMsg{err}
}
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
if err != nil {
return scheduleActionErrMsg{err}
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleActionErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
}
}
func deleteScheduleEntryCmd(client *api.Client, entryID int64, from, to time.Time) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteScheduleEntry(entryID); err != nil {
return scheduleActionErrMsg{err}
}
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
if err != nil {
return scheduleActionErrMsg{err}
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleActionErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
}
}
func fetchUsersCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
users, err := client.ListUsers()
if err != nil {
return usersFetchedMsg{} // empty on error, statusMsg set elsewhere
}
return usersFetchedMsg{users: users}
}
}
func tickCmd(interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(t time.Time) tea.Msg {
return tickMsg(t)