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:
+83
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user