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
+68
View File
@@ -187,6 +187,74 @@ func (c *Client) GetStatsByDay() ([]DayStat, error) {
return result, c.do(req, &result)
}
func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/schedule?from=%s&to=%s", from, to))
if err != nil {
return nil, err
}
var entries []ScheduleEntry
return entries, c.do(req, &entries)
}
// GetCurrentOnCall returns today's on-call entry, or nil if nobody is scheduled.
func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) {
req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 400 {
var e struct{ Error string `json:"error"` }
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" {
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
}
var entry ScheduleEntry
if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil {
return nil, err
}
return &entry, nil
}
func (c *Client) AssignSchedule(userID int64, dates []string) ([]ScheduleEntry, error) {
body := struct {
UserID int64 `json:"user_id"`
Dates []string `json:"dates"`
}{UserID: userID, Dates: dates}
req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body)
if err != nil {
return nil, err
}
var entries []ScheduleEntry
return entries, c.do(req, &entries)
}
func (c *Client) DeleteScheduleEntry(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/schedule/%d", id))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) ListUsers() ([]User, error) {
req, err := c.newRequest(http.MethodGet, "/api/users")
if err != nil {
return nil, err
}
var users []User
return users, c.do(req, &users)
}
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway).
func (c *Client) HealthCheck() error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
+15
View File
@@ -48,3 +48,18 @@ type DayStat struct {
DayName string `json:"day_name"`
Count int `json:"count"`
}
type ScheduleEntry struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Date string `json:"date"` // YYYY-MM-DD
CreatedAt time.Time `json:"created_at"`
}
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
+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)
+200 -27
View File
@@ -13,6 +13,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.rebuildTable()
m.rebuildScheduleTable()
m.rebuildUserPickerTable()
m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight()
m.statsViewport.Width = m.width
@@ -21,7 +23,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshStatsContent()
return m, nil
// Dashboard messages
// ── Dashboard messages ────────────────────────────────────────────────
case connectedMsg:
m.connected = true
m.err = nil
@@ -57,7 +60,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
fetchStatsCmd(m.client),
)
// Detail messages
// ── Detail messages ───────────────────────────────────────────────────
case alertDetailFetchedMsg:
m.selectedAlert = msg.alert
m.comments = msg.comments
@@ -91,6 +95,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.mode = modeDetail
return m, clearStatusCmd()
// ── Schedule messages ─────────────────────────────────────────────────
case scheduleFetchedMsg:
m.scheduleEntries = msg.entries
m.currentOnCall = msg.current
m.scheduleDays = buildScheduleDays(m.scheduleWindow, msg.entries)
m.scheduleLoading = false
m.rebuildScheduleTable()
return m, nil
case scheduleFetchErrMsg:
m.scheduleLoading = false
m.statusMsg = "schedule error: " + msg.err.Error()
return m, clearStatusCmd()
case scheduleActionErrMsg:
m.scheduleLoading = false
m.statusMsg = "error: " + msg.err.Error()
return m, clearStatusCmd()
case usersFetchedMsg:
m.users = msg.users
m.usersLoading = false
m.rebuildUserPickerTable()
return m, nil
// ── Common ────────────────────────────────────────────────────────────
case clearStatusMsg:
m.statusMsg = ""
return m, nil
@@ -102,15 +134,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// routeKey passes the key event to the appropriate component then our handler.
// routeKey passes the key to the active component then to our handler.
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch m.mode {
case modeDetail, modeConfirmDelete:
case modeDetail:
var vpCmd tea.Cmd
m.detailViewport, vpCmd = m.detailViewport.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(vpCmd, ourCmd)
case modeConfirmDelete:
// No component to scroll; just handle y/n.
return m.handleKey(msg)
case modeComment:
var inputCmd tea.Cmd
m.commentInput, inputCmd = m.commentInput.Update(msg)
@@ -123,12 +159,26 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(vpCmd, ourCmd)
case modeScheduleUserPicker:
var tableCmd tea.Cmd
m.userPickerTable, tableCmd = m.userPickerTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
default: // modeDashboard
if m.activeSection == sectionAlerts && m.connected {
var tableCmd tea.Cmd
m.alertTable, tableCmd = m.alertTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
if m.connected {
switch m.activeSection {
case sectionAlerts:
var tableCmd tea.Cmd
m.alertTable, tableCmd = m.alertTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case sectionSchedule:
var tableCmd tea.Cmd
m.scheduleTable, tableCmd = m.scheduleTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
}
}
return m.handleKey(msg)
}
@@ -144,24 +194,41 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleConfirmKey(msg)
case modeStats:
return m.handleStatsKey(msg)
case modeScheduleUserPicker:
return m.handleUserPickerKey(msg)
default:
return m.handleDashboardKey(msg)
}
}
// ── Dashboard ─────────────────────────────────────────────────────────────
func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "tab":
m.activeSection = (m.activeSection + 1) % 3
next := section((int(m.activeSection) + 1) % 3)
m.activeSection = next
if next == sectionSchedule && len(m.scheduleDays) == 0 {
m.scheduleLoading = true
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
return m, fetchScheduleCmd(m.client, from, to)
}
return m, nil
case "r":
if !m.connected {
return m, connectCmd(m.client)
}
if m.activeSection == sectionSchedule {
m.scheduleLoading = true
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
return m, fetchScheduleCmd(m.client, from, to)
}
m.statusMsg = "Refreshing…"
return m, tea.Batch(
fetchAlertsCmd(m.client, m.filterStatus),
@@ -185,24 +252,76 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, fetchAlertsCmd(m.client, m.filterStatus)
case "enter":
if m.activeSection != sectionAlerts || len(m.alerts) == 0 {
if m.activeSection == sectionAlerts && len(m.alerts) > 0 {
cursor := m.alertTable.Cursor()
if cursor < len(m.alerts) {
m.selectedAlert = m.alerts[cursor]
m.mode = modeDetail
m.commentCursor = -1
m.detailLoading = true
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
}
}
return m, nil
// Schedule-specific keys
case "left", "h":
if m.activeSection == sectionSchedule {
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
m.scheduleLoading = true
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
return m, fetchScheduleCmd(m.client, from, to)
}
return m, nil
case "right", "l":
if m.activeSection == sectionSchedule {
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
m.scheduleLoading = true
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
return m, fetchScheduleCmd(m.client, from, to)
}
return m, nil
case "+":
if m.activeSection != sectionSchedule || !m.connected {
return m, nil
}
cursor := m.alertTable.Cursor()
if cursor >= len(m.alerts) {
m.mode = modeScheduleUserPicker
if len(m.users) == 0 {
m.usersLoading = true
return m, fetchUsersCmd(m.client)
}
m.rebuildUserPickerTable()
return m, nil
case "d":
if m.activeSection != sectionSchedule || !m.connected {
return m, nil
}
m.selectedAlert = m.alerts[cursor]
m.mode = modeDetail
m.commentCursor = -1
m.detailLoading = true
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
cursor := m.scheduleTable.Cursor()
if cursor >= len(m.scheduleDays) {
return m, nil
}
day := m.scheduleDays[cursor]
if day.entry == nil {
m.statusMsg = "no assignment to delete on this date"
return m, clearStatusCmd()
}
m.pendingDeleteEntry = day.entry
m.confirmTarget = confirmDeleteScheduleEntry
m.mode = modeConfirmDelete
return m, nil
}
return m, nil
}
// ── Detail ────────────────────────────────────────────────────────────────
func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc", "backspace":
@@ -237,6 +356,7 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, clearStatusCmd()
}
m.pendingDeleteID = m.comments[m.commentCursor].ID
m.confirmTarget = confirmDeleteComment
m.mode = modeConfirmDelete
return m, nil
@@ -278,6 +398,8 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
// ── Comment compose ───────────────────────────────────────────────────────
func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
@@ -300,22 +422,43 @@ func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
// ── Confirm delete ────────────────────────────────────────────────────────
func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch strings.ToLower(msg.String()) {
case "y":
alertID := m.selectedAlert.ID
commentID := m.pendingDeleteID
m.mode = modeDetail
m.commentCursor = -1
m.pendingDeleteID = 0
return m, deleteCommentCmd(m.client, alertID, commentID)
switch m.confirmTarget {
case confirmDeleteComment:
alertID := m.selectedAlert.ID
commentID := m.pendingDeleteID
m.mode = modeDetail
m.commentCursor = -1
m.pendingDeleteID = 0
return m, deleteCommentCmd(m.client, alertID, commentID)
case confirmDeleteScheduleEntry:
entry := m.pendingDeleteEntry
m.mode = modeDashboard
m.pendingDeleteEntry = nil
m.scheduleLoading = true
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
return m, deleteScheduleEntryCmd(m.client, entry.ID, from, to)
}
default:
m.mode = modeDetail
switch m.confirmTarget {
case confirmDeleteComment:
m.mode = modeDetail
case confirmDeleteScheduleEntry:
m.mode = modeDashboard
}
m.pendingDeleteID = 0
return m, nil
m.pendingDeleteEntry = nil
}
return m, nil
}
// ── Stats ─────────────────────────────────────────────────────────────────
func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if msg.String() == "esc" {
m.mode = modeDetail
@@ -323,3 +466,33 @@ func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
return m, nil
}
// ── User picker ───────────────────────────────────────────────────────────
func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeDashboard
return m, nil
case "enter":
cursor := m.userPickerTable.Cursor()
if cursor >= len(m.users) {
return m, nil
}
user := m.users[cursor]
scheduleCursor := m.scheduleTable.Cursor()
if scheduleCursor >= len(m.scheduleDays) {
m.mode = modeDashboard
return m, nil
}
date := m.scheduleDays[scheduleCursor].date.Format("2006-01-02")
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
m.mode = modeDashboard
m.scheduleLoading = true
return m, assignScheduleCmd(m.client, user.ID, date, from, to)
}
return m, nil
}
+83 -6
View File
@@ -62,9 +62,14 @@ func (m Model) renderBody() string {
case modeComment:
return m.renderCommentCompose()
case modeConfirmDelete:
return m.renderDetail()
if m.confirmTarget == confirmDeleteComment {
return m.renderDetail()
}
return m.renderSchedule()
case modeStats:
return m.renderStats()
case modeScheduleUserPicker:
return m.renderUserPicker()
default:
return m.renderDashboard()
}
@@ -83,11 +88,22 @@ func (m Model) renderFooter() string {
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
case modeConfirmDelete:
commentInfo := ""
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
commentInfo = fmt.Sprintf(" by %s", m.comments[m.commentCursor].Username)
var deleteDesc string
switch m.confirmTarget {
case confirmDeleteComment:
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
deleteDesc = fmt.Sprintf("comment by %s", m.comments[m.commentCursor].Username)
} else {
deleteDesc = "comment"
}
case confirmDeleteScheduleEntry:
if m.pendingDeleteEntry != nil {
deleteDesc = fmt.Sprintf("on-call for %s (%s)", m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username)
} else {
deleteDesc = "schedule entry"
}
}
return "\n" + styleError.Render(fmt.Sprintf(" Delete comment%s? [y/N]", commentInfo))
return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc))
case modeStats:
if m.statusMsg != "" {
@@ -95,7 +111,17 @@ func (m Model) renderFooter() string {
}
return "\n" + styleFooter.Render(" esc·back")
case modeScheduleUserPicker:
return "\n" + styleFooter.Render(" j/k·navigate enter·select esc·cancel")
default:
if m.activeSection == sectionSchedule {
actions := styleFooter.Render(" +·assign d·del ←/→·shift week tab·section r·refresh q·quit")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
}
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
if m.statusMsg != "" {
status := styleStatus.Render(m.statusMsg)
@@ -116,13 +142,64 @@ func (m Model) renderDashboard() string {
case sectionAlerts:
return m.renderAlerts()
case sectionSchedule:
return "\n" + styleMuted.Render(" On-call schedule — coming in Stage 4")
return m.renderSchedule()
case sectionUsers:
return "\n" + styleMuted.Render(" User management — coming in Stage 5")
}
return ""
}
// ── Schedule ───────────────────────────────────────────────────────────────
func (m Model) renderSchedule() string {
if m.scheduleLoading {
return "\n" + styleMuted.Render(" Loading schedule…")
}
// On-call header
var onCallLine string
if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s",
styleAlertName.Render(m.currentOnCall.Username))
} else {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled")
}
// Window label
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
gap := m.width - lipgloss.Width(onCallLine) - lipgloss.Width(windowLabel)
if gap < 0 {
gap = 0
}
header := "\n" + onCallLine + strings.Repeat(" ", gap) + windowLabel + "\n"
return header + m.scheduleTable.View()
}
func (m Model) renderUserPicker() string {
if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…")
}
selectedDate := ""
cursor := m.scheduleTable.Cursor()
if cursor < len(m.scheduleDays) {
d := m.scheduleDays[cursor]
if d.date.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
selectedDate = "Today (" + d.date.Format("Mon") + ")"
} else {
selectedDate = d.date.Format("Jan 02 (Mon)")
}
}
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n",
styleBold.Render(selectedDate))
return header + m.userPickerTable.View()
}
func (m Model) renderAlerts() string {
statsBar := m.renderStatsBar()
var content string