Files
terdut-tui/internal/tui/update.go
T
Niklas Ye ffacbee429 feat: week numbers in schedule and week-wide assignment
Schedule table now shows ISO week number (W21) on the first
row of each week visible in the 14-day window (first row and
every Monday); other rows get a 4-space indent. Date column
widened from 16→18 to accommodate the prefix.

New W key in Schedule section opens the user picker in
week-assign mode, which assigns the selected user to all 7
days (Mon–Sun) of the ISO week containing the cursor. The +
key retains single-day assignment. The user picker header and
footer reflect which mode is active.
2026-05-22 11:17:17 +02:00

753 lines
20 KiB
Go

package tui
import (
"strconv"
"strings"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.rebuildTable()
m.rebuildScheduleTable()
m.rebuildUserPickerTable()
m.rebuildUserManageTable()
m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight()
m.statsViewport.Width = m.width
m.statsViewport.Height = m.height - 5
m.refreshDetailContent()
m.refreshStatsContent()
return m, nil
// ── Dashboard messages ────────────────────────────────────────────────
case connectedMsg:
m.connected = true
m.err = nil
return m, tea.Batch(
tickCmd(m.refreshInterval),
fetchAlertsCmd(m.client, m.filterStatus),
fetchStatsCmd(m.client),
)
case connectErrMsg:
m.connected = false
m.err = msg.err
return m, nil
case alertsFetchedMsg:
m.alerts = msg.alerts
m.loading = false
m.rebuildTable()
return m, nil
case statsFetchedMsg:
m.stats = &msg.stats
return m, nil
case fetchDataErrMsg:
m.statusMsg = "refresh error: " + msg.err.Error()
return m, clearStatusCmd()
case tickMsg:
return m, tea.Batch(
tickCmd(m.refreshInterval),
fetchAlertsCmd(m.client, m.filterStatus),
fetchStatsCmd(m.client),
)
// ── Detail messages ───────────────────────────────────────────────────
case alertDetailFetchedMsg:
m.selectedAlert = msg.alert
m.comments = msg.comments
m.detailLoading = false
if m.commentCursor >= len(m.comments) {
m.commentCursor = -1
}
m.refreshDetailContent()
return m, nil
case alertDetailErrMsg:
m.detailLoading = false
m.statusMsg = "error: " + msg.err.Error()
return m, clearStatusCmd()
case actionErrMsg:
m.statusMsg = "error: " + msg.err.Error()
return m, clearStatusCmd()
case detailStatsFetchedMsg:
m.topAlerts = msg.top
m.hourStats = msg.byHour
m.dayStats = msg.byDay
m.statsLoading = false
m.refreshStatsContent()
return m, nil
case detailStatsErrMsg:
m.statsLoading = false
m.statusMsg = "stats error: " + msg.err.Error()
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()
m.rebuildUserManageTable()
return m, nil
case apiKeyCreatedMsg:
m.revealedAPIKey = msg.key
m.mode = modeAPIKeyReveal
return m, nil
case apiKeyRevokedMsg:
m.statusMsg = "API key revoked"
m.mode = modeDashboard
return m, clearStatusCmd()
case userActionErrMsg:
m.usersLoading = false
m.statusMsg = "error: " + msg.err.Error()
m.mode = modeDashboard
return m, clearStatusCmd()
// ── Common ────────────────────────────────────────────────────────────
case clearStatusMsg:
m.statusMsg = ""
return m, nil
case tea.KeyMsg:
return m.routeKey(msg)
}
return m, nil
}
// 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:
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)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeStats:
var vpCmd tea.Cmd
m.statsViewport, vpCmd = m.statsViewport.Update(msg)
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)
case modeUserCreate:
var inputCmd tea.Cmd
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeAPIKeyCreate:
var inputCmd tea.Cmd
m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeAPIKeyRevokeByID:
var inputCmd tea.Cmd
m.apiKeyRevokeInput, inputCmd = m.apiKeyRevokeInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeAPIKeyMenu, modeAPIKeyReveal:
return m.handleKey(msg)
default: // modeDashboard
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)
case sectionUsers:
var tableCmd tea.Cmd
m.userManageTable, tableCmd = m.userManageTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
}
}
return m.handleKey(msg)
}
}
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch m.mode {
case modeDetail:
return m.handleDetailKey(msg)
case modeComment:
return m.handleCommentKey(msg)
case modeConfirmDelete:
return m.handleConfirmKey(msg)
case modeStats:
return m.handleStatsKey(msg)
case modeScheduleUserPicker:
return m.handleUserPickerKey(msg)
case modeUserCreate:
return m.handleUserCreateKey(msg)
case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg)
case modeAPIKeyCreate:
return m.handleAPIKeyCreateKey(msg)
case modeAPIKeyReveal:
return m.handleAPIKeyRevealKey(msg)
case modeAPIKeyRevokeByID:
return m.handleAPIKeyRevokeKey(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":
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)
}
if next == sectionUsers && len(m.users) == 0 {
m.usersLoading = true
return m, fetchUsersCmd(m.client)
}
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)
}
if m.activeSection == sectionUsers {
m.usersLoading = true
return m, fetchUsersCmd(m.client)
}
m.statusMsg = "Refreshing…"
return m, tea.Batch(
fetchAlertsCmd(m.client, m.filterStatus),
fetchStatsCmd(m.client),
clearStatusCmd(),
)
case "f":
if m.activeSection != sectionAlerts || !m.connected {
return m, nil
}
switch m.filterStatus {
case "firing":
m.filterStatus = "resolved"
case "resolved":
m.filterStatus = ""
default:
m.filterStatus = "firing"
}
m.loading = true
return m, fetchAlertsCmd(m.client, m.filterStatus)
case "enter":
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
}
m.pickerAssignWeek = false
m.mode = modeScheduleUserPicker
if len(m.users) == 0 {
m.usersLoading = true
return m, fetchUsersCmd(m.client)
}
m.rebuildUserPickerTable()
return m, nil
case "W":
if m.activeSection != sectionSchedule || !m.connected {
return m, nil
}
m.pickerAssignWeek = true
m.mode = modeScheduleUserPicker
if len(m.users) == 0 {
m.usersLoading = true
return m, fetchUsersCmd(m.client)
}
m.rebuildUserPickerTable()
return m, nil
case "d":
switch m.activeSection {
case sectionSchedule:
if !m.connected {
return m, nil
}
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
case sectionUsers:
if !m.connected || len(m.users) == 0 {
return m, nil
}
cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) {
return m, nil
}
m.selectedUser = m.users[cursor]
m.confirmTarget = confirmDeleteUser
m.mode = modeConfirmDelete
}
return m, nil
case "n":
if m.activeSection != sectionUsers || !m.connected {
return m, nil
}
m.userFormInputs[0].Reset()
m.userFormInputs[1].Reset()
m.userFormFocus = 0
m.userFormInputs[0].Focus()
m.userFormInputs[1].Blur()
m.mode = modeUserCreate
return m, nil
case "k":
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
return m, nil
}
cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) {
return m, nil
}
m.selectedUser = m.users[cursor]
m.mode = modeAPIKeyMenu
return m, nil
}
return m, nil
}
// ── Detail ────────────────────────────────────────────────────────────────
func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc", "backspace":
m.mode = modeDashboard
m.statusMsg = ""
return m, nil
case "a":
if m.selectedAlert.AcknowledgedByID != nil {
m.statusMsg = "already acknowledged"
return m, clearStatusCmd()
}
return m, acknowledgeCmd(m.client, m.selectedAlert.ID)
case "A":
if m.selectedAlert.AcknowledgedByID == nil {
m.statusMsg = "not acknowledged"
return m, clearStatusCmd()
}
return m, unacknowledgeCmd(m.client, m.selectedAlert.ID)
case "c":
m.mode = modeComment
m.commentInput.Reset()
m.commentInput.Focus()
m.detailViewport.Height = m.detailViewportHeight()
return m, nil
case "d":
if m.commentCursor < 0 || m.commentCursor >= len(m.comments) {
m.statusMsg = "select a comment first with [ / ]"
return m, clearStatusCmd()
}
m.pendingDeleteID = m.comments[m.commentCursor].ID
m.confirmTarget = confirmDeleteComment
m.mode = modeConfirmDelete
return m, nil
case "s":
m.statusMsg = "assign not yet supported by server"
return m, clearStatusCmd()
case "S":
m.mode = modeStats
m.statsLoading = true
m.statsViewport = viewport.New(m.width, m.height-5)
return m, fetchDetailStatsCmd(m.client)
case "[":
if len(m.comments) == 0 {
return m, nil
}
if m.commentCursor <= 0 {
m.commentCursor = len(m.comments) - 1
} else {
m.commentCursor--
}
m.refreshDetailContent()
return m, nil
case "]":
if len(m.comments) == 0 {
return m, nil
}
if m.commentCursor >= len(m.comments)-1 {
m.commentCursor = 0
} else {
m.commentCursor++
}
m.refreshDetailContent()
return m, nil
}
return m, nil
}
// ── Comment compose ───────────────────────────────────────────────────────
func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeDetail
m.commentInput.Blur()
m.detailViewport.Height = m.detailViewportHeight()
return m, nil
case "enter":
content := strings.TrimSpace(m.commentInput.Value())
if content == "" {
return m, nil
}
m.mode = modeDetail
m.commentInput.Blur()
m.detailViewport.Height = m.detailViewportHeight()
return m, addCommentCmd(m.client, m.selectedAlert.ID, content)
}
return m, nil
}
// ── Confirm delete ────────────────────────────────────────────────────────
func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch strings.ToLower(msg.String()) {
case "y":
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)
case confirmDeleteUser:
userID := m.selectedUser.ID
m.mode = modeDashboard
m.usersLoading = true
return m, deleteUserCmd(m.client, userID)
}
default:
switch m.confirmTarget {
case confirmDeleteComment:
m.mode = modeDetail
case confirmDeleteScheduleEntry:
m.mode = modeDashboard
case confirmDeleteUser:
m.mode = modeDashboard
}
m.pendingDeleteID = 0
m.pendingDeleteEntry = nil
}
return m, nil
}
// ── Stats ─────────────────────────────────────────────────────────────────
func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if msg.String() == "esc" {
m.mode = modeDetail
return m, nil
}
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
}
d := m.scheduleDays[scheduleCursor].date
var dates []string
if m.pickerAssignWeek {
weekday := int(d.Weekday())
if weekday == 0 {
weekday = 7 // ISO: Sunday = 7
}
monday := d.AddDate(0, 0, -(weekday - 1))
for i := 0; i < 7; i++ {
dates = append(dates, monday.AddDate(0, 0, i).Format("2006-01-02"))
}
} else {
dates = []string{d.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, dates, from, to)
}
return m, nil
}
// ── User management ───────────────────────────────────────────────────────────
func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.userFormInputs[0].Blur()
m.userFormInputs[1].Blur()
m.mode = modeDashboard
return m, nil
case "tab", "shift+tab":
m.userFormInputs[m.userFormFocus].Blur()
m.userFormFocus = (m.userFormFocus + 1) % 2
m.userFormInputs[m.userFormFocus].Focus()
return m, nil
case "enter":
username := strings.TrimSpace(m.userFormInputs[0].Value())
email := strings.TrimSpace(m.userFormInputs[1].Value())
if username == "" || email == "" {
m.statusMsg = "username and email are required"
return m, clearStatusCmd()
}
m.userFormInputs[0].Blur()
m.userFormInputs[1].Blur()
m.mode = modeDashboard
m.usersLoading = true
return m, createUserCmd(m.client, username, email)
}
return m, nil
}
func (m Model) handleAPIKeyMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeDashboard
return m, nil
case "n":
m.apiKeyNameInput.Reset()
m.apiKeyNameInput.Focus()
m.mode = modeAPIKeyCreate
return m, nil
case "r":
m.apiKeyRevokeInput.Reset()
m.apiKeyRevokeInput.Focus()
m.mode = modeAPIKeyRevokeByID
return m, nil
}
return m, nil
}
func (m Model) handleAPIKeyCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.apiKeyNameInput.Blur()
m.mode = modeAPIKeyMenu
return m, nil
case "enter":
name := strings.TrimSpace(m.apiKeyNameInput.Value())
if name == "" {
m.statusMsg = "key name is required"
return m, clearStatusCmd()
}
m.apiKeyNameInput.Blur()
m.mode = modeDashboard
return m, createAPIKeyCmd(m.client, m.selectedUser.ID, name)
}
return m, nil
}
func (m Model) handleAPIKeyRevealKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "c":
if err := clipboard.WriteAll(m.revealedAPIKey.Key); err != nil {
m.statusMsg = "clipboard error: " + err.Error()
} else {
m.statusMsg = "copied to clipboard"
}
return m, clearStatusCmd()
case "esc", "enter", "q":
m.revealedAPIKey = api.APIKey{}
m.mode = modeDashboard
return m, nil
}
return m, nil
}
func (m Model) handleAPIKeyRevokeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.apiKeyRevokeInput.Blur()
m.mode = modeAPIKeyMenu
return m, nil
case "enter":
raw := strings.TrimSpace(m.apiKeyRevokeInput.Value())
keyID, err := strconv.ParseInt(raw, 10, 64)
if err != nil || keyID <= 0 {
m.statusMsg = "invalid key ID — must be a positive integer"
return m, clearStatusCmd()
}
m.apiKeyRevokeInput.Blur()
m.mode = modeDashboard
return m, deleteAPIKeyCmd(m.client, m.selectedUser.ID, keyID)
}
return m, nil
}