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:
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
+227
-26
@@ -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
|
||||
// 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)
|
||||
|
||||
+189
-16
@@ -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 {
|
||||
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,13 +252,9 @@ 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 {
|
||||
return m, nil
|
||||
}
|
||||
if m.activeSection == sectionAlerts && len(m.alerts) > 0 {
|
||||
cursor := m.alertTable.Cursor()
|
||||
if cursor >= len(m.alerts) {
|
||||
return m, nil
|
||||
}
|
||||
if cursor < len(m.alerts) {
|
||||
m.selectedAlert = m.alerts[cursor]
|
||||
m.mode = modeDetail
|
||||
m.commentCursor = -1
|
||||
@@ -199,10 +262,66 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
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.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
|
||||
}
|
||||
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":
|
||||
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)
|
||||
default:
|
||||
m.mode = modeDetail
|
||||
m.pendingDeleteID = 0
|
||||
return m, nil
|
||||
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:
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteComment:
|
||||
m.mode = modeDetail
|
||||
case confirmDeleteScheduleEntry:
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+81
-4
@@ -62,9 +62,14 @@ func (m Model) renderBody() string {
|
||||
case modeComment:
|
||||
return m.renderCommentCompose()
|
||||
case modeConfirmDelete:
|
||||
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 := ""
|
||||
var deleteDesc string
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteComment:
|
||||
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
|
||||
commentInfo = fmt.Sprintf(" by %s", m.comments[m.commentCursor].Username)
|
||||
deleteDesc = fmt.Sprintf("comment by %s", m.comments[m.commentCursor].Username)
|
||||
} else {
|
||||
deleteDesc = "comment"
|
||||
}
|
||||
return "\n" + styleError.Render(fmt.Sprintf(" Delete comment%s? [y/N]", commentInfo))
|
||||
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 %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
|
||||
|
||||
Reference in New Issue
Block a user