dashboard of nationalrail train times
at main 5.9 kB view raw
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}