1package main
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10)
11
12// CLI Model
13type model struct {
14 config *Config
15 stations []StationData
16 lastUpdate time.Time
17 loading bool
18 width int
19 height int
20}
21
22// Messages
23type updateDataMsg []StationData
24type tickMsg time.Time
25
26func initialModel(config *Config) model {
27 return model{
28 config: config,
29 loading: true,
30 width: 80,
31 height: 24,
32 }
33}
34
35func (m model) Init() tea.Cmd {
36 return tea.Batch(
37 fetchData(m.config.Stations),
38 tickCmd(),
39 )
40}
41
42func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43 switch msg := msg.(type) {
44 case tea.WindowSizeMsg:
45 m.width = msg.Width
46 m.height = msg.Height
47 return m, nil
48
49 case tea.KeyMsg:
50 switch msg.String() {
51 case "q", "ctrl+c":
52 return m, tea.Quit
53 case "r":
54 m.loading = true
55 return m, fetchData(m.config.Stations)
56 }
57
58 case updateDataMsg:
59 m.stations = []StationData(msg)
60 m.lastUpdate = time.Now()
61 m.loading = false
62 return m, nil
63
64 case tickMsg:
65 return m, tea.Batch(
66 fetchData(m.config.Stations),
67 tickCmd(),
68 )
69 }
70
71 return m, nil
72}
73
74func (m model) View() string {
75 if m.loading && len(m.stations) == 0 {
76 return lipgloss.NewStyle().
77 Width(m.width).
78 Align(lipgloss.Center).
79 Render("Loading train data...")
80 }
81
82 var sections []string
83
84 // Header
85 headerStyle := lipgloss.NewStyle().
86 Bold(true).
87 Foreground(lipgloss.Color("15")).
88 MarginBottom(1)
89
90 timeStyle := lipgloss.NewStyle().
91 Foreground(lipgloss.Color("8")).
92 Align(lipgloss.Right)
93
94 header := lipgloss.JoinHorizontal(
95 lipgloss.Top,
96 headerStyle.Width(m.width-20).Render("SUNDIAL"),
97 timeStyle.Width(20).Render("Last updated: "+m.lastUpdate.Format("15:04")),
98 )
99
100 sections = append(sections, header)
101
102 // Horizontal rule
103 rule := strings.Repeat("─", m.width)
104 sections = append(sections, rule)
105
106 // Station tables
107 for _, station := range m.stations {
108 sections = append(sections, renderStation(station, m.width))
109 }
110
111 // Loading indicator
112 if m.loading {
113 sections = append(sections, "\nRefreshing...")
114 }
115
116 // Help
117 help := lipgloss.NewStyle().
118 Foreground(lipgloss.Color("8")).
119 MarginTop(1).
120 Render("Press 'r' to refresh, 'q' to quit")
121
122 sections = append(sections, help)
123
124 return lipgloss.JoinVertical(lipgloss.Left, sections...)
125}
126
127// Commands
128func fetchData(stations []Station) tea.Cmd {
129 return func() tea.Msg {
130 data := FetchAllStationsData(stations)
131 return updateDataMsg(data)
132 }
133}
134
135func tickCmd() tea.Cmd {
136 return tea.Tick(60*time.Second, func(t time.Time) tea.Msg {
137 return tickMsg(t)
138 })
139}
140
141// Rendering functions
142func renderStation(station StationData, width int) string {
143 var sections []string
144
145 // Station name
146 stationStyle := lipgloss.NewStyle().
147 Bold(true).
148 Foreground(lipgloss.Color("15")).
149 MarginTop(1).
150 MarginBottom(1)
151
152 sections = append(sections, stationStyle.Render(station.Station.Name+" ("+station.Station.Code+")"))
153
154 if station.Error != "" {
155 errorStyle := lipgloss.NewStyle().
156 Foreground(lipgloss.Color("9")).
157 Background(lipgloss.Color("1"))
158
159 sections = append(sections, errorStyle.Render("ERROR: "+station.Error))
160 return lipgloss.JoinVertical(lipgloss.Left, sections...)
161 }
162
163 if len(station.Departures) == 0 {
164 sections = append(sections, "No departures found - The National Rail website now uses dynamic JavaScript to load departure data.")
165 sections = append(sections, "This simple HTTP scraper cannot access the live data. Consider using the National Rail API instead.")
166 return lipgloss.JoinVertical(lipgloss.Left, sections...)
167 }
168
169 // Table
170 table := renderDeparturesTable(station.Departures, width-4)
171 sections = append(sections, table)
172
173 return lipgloss.JoinVertical(lipgloss.Left, sections...)
174}
175
176func renderDeparturesTable(departures []Departure, width int) string {
177 // Styles
178 headerStyle := lipgloss.NewStyle().
179 Bold(true).
180 Foreground(lipgloss.Color("15"))
181
182 cellStyle := lipgloss.NewStyle().
183 Foreground(lipgloss.Color("15"))
184
185 delayedStyle := lipgloss.NewStyle().
186 Foreground(lipgloss.Color("9")) // Red
187
188 // Column widths (matching web interface)
189 timeWidth := 6 // "HH:MM"
190 platformWidth := 4 // "Pl."
191 destWidth := width - timeWidth - platformWidth - 4 // Remaining space minus padding
192
193 // Header row
194 headerRow := lipgloss.JoinHorizontal(
195 lipgloss.Top,
196 headerStyle.Width(timeWidth).Render("Time"),
197 headerStyle.Width(destWidth).Render("Destination"),
198 headerStyle.Width(platformWidth).Align(lipgloss.Right).Render("Pl."),
199 )
200
201 var rows []string
202 rows = append(rows, headerRow)
203
204 // Data rows
205 for _, departure := range departures {
206 // Time cell
207 var timeText string
208 if departure.ExpectedTime != "" {
209 timeText = departure.ExpectedTime
210 } else {
211 timeText = departure.ScheduledTime
212 }
213
214 var timeCell string
215 if departure.ExpectedTime != "" && departure.ExpectedTime != departure.ScheduledTime {
216 timeCell = delayedStyle.Width(timeWidth).Render(timeText)
217 } else {
218 timeCell = cellStyle.Width(timeWidth).Render(timeText)
219 }
220
221 // Destination cell
222 destText := departure.Destination
223 if departure.Via != "" {
224 destText += " " + departure.Via
225 }
226 destCell := cellStyle.Width(destWidth).Render(destText)
227
228 // Platform cell
229 platformCell := cellStyle.Width(platformWidth).Align(lipgloss.Right).Render(departure.Platform)
230
231 row := lipgloss.JoinHorizontal(lipgloss.Top, timeCell, destCell, platformCell)
232 rows = append(rows, row)
233 }
234
235 return lipgloss.JoinVertical(lipgloss.Left, rows...)
236}
237
238// runTUI starts the Bubble Tea CLI application
239func runTUI() error {
240 config, err := LoadConfig("config.yaml")
241 if err != nil {
242 return fmt.Errorf("error loading config: %w", err)
243 }
244
245 p := tea.NewProgram(initialModel(config), tea.WithAltScreen())
246 _, err = p.Run()
247 return err
248}