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)
|
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).
|
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway).
|
||||||
func (c *Client) HealthCheck() error {
|
func (c *Client) HealthCheck() error {
|
||||||
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
||||||
|
|||||||
@@ -48,3 +48,18 @@ type DayStat struct {
|
|||||||
DayName string `json:"day_name"`
|
DayName string `json:"day_name"`
|
||||||
Count int `json:"count"`
|
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"
|
"github.com/yeniklas/terdut-tui/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Enums ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type section int
|
type section int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,10 +31,19 @@ const (
|
|||||||
modeComment
|
modeComment
|
||||||
modeConfirmDelete
|
modeConfirmDelete
|
||||||
modeStats
|
modeStats
|
||||||
|
modeScheduleUserPicker
|
||||||
)
|
)
|
||||||
|
|
||||||
// tea.Msg types — dashboard
|
type confirmTarget int
|
||||||
|
|
||||||
|
const (
|
||||||
|
confirmDeleteComment confirmTarget = iota
|
||||||
|
confirmDeleteScheduleEntry
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Messages ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// dashboard
|
||||||
type connectedMsg struct{}
|
type connectedMsg struct{}
|
||||||
type connectErrMsg struct{ err error }
|
type connectErrMsg struct{ err error }
|
||||||
type alertsFetchedMsg struct{ alerts []api.Alert }
|
type alertsFetchedMsg struct{ alerts []api.Alert }
|
||||||
@@ -41,8 +52,7 @@ type fetchDataErrMsg struct{ err error }
|
|||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
type clearStatusMsg struct{}
|
type clearStatusMsg struct{}
|
||||||
|
|
||||||
// tea.Msg types — detail
|
// detail
|
||||||
|
|
||||||
type alertDetailFetchedMsg struct {
|
type alertDetailFetchedMsg struct {
|
||||||
alert api.Alert
|
alert api.Alert
|
||||||
comments []api.Comment
|
comments []api.Comment
|
||||||
@@ -56,7 +66,21 @@ type detailStatsFetchedMsg struct {
|
|||||||
}
|
}
|
||||||
type detailStatsErrMsg struct{ err error }
|
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 {
|
type Model struct {
|
||||||
client *api.Client
|
client *api.Client
|
||||||
@@ -78,7 +102,7 @@ type Model struct {
|
|||||||
filterStatus string
|
filterStatus string
|
||||||
alertTable table.Model
|
alertTable table.Model
|
||||||
|
|
||||||
// Detail view
|
// Detail
|
||||||
selectedAlert api.Alert
|
selectedAlert api.Alert
|
||||||
comments []api.Comment
|
comments []api.Comment
|
||||||
commentCursor int
|
commentCursor int
|
||||||
@@ -89,33 +113,53 @@ type Model struct {
|
|||||||
commentInput textinput.Model
|
commentInput textinput.Model
|
||||||
|
|
||||||
// Confirm delete
|
// Confirm delete
|
||||||
pendingDeleteID int64
|
confirmTarget confirmTarget
|
||||||
|
pendingDeleteID int64 // comment ID
|
||||||
|
pendingDeleteEntry *api.ScheduleEntry
|
||||||
|
|
||||||
// Stats view
|
// Stats
|
||||||
topAlerts []api.TopAlert
|
topAlerts []api.TopAlert
|
||||||
hourStats []api.HourStat
|
hourStats []api.HourStat
|
||||||
dayStats []api.DayStat
|
dayStats []api.DayStat
|
||||||
statsLoading bool
|
statsLoading bool
|
||||||
statsViewport viewport.Model
|
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
|
help help.Model
|
||||||
keys keyMap
|
keys keyMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
|
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
|
||||||
t := table.New(table.WithFocused(true))
|
ts := defaultTableStyles()
|
||||||
s := table.DefaultStyles()
|
|
||||||
s.Header = s.Header.Bold(true)
|
alertT := table.New(table.WithFocused(true))
|
||||||
s.Selected = s.Selected.
|
alertT.SetStyles(ts)
|
||||||
Foreground(lipgloss.Color("0")).
|
|
||||||
Background(colorPrimary).
|
schedT := table.New(table.WithFocused(true))
|
||||||
Bold(true)
|
schedT.SetStyles(ts)
|
||||||
t.SetStyles(s)
|
|
||||||
|
pickerT := table.New(table.WithFocused(true))
|
||||||
|
pickerT.SetStyles(ts)
|
||||||
|
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "type your comment…"
|
ti.Placeholder = "type your comment…"
|
||||||
ti.CharLimit = 1000
|
ti.CharLimit = 1000
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
window := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
client: client,
|
client: client,
|
||||||
serverURL: serverURL,
|
serverURL: serverURL,
|
||||||
@@ -125,8 +169,11 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
|||||||
loading: true,
|
loading: true,
|
||||||
filterStatus: "firing",
|
filterStatus: "firing",
|
||||||
commentCursor: -1,
|
commentCursor: -1,
|
||||||
alertTable: t,
|
alertTable: alertT,
|
||||||
commentInput: ti,
|
commentInput: ti,
|
||||||
|
scheduleWindow: window,
|
||||||
|
scheduleTable: schedT,
|
||||||
|
userPickerTable: pickerT,
|
||||||
help: help.New(),
|
help: help.New(),
|
||||||
keys: keys,
|
keys: keys,
|
||||||
}
|
}
|
||||||
@@ -136,7 +183,18 @@ func (m Model) Init() tea.Cmd {
|
|||||||
return connectCmd(m.client)
|
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() {
|
func (m *Model) rebuildTable() {
|
||||||
m.alertTable.SetColumns(alertColumns(m.width))
|
m.alertTable.SetColumns(alertColumns(m.width))
|
||||||
m.alertTable.SetRows(alertRows(m.alerts))
|
m.alertTable.SetRows(alertRows(m.alerts))
|
||||||
@@ -147,26 +205,45 @@ func (m *Model) rebuildTable() {
|
|||||||
m.alertTable.SetHeight(h)
|
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() {
|
func (m *Model) refreshDetailContent() {
|
||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content := buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width)
|
m.detailViewport.SetContent(buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width))
|
||||||
m.detailViewport.SetContent(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshStatsContent rebuilds the stats viewport from loaded stats data.
|
|
||||||
func (m *Model) refreshStatsContent() {
|
func (m *Model) refreshStatsContent() {
|
||||||
content := buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width)
|
m.statsViewport.SetContent(buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width))
|
||||||
m.statsViewport.SetContent(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailViewportHeight returns the detail viewport height for the current mode.
|
|
||||||
func (m Model) detailViewportHeight() int {
|
func (m Model) detailViewportHeight() int {
|
||||||
h := m.height - 5
|
h := m.height - 5
|
||||||
if m.mode == modeComment {
|
if m.mode == modeComment {
|
||||||
h -= 2 // separator + input line
|
h -= 2
|
||||||
}
|
}
|
||||||
if h < 1 {
|
if h < 1 {
|
||||||
h = 1
|
h = 1
|
||||||
@@ -174,6 +251,8 @@ func (m Model) detailViewportHeight() int {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Column definitions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
func alertColumns(width int) []table.Column {
|
func alertColumns(width int) []table.Column {
|
||||||
nameW := width/2 - 8
|
nameW := width/2 - 8
|
||||||
if nameW < 20 {
|
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 {
|
func alertRows(alerts []api.Alert) []table.Row {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
rows := make([]table.Row, len(alerts))
|
rows := make([]table.Row, len(alerts))
|
||||||
@@ -200,6 +305,44 @@ func alertRows(alerts []api.Alert) []table.Row {
|
|||||||
return rows
|
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 {
|
func humanAgo(now, t time.Time) string {
|
||||||
d := now.Sub(t)
|
d := now.Sub(t)
|
||||||
if d < 0 {
|
if d < 0 {
|
||||||
@@ -227,7 +370,7 @@ func humanAgo(now, t time.Time) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command constructors
|
// ── Commands ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func connectCmd(client *api.Client) tea.Cmd {
|
func connectCmd(client *api.Client) tea.Cmd {
|
||||||
return func() tea.Msg {
|
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 {
|
func tickCmd(interval time.Duration) tea.Cmd {
|
||||||
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
||||||
return tickMsg(t)
|
return tickMsg(t)
|
||||||
|
|||||||
+186
-13
@@ -13,6 +13,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.rebuildTable()
|
m.rebuildTable()
|
||||||
|
m.rebuildScheduleTable()
|
||||||
|
m.rebuildUserPickerTable()
|
||||||
m.detailViewport.Width = m.width
|
m.detailViewport.Width = m.width
|
||||||
m.detailViewport.Height = m.detailViewportHeight()
|
m.detailViewport.Height = m.detailViewportHeight()
|
||||||
m.statsViewport.Width = m.width
|
m.statsViewport.Width = m.width
|
||||||
@@ -21,7 +23,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshStatsContent()
|
m.refreshStatsContent()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
// Dashboard messages
|
// ── Dashboard messages ────────────────────────────────────────────────
|
||||||
|
|
||||||
case connectedMsg:
|
case connectedMsg:
|
||||||
m.connected = true
|
m.connected = true
|
||||||
m.err = nil
|
m.err = nil
|
||||||
@@ -57,7 +60,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
fetchStatsCmd(m.client),
|
fetchStatsCmd(m.client),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Detail messages
|
// ── Detail messages ───────────────────────────────────────────────────
|
||||||
|
|
||||||
case alertDetailFetchedMsg:
|
case alertDetailFetchedMsg:
|
||||||
m.selectedAlert = msg.alert
|
m.selectedAlert = msg.alert
|
||||||
m.comments = msg.comments
|
m.comments = msg.comments
|
||||||
@@ -91,6 +95,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.mode = modeDetail
|
m.mode = modeDetail
|
||||||
return m, clearStatusCmd()
|
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:
|
case clearStatusMsg:
|
||||||
m.statusMsg = ""
|
m.statusMsg = ""
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -102,15 +134,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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) {
|
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case modeDetail, modeConfirmDelete:
|
case modeDetail:
|
||||||
var vpCmd tea.Cmd
|
var vpCmd tea.Cmd
|
||||||
m.detailViewport, vpCmd = m.detailViewport.Update(msg)
|
m.detailViewport, vpCmd = m.detailViewport.Update(msg)
|
||||||
m2, ourCmd := m.handleKey(msg)
|
m2, ourCmd := m.handleKey(msg)
|
||||||
return m2, tea.Batch(vpCmd, ourCmd)
|
return m2, tea.Batch(vpCmd, ourCmd)
|
||||||
|
|
||||||
|
case modeConfirmDelete:
|
||||||
|
// No component to scroll; just handle y/n.
|
||||||
|
return m.handleKey(msg)
|
||||||
|
|
||||||
case modeComment:
|
case modeComment:
|
||||||
var inputCmd tea.Cmd
|
var inputCmd tea.Cmd
|
||||||
m.commentInput, inputCmd = m.commentInput.Update(msg)
|
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)
|
m2, ourCmd := m.handleKey(msg)
|
||||||
return m2, tea.Batch(vpCmd, ourCmd)
|
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
|
default: // modeDashboard
|
||||||
if m.activeSection == sectionAlerts && m.connected {
|
if m.connected {
|
||||||
|
switch m.activeSection {
|
||||||
|
case sectionAlerts:
|
||||||
var tableCmd tea.Cmd
|
var tableCmd tea.Cmd
|
||||||
m.alertTable, tableCmd = m.alertTable.Update(msg)
|
m.alertTable, tableCmd = m.alertTable.Update(msg)
|
||||||
m2, ourCmd := m.handleKey(msg)
|
m2, ourCmd := m.handleKey(msg)
|
||||||
return m2, tea.Batch(tableCmd, ourCmd)
|
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)
|
return m.handleKey(msg)
|
||||||
}
|
}
|
||||||
@@ -144,24 +194,41 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m.handleConfirmKey(msg)
|
return m.handleConfirmKey(msg)
|
||||||
case modeStats:
|
case modeStats:
|
||||||
return m.handleStatsKey(msg)
|
return m.handleStatsKey(msg)
|
||||||
|
case modeScheduleUserPicker:
|
||||||
|
return m.handleUserPickerKey(msg)
|
||||||
default:
|
default:
|
||||||
return m.handleDashboardKey(msg)
|
return m.handleDashboardKey(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dashboard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case "tab":
|
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
|
return m, nil
|
||||||
|
|
||||||
case "r":
|
case "r":
|
||||||
if !m.connected {
|
if !m.connected {
|
||||||
return m, connectCmd(m.client)
|
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…"
|
m.statusMsg = "Refreshing…"
|
||||||
return m, tea.Batch(
|
return m, tea.Batch(
|
||||||
fetchAlertsCmd(m.client, m.filterStatus),
|
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)
|
return m, fetchAlertsCmd(m.client, m.filterStatus)
|
||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.activeSection != sectionAlerts || len(m.alerts) == 0 {
|
if m.activeSection == sectionAlerts && len(m.alerts) > 0 {
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
cursor := m.alertTable.Cursor()
|
cursor := m.alertTable.Cursor()
|
||||||
if cursor >= len(m.alerts) {
|
if cursor < len(m.alerts) {
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.selectedAlert = m.alerts[cursor]
|
m.selectedAlert = m.alerts[cursor]
|
||||||
m.mode = modeDetail
|
m.mode = modeDetail
|
||||||
m.commentCursor = -1
|
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())
|
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
|
||||||
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Detail ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc", "backspace":
|
case "esc", "backspace":
|
||||||
@@ -237,6 +356,7 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, clearStatusCmd()
|
return m, clearStatusCmd()
|
||||||
}
|
}
|
||||||
m.pendingDeleteID = m.comments[m.commentCursor].ID
|
m.pendingDeleteID = m.comments[m.commentCursor].ID
|
||||||
|
m.confirmTarget = confirmDeleteComment
|
||||||
m.mode = modeConfirmDelete
|
m.mode = modeConfirmDelete
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -278,6 +398,8 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Comment compose ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
@@ -300,21 +422,42 @@ func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Confirm delete ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch strings.ToLower(msg.String()) {
|
switch strings.ToLower(msg.String()) {
|
||||||
case "y":
|
case "y":
|
||||||
|
switch m.confirmTarget {
|
||||||
|
case confirmDeleteComment:
|
||||||
alertID := m.selectedAlert.ID
|
alertID := m.selectedAlert.ID
|
||||||
commentID := m.pendingDeleteID
|
commentID := m.pendingDeleteID
|
||||||
m.mode = modeDetail
|
m.mode = modeDetail
|
||||||
m.commentCursor = -1
|
m.commentCursor = -1
|
||||||
m.pendingDeleteID = 0
|
m.pendingDeleteID = 0
|
||||||
return m, deleteCommentCmd(m.client, alertID, commentID)
|
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:
|
default:
|
||||||
|
switch m.confirmTarget {
|
||||||
|
case confirmDeleteComment:
|
||||||
m.mode = modeDetail
|
m.mode = modeDetail
|
||||||
|
case confirmDeleteScheduleEntry:
|
||||||
|
m.mode = modeDashboard
|
||||||
|
}
|
||||||
m.pendingDeleteID = 0
|
m.pendingDeleteID = 0
|
||||||
|
m.pendingDeleteEntry = nil
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
if msg.String() == "esc" {
|
if msg.String() == "esc" {
|
||||||
@@ -323,3 +466,33 @@ func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
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:
|
case modeComment:
|
||||||
return m.renderCommentCompose()
|
return m.renderCommentCompose()
|
||||||
case modeConfirmDelete:
|
case modeConfirmDelete:
|
||||||
|
if m.confirmTarget == confirmDeleteComment {
|
||||||
return m.renderDetail()
|
return m.renderDetail()
|
||||||
|
}
|
||||||
|
return m.renderSchedule()
|
||||||
case modeStats:
|
case modeStats:
|
||||||
return m.renderStats()
|
return m.renderStats()
|
||||||
|
case modeScheduleUserPicker:
|
||||||
|
return m.renderUserPicker()
|
||||||
default:
|
default:
|
||||||
return m.renderDashboard()
|
return m.renderDashboard()
|
||||||
}
|
}
|
||||||
@@ -83,11 +88,22 @@ func (m Model) renderFooter() string {
|
|||||||
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
|
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
|
||||||
|
|
||||||
case modeConfirmDelete:
|
case modeConfirmDelete:
|
||||||
commentInfo := ""
|
var deleteDesc string
|
||||||
|
switch m.confirmTarget {
|
||||||
|
case confirmDeleteComment:
|
||||||
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
|
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:
|
case modeStats:
|
||||||
if m.statusMsg != "" {
|
if m.statusMsg != "" {
|
||||||
@@ -95,7 +111,17 @@ func (m Model) renderFooter() string {
|
|||||||
}
|
}
|
||||||
return "\n" + styleFooter.Render(" esc·back")
|
return "\n" + styleFooter.Render(" esc·back")
|
||||||
|
|
||||||
|
case modeScheduleUserPicker:
|
||||||
|
return "\n" + styleFooter.Render(" j/k·navigate enter·select esc·cancel")
|
||||||
|
|
||||||
default:
|
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()))
|
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||||||
if m.statusMsg != "" {
|
if m.statusMsg != "" {
|
||||||
status := styleStatus.Render(m.statusMsg)
|
status := styleStatus.Render(m.statusMsg)
|
||||||
@@ -116,13 +142,64 @@ func (m Model) renderDashboard() string {
|
|||||||
case sectionAlerts:
|
case sectionAlerts:
|
||||||
return m.renderAlerts()
|
return m.renderAlerts()
|
||||||
case sectionSchedule:
|
case sectionSchedule:
|
||||||
return "\n" + styleMuted.Render(" On-call schedule — coming in Stage 4")
|
return m.renderSchedule()
|
||||||
case sectionUsers:
|
case sectionUsers:
|
||||||
return "\n" + styleMuted.Render(" User management — coming in Stage 5")
|
return "\n" + styleMuted.Render(" User management — coming in Stage 5")
|
||||||
}
|
}
|
||||||
return ""
|
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 {
|
func (m Model) renderAlerts() string {
|
||||||
statsBar := m.renderStatsBar()
|
statsBar := m.renderStatsBar()
|
||||||
var content string
|
var content string
|
||||||
|
|||||||
Reference in New Issue
Block a user