diff --git a/internal/api/client.go b/internal/api/client.go index c509fb0..804e3c6 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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) diff --git a/internal/api/types.go b/internal/api/types.go index ba801be..7e72eb8 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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"` +} diff --git a/internal/tui/model.go b/internal/tui/model.go index ff9bf76..c5ba853 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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) diff --git a/internal/tui/update.go b/internal/tui/update.go index 1b82a53..4b1dd6f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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 +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 7adbe39..d2b0ee9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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