+5
-6
main.go
+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
+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
+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
+
}