dashboard of nationalrail train times

sundial: add frontends; bubbletea TUI and webserver

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 791aecb6 89caa6c0

verified
Changed files
+413 -6
+5 -6
main.go
··· 12 12 fmt.Println("Sundial - Live Train Departures") 13 13 fmt.Println("Usage:") 14 14 fmt.Println(" go run . server - Start web server") 15 - fmt.Println(" go run . cli - Start interactive CLI") 15 + fmt.Println(" go run . tui - Start interactive tui") 16 16 fmt.Println(" go run . parse <html_file> - Parse HTML file") 17 17 fmt.Println("Examples:") 18 18 fmt.Println(" go run . server") 19 - fmt.Println(" go run . cli") 19 + fmt.Println(" go run . tui") 20 20 fmt.Println(" go run . parse sample.html") 21 21 os.Exit(1) 22 22 } ··· 26 26 switch command { 27 27 case "server": 28 28 runServer() 29 - case "cli": 30 - err := runCLI() 29 + case "tui": 30 + err := runTUI() 31 31 if err != nil { 32 - log.Fatalf("CLI error: %v", err) 32 + log.Fatalf("tui error: %v", err) 33 33 } 34 34 case "parse": 35 35 if len(os.Args) < 3 { ··· 69 69 70 70 fmt.Println(string(jsonData)) 71 71 } 72 -
+160
server.go
··· 1 + package main 2 + 3 + import ( 4 + "html/template" 5 + "log" 6 + "net/http" 7 + "strconv" 8 + "time" 9 + ) 10 + 11 + type Server struct { 12 + config *Config 13 + data []StationData 14 + } 15 + 16 + func NewServer(config *Config) *Server { 17 + return &Server{ 18 + config: config, 19 + } 20 + } 21 + 22 + func (s *Server) refreshData() { 23 + log.Println("Refreshing station data...") 24 + s.data = FetchAllStationsData(s.config.Stations) 25 + log.Printf("Updated data for %d stations", len(s.data)) 26 + } 27 + 28 + func (s *Server) indexHandler(w http.ResponseWriter, r *http.Request) { 29 + // Refresh data on page load 30 + s.refreshData() 31 + 32 + tmpl := `<!DOCTYPE html> 33 + <html> 34 + <head> 35 + <title>Sundial - Live Train Departures</title> 36 + <meta http-equiv="refresh" content="{{.RefreshInterval}}"> 37 + <style> 38 + body { 39 + font-family: monospace; 40 + margin: 20px; 41 + font-size: 14px; 42 + line-height: 1.4; 43 + } 44 + .header { 45 + display: flex; 46 + justify-content: space-between; 47 + align-items: center; 48 + } 49 + table { 50 + border-collapse: collapse; 51 + width: 100%; 52 + margin-bottom: 20px; 53 + table-layout: fixed; 54 + } 55 + th, td { 56 + padding: 8px; 57 + text-align: left; 58 + } 59 + th:nth-child(1), td:nth-child(1) { width: 50px; padding: 4px; } /* Time */ 60 + th:nth-child(2), td:nth-child(2) { width: auto; } /* Destination */ 61 + th:nth-child(3), td:nth-child(3) { width: 50px; text-align: right; padding: 4px; } /* Platform */ 62 + .delayed { 63 + color: red; 64 + } 65 + .station-name { 66 + font-weight: bold; 67 + margin: 20px 0 10px 0; 68 + } 69 + </style> 70 + </head> 71 + <body> 72 + <div class="header"> 73 + <div>SUNDIAL - LIVE TRAIN DEPARTURES</div> 74 + <div>Last updated: {{.LastUpdateTime}}</div> 75 + </div> 76 + <hr> 77 + 78 + {{range .Stations}} 79 + <div class="station-name">{{.Station.Name}} ({{.Station.Code}})</div> 80 + 81 + {{if .Error}} 82 + <div>ERROR: {{.Error}}</div> 83 + {{else}} 84 + {{if not .Departures}} 85 + <div>No departures found - The National Rail website now uses dynamic JavaScript to load departure data.</div> 86 + <div>This simple HTTP scraper cannot access the live data. Consider using the National Rail API instead.</div> 87 + {{else}} 88 + <table> 89 + <tr> 90 + <th>Time</th> 91 + <th>Destination</th> 92 + <th>Pl.</th> 93 + </tr> 94 + {{range .Departures}} 95 + <tr> 96 + <td{{if and .ExpectedTime (ne .ExpectedTime .ScheduledTime)}} class="delayed"{{end}}> 97 + {{if .ExpectedTime}}{{.ExpectedTime}}{{else}}{{.ScheduledTime}}{{end}} 98 + </td> 99 + <td> 100 + {{.Destination}}{{if .Via}} {{.Via}}{{end}} 101 + </td> 102 + <td class="platform">{{.Platform}}</td> 103 + </tr> 104 + {{end}} 105 + </table> 106 + {{end}} 107 + {{end}} 108 + 109 + {{end}} 110 + 111 + </body> 112 + </html>` 113 + 114 + t, err := template.New("index").Parse(tmpl) 115 + if err != nil { 116 + http.Error(w, "Template error", http.StatusInternalServerError) 117 + return 118 + } 119 + 120 + data := struct { 121 + Stations []StationData 122 + RefreshInterval int 123 + LastUpdateTime string 124 + }{ 125 + Stations: s.data, 126 + RefreshInterval: s.config.Server.RefreshInterval, 127 + LastUpdateTime: getLastUpdateTime(s.data).Format("15:04"), 128 + } 129 + 130 + w.Header().Set("Content-Type", "text/html") 131 + err = t.Execute(w, data) 132 + if err != nil { 133 + log.Printf("Template execution error: %v", err) 134 + } 135 + } 136 + 137 + func (s *Server) Start() error { 138 + http.HandleFunc("/", s.indexHandler) 139 + 140 + addr := ":" + strconv.Itoa(s.config.Server.Port) 141 + log.Printf("Starting server on http://localhost%s", addr) 142 + log.Printf("Auto-refresh every %d seconds", s.config.Server.RefreshInterval) 143 + 144 + return http.ListenAndServe(addr, nil) 145 + } 146 + 147 + // getLastUpdateTime finds the most recent update time from all stations 148 + func getLastUpdateTime(stations []StationData) time.Time { 149 + if len(stations) == 0 { 150 + return time.Now() 151 + } 152 + 153 + latest := stations[0].LastUpdate 154 + for _, station := range stations[1:] { 155 + if station.LastUpdate.After(latest) { 156 + latest = station.LastUpdate 157 + } 158 + } 159 + return latest 160 + }
+248
tui.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/charmbracelet/lipgloss" 10 + ) 11 + 12 + // CLI Model 13 + type 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 23 + type updateDataMsg []StationData 24 + type tickMsg time.Time 25 + 26 + func initialModel(config *Config) model { 27 + return model{ 28 + config: config, 29 + loading: true, 30 + width: 80, 31 + height: 24, 32 + } 33 + } 34 + 35 + func (m model) Init() tea.Cmd { 36 + return tea.Batch( 37 + fetchData(m.config.Stations), 38 + tickCmd(), 39 + ) 40 + } 41 + 42 + func (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 + 74 + func (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 128 + func fetchData(stations []Station) tea.Cmd { 129 + return func() tea.Msg { 130 + data := FetchAllStationsData(stations) 131 + return updateDataMsg(data) 132 + } 133 + } 134 + 135 + func 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 142 + func 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 + 176 + func 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 239 + func 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 + }