summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--converter.go24
-rw-r--r--go.mod27
-rw-r--r--go.sum47
-rw-r--r--main.go52
-rw-r--r--tui_model.go708
-rw-r--r--weather_mapper.go69
6 files changed, 884 insertions, 43 deletions
diff --git a/converter.go b/converter.go
index 0e2b74c..fa49a61 100644
--- a/converter.go
+++ b/converter.go
@@ -1,4 +1,4 @@
-// converter.go - Converts ForecastData to OWMResponse
+// converter.go - Update to use exported FmiToOwm map
package main
import (
@@ -119,7 +119,27 @@ func (fd *ForecastData) convertToCurrent(index int, timestamp int64, loc *time.L
if val, err := fd.getFloatValue(index, "WeatherSymbol3"); err == nil && !math.IsNaN(val) {
symbol := int(math.Round(val))
forecastTime := time.Unix(timestamp, 0)
- current.Weather = []Weather{mapper.Map(symbol, forecastTime, sunrise, sunset)}
+
+ // Use the mapper or fallback to the global map
+ var weather Weather
+ if mapper != nil {
+ weather = mapper.Map(symbol, forecastTime, sunrise, sunset)
+ } else {
+ // Fallback to global map
+ if w, ok := FmiToOwm[symbol]; ok {
+ weather = w
+ // Determine day/night
+ isDay := forecastTime.After(sunrise) && forecastTime.Before(sunset)
+ if isDay {
+ weather.Icon += "d"
+ } else {
+ weather.Icon += "n"
+ }
+ } else {
+ weather = Weather{800, "Clear", "clear sky", "01d"}
+ }
+ }
+ current.Weather = []Weather{weather}
} else {
// Default weather
current.Weather = []Weather{{800, "Clear", "clear sky", "01d"}}
diff --git a/go.mod b/go.mod
index 8b28fc6..7be49ae 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,30 @@
module tammi.cc/weather
go 1.25.5
+
+require (
+ github.com/charmbracelet/bubbles v0.21.0
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
+)
+
+require (
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/harmonica v0.2.0 // indirect
+ github.com/charmbracelet/x/ansi v0.10.1 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sys v0.36.0 // indirect
+ golang.org/x/text v0.3.8 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..6ec4a0f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,47 @@
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
diff --git a/main.go b/main.go
index c5008be..268f8f9 100644
--- a/main.go
+++ b/main.go
@@ -1,4 +1,4 @@
-// main.go - Main application entry point
+// main.go - Main application
package main
import (
@@ -6,13 +6,22 @@ import (
"flag"
"fmt"
"os"
+ "os/signal"
+ "syscall"
+ "time"
)
func main() {
- location := flag.String("place", "Helsinki", "Location for the forecast")
+ var (
+ location = flag.String("place", "Helsinki", "Location for the forecast")
+ tuiMode = flag.Bool("tui", true, "Run in TUI mode")
+ jsonMode = flag.Bool("json", false, "Output as JSON")
+ days = flag.Int("days", 1, "Number of days to forecast (1-3)")
+ refresh = flag.Int("refresh", 0, "Refresh interval in minutes (0 for no refresh)")
+ )
flag.Parse()
- // Create client and get forecast
+ // Get initial forecast
client := NewFMIClient()
response, err := client.GetForecast(*location)
if err != nil {
@@ -20,11 +29,36 @@ func main() {
os.Exit(1)
}
- // Output JSON
- jsonData, err := json.MarshalIndent(response, "", " ")
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err)
- os.Exit(1)
+ // JSON output mode
+ if *jsonMode {
+ jsonData, err := json.MarshalIndent(response, "", " ")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Println(string(jsonData))
+ return
+ }
+
+ // TUI mode
+ if *tuiMode {
+ model := NewModel(response, *location, *days)
+ if *refresh > 0 {
+ model.SetRefreshInterval(time.Duration(*refresh) * time.Minute)
+ }
+
+ // Handle interrupts
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ <-sigChan
+ model.Quit()
+ }()
+
+ // Run the TUI
+ if err := model.Run(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
+ os.Exit(1)
+ }
}
- fmt.Println(string(jsonData))
}
diff --git a/tui_model.go b/tui_model.go
new file mode 100644
index 0000000..5416a39
--- /dev/null
+++ b/tui_model.go
@@ -0,0 +1,708 @@
+// tui_model.go - Fixed version
+package main
+
+import (
+ "fmt"
+ "math"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/progress"
+ "github.com/charmbracelet/bubbles/spinner"
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Model represents the TUI application state
+type Model struct {
+ response *OWMResponse
+ location string
+ days int
+ viewport viewport.Model
+ progress progress.Model
+ spinner spinner.Model
+ loading bool
+ refreshing bool
+ width int
+ height int
+ selectedDay int
+ showHourly bool
+ refreshTimer *time.Ticker
+ refreshMinutes int
+ err error
+}
+
+// NewModel creates a new TUI model
+func NewModel(response *OWMResponse, location string, days int) *Model {
+ // Initialize spinner
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+ s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+
+ // Initialize progress bar for hourly forecast
+ p := progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C"))
+
+ // Initialize viewport
+ vp := viewport.New(80, 24)
+
+ m := &Model{
+ response: response,
+ location: location,
+ days: min(days, 3),
+ spinner: s,
+ progress: p,
+ viewport: vp,
+ loading: false,
+ refreshing: false,
+ selectedDay: 0,
+ showHourly: true,
+ }
+
+ return m
+}
+
+// SetRefreshInterval sets the auto-refresh interval
+func (m *Model) SetRefreshInterval(interval time.Duration) {
+ if interval > 0 {
+ m.refreshTimer = time.NewTicker(interval)
+ m.refreshMinutes = int(interval.Minutes())
+ }
+}
+
+// Quit gracefully exits the TUI
+func (m *Model) Quit() {
+ if m.refreshTimer != nil {
+ m.refreshTimer.Stop()
+ }
+}
+
+// Run starts the Bubble Tea program
+func (m *Model) Run() error {
+ p := tea.NewProgram(m, tea.WithAltScreen())
+ _, err := p.Run()
+ return err
+}
+
+// Init initializes the model
+func (m Model) Init() tea.Cmd {
+ return tea.Batch(
+ m.spinner.Tick,
+ tea.EnterAltScreen,
+ )
+}
+
+// Update handles messages and updates the model
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m.viewport.Width = msg.Width
+ m.viewport.Height = msg.Height - 2
+
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "q", "ctrl+c", "esc":
+ m.Quit()
+ return m, tea.Quit
+ case "r", "R":
+ m.refreshing = true
+ cmds = append(cmds, m.refreshData())
+ case "H":
+ m.showHourly = !m.showHourly
+ case "left", "h":
+ m.selectedDay = max(0, m.selectedDay-1)
+ case "right", "l":
+ m.selectedDay = min(m.days-1, m.selectedDay+1)
+ case "d", "D":
+ m.selectedDay = (m.selectedDay + 1) % m.days
+ }
+
+ case refreshMsg:
+ m.response = msg.response
+ m.err = msg.err
+ m.refreshing = false
+
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ cmds = append(cmds, cmd)
+
+ case progress.FrameMsg:
+ progressModel, cmd := m.progress.Update(msg)
+ m.progress = progressModel.(progress.Model)
+ cmds = append(cmds, cmd)
+ }
+
+ // Handle auto-refresh timer
+ if m.refreshTimer != nil {
+ select {
+ case <-m.refreshTimer.C:
+ m.refreshing = true
+ cmds = append(cmds, m.refreshData())
+ default:
+ }
+ }
+
+ // Update viewport
+ var vpCmd tea.Cmd
+ m.viewport, vpCmd = m.viewport.Update(msg)
+ cmds = append(cmds, vpCmd)
+
+ return m, tea.Batch(cmds...)
+}
+
+// View renders the TUI
+func (m Model) View() string {
+ if m.loading {
+ return m.loadingView()
+ }
+
+ if m.err != nil {
+ return m.errorView()
+ }
+
+ if m.response == nil {
+ return m.placeholderView()
+ }
+
+ content := m.mainView()
+
+ // Add help footer
+ footer := m.footerView()
+
+ // Combine content and footer
+ return lipgloss.JoinVertical(lipgloss.Top, content, footer)
+}
+
+func (m Model) mainView() string {
+ var sections []string
+
+ // Header with location and current conditions
+ sections = append(sections, m.renderHeader())
+
+ // Current weather
+ sections = append(sections, m.renderCurrentWeather())
+
+ // Hourly or daily forecast
+ if m.showHourly {
+ sections = append(sections, m.renderHourlyForecast())
+ } else {
+ sections = append(sections, m.renderDailyForecast())
+ }
+
+ // Weather details
+ sections = append(sections, m.renderDetails())
+
+ // Combine sections with spacing
+ return lipgloss.JoinVertical(lipgloss.Left,
+ sections...,
+ )
+}
+
+func (m Model) renderHeader() string {
+ current := m.response.Current
+
+ // Get weather description
+ description := "Unknown"
+ if len(current.Weather) > 0 {
+ description = current.Weather[0].Description
+ }
+
+ // Format temperature
+ temp := fmt.Sprintf("%.1f°C", current.Temp)
+ feelsLike := fmt.Sprintf("%.1f°C", current.FeelsLike)
+
+ title := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("15")).
+ Bold(true).
+ Padding(0, 1).
+ Render(fmt.Sprintf("%s • %s", m.location, description))
+
+ tempStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("39")).
+ Bold(true).
+ Render(temp)
+
+ feelsStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("246")).
+ Render(fmt.Sprintf("Feels like %s", feelsLike))
+
+ // Get timezone-aware current time
+ loc, _ := time.LoadLocation(m.response.Timezone)
+ currentTime := time.Now().In(loc).Format("15:04 MST")
+
+ timeStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("245")).
+ Italic(true).
+ Render(currentTime)
+
+ header := lipgloss.JoinVertical(lipgloss.Left,
+ title,
+ "",
+ lipgloss.JoinHorizontal(lipgloss.Center,
+ tempStyle,
+ lipgloss.NewStyle().Width(4).Render(""),
+ feelsStyle,
+ ),
+ "",
+ timeStyle,
+ )
+
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("240")).
+ Padding(1, 2).
+ Margin(0, 0, 1, 0).
+ Render(header)
+}
+
+func (m Model) renderCurrentWeather() string {
+ current := m.response.Current
+
+ // Left column: Basic metrics
+ leftColumn := []string{
+ m.renderMetric("💧 Humidity", fmt.Sprintf("%d%%", current.Humidity)),
+ m.renderMetric("💨 Wind", fmt.Sprintf("%.1f km/h", current.WindSpeed*3.6)),
+ m.renderMetric("🧭 Direction", fmt.Sprintf("%d°", current.WindDeg)),
+ m.renderMetric("🌀 Pressure", fmt.Sprintf("%d hPa", current.Pressure)),
+ }
+
+ // Right column: Advanced metrics
+ rightColumn := []string{
+ m.renderMetric("🌫️ Dew Point", fmt.Sprintf("%.1f°C", current.DewPoint)),
+ m.renderMetric("👁️ Visibility", fmt.Sprintf("%d m", current.Visibility)),
+ m.renderMetric("☁️ Clouds", fmt.Sprintf("%d%%", current.Clouds)),
+ m.renderMetric("☀️ UV Index", fmt.Sprintf("%.1f", current.Uvi)),
+ }
+
+ // Sunrise/Sunset
+ sunrise := time.Unix(current.Sunrise, 0).Format("15:04")
+ sunset := time.Unix(current.Sunset, 0).Format("15:04")
+
+ sunInfo := lipgloss.JoinHorizontal(lipgloss.Center,
+ lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Render("☀️ "+sunrise),
+ lipgloss.NewStyle().Width(10).Render(""),
+ lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Render("🌙 "+sunset),
+ )
+
+ columns := lipgloss.JoinHorizontal(lipgloss.Top,
+ lipgloss.JoinVertical(lipgloss.Left, leftColumn...),
+ lipgloss.NewStyle().Width(10).Render(""),
+ lipgloss.JoinVertical(lipgloss.Left, rightColumn...),
+ )
+
+ return lipgloss.NewStyle().
+ Padding(0, 1).
+ Margin(0, 0, 1, 0).
+ Render(lipgloss.JoinVertical(lipgloss.Left,
+ columns,
+ "",
+ sunInfo,
+ ))
+}
+
+func (m Model) renderHourlyForecast() string {
+ // Take next 24 hours or available data
+ hours := min(24, len(m.response.Hourly))
+ if hours == 0 {
+ return ""
+ }
+
+ // Find min/max for scaling
+ minTemp := math.MaxFloat64
+ maxTemp := -math.MaxFloat64
+ maxPrecip := 0.0
+
+ for i := 0; i < hours; i++ {
+ hour := m.response.Hourly[i]
+ if hour.Temp < minTemp {
+ minTemp = hour.Temp
+ }
+ if hour.Temp > maxTemp {
+ maxTemp = hour.Temp
+ }
+ if hour.Rain != nil && hour.Rain.OneH > maxPrecip {
+ maxPrecip = hour.Rain.OneH
+ }
+ }
+
+ tempRange := maxTemp - minTemp
+ if tempRange == 0 {
+ tempRange = 1
+ }
+
+ var bars []string
+ var times []string
+ var temps []string
+
+ barHeight := 10
+
+ for i := 0; i < hours; i++ {
+ hour := m.response.Hourly[i]
+
+ // Time label
+ t := time.Unix(hour.Dt, 0)
+ timeLabel := t.Format("15")
+ if i == 0 {
+ timeLabel = "Now"
+ }
+
+ // Temperature bar
+ tempHeight := int(((hour.Temp - minTemp) / tempRange) * float64(barHeight))
+ bar := ""
+ for j := 0; j < barHeight; j++ {
+ if j < tempHeight {
+ // Gradient color based on temperature
+ color := m.getTempColor(hour.Temp)
+ bar += lipgloss.NewStyle().Foreground(color).Render("▊")
+ } else {
+ bar += " "
+ }
+ }
+
+ // Precipitation indicator
+ precip := 0.0
+ if hour.Rain != nil {
+ precip = hour.Rain.OneH
+ }
+
+ precipChar := " "
+ if precip > 0.1 {
+ precipChar = "•"
+ if precip > 2.5 {
+ precipChar = "●"
+ }
+ }
+
+ // Weather icon
+ icon := " "
+ if len(hour.Weather) > 0 {
+ icon = m.getWeatherIcon(hour.Weather[0].Icon)
+ }
+
+ // Temperature label
+ tempLabel := fmt.Sprintf("%.0f°", hour.Temp)
+
+ bars = append(bars, bar)
+ times = append(times, timeLabel)
+ temps = append(temps, tempLabel)
+
+ // Add precipitation indicator and weather icon
+ if i < len(bars) {
+ bars[i] = bars[i] + " " +
+ lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Render(precipChar) +
+ " " + icon
+ }
+ }
+
+ // Create the chart
+ chart := lipgloss.JoinVertical(lipgloss.Right,
+ lipgloss.JoinHorizontal(lipgloss.Bottom, temps...),
+ lipgloss.JoinHorizontal(lipgloss.Top, bars...),
+ lipgloss.JoinHorizontal(lipgloss.Top, times...),
+ )
+
+ title := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("15")).
+ Bold(true).
+ Render("Hourly Forecast")
+
+ rangeLabel := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("246")).
+ Render(fmt.Sprintf("%.0f/%.0f°C", maxTemp, minTemp))
+
+ header := lipgloss.JoinHorizontal(lipgloss.Left,
+ title,
+ lipgloss.NewStyle().Width(m.width/2).Render(""),
+ rangeLabel,
+ )
+
+ return lipgloss.NewStyle().
+ Padding(0, 1).
+ Margin(0, 0, 1, 0).
+ Render(lipgloss.JoinVertical(lipgloss.Left,
+ header,
+ "",
+ chart,
+ ))
+}
+
+func (m Model) renderDailyForecast() string {
+ // Group by day
+ daily := make(map[string][]Current)
+ loc, _ := time.LoadLocation(m.response.Timezone)
+
+ for _, hour := range m.response.Hourly {
+ t := time.Unix(hour.Dt, 0).In(loc)
+ day := t.Format("2006-01-02")
+ daily[day] = append(daily[day], hour)
+ }
+
+ // Sort days
+ var days []string
+ for day := range daily {
+ days = append(days, day)
+ }
+
+ if len(days) == 0 {
+ return ""
+ }
+
+ var rows []string
+
+ for i, day := range days {
+ if i >= m.days {
+ break
+ }
+
+ hours := daily[day]
+ if len(hours) == 0 {
+ continue
+ }
+
+ // Calculate day statistics
+ minTemp := math.MaxFloat64
+ maxTemp := -math.MaxFloat64
+ var totalPrecip float64
+ weatherCount := make(map[int]int)
+
+ for _, hour := range hours {
+ if hour.Temp < minTemp {
+ minTemp = hour.Temp
+ }
+ if hour.Temp > maxTemp {
+ maxTemp = hour.Temp
+ }
+ if hour.Rain != nil {
+ totalPrecip += hour.Rain.OneH
+ }
+ if len(hour.Weather) > 0 {
+ weatherCount[hour.Weather[0].ID]++
+ }
+ }
+
+ // Find most common weather
+ commonWeather := Weather{ID: 800, Main: "Clear", Description: "clear sky", Icon: "01d"}
+ maxCount := 0
+
+ // Use the global FmiToOwm map from weather_mapper.go
+ for _, weather := range FmiToOwm {
+ count, exists := weatherCount[weather.ID]
+ if exists && count > maxCount {
+ maxCount = count
+ commonWeather = weather
+ // Set day/night icon based on time
+ t, _ := time.Parse("2006-01-02", day)
+ if t.Hour() < 18 && t.Hour() > 6 {
+ commonWeather.Icon = commonWeather.Icon + "d"
+ } else {
+ commonWeather.Icon = commonWeather.Icon + "n"
+ }
+ }
+ }
+
+ // Format day
+ t, _ := time.Parse("2006-01-02", day)
+ dayLabel := t.Format("Mon 02")
+ if i == 0 {
+ dayLabel = "Today"
+ } else if i == 1 {
+ dayLabel = "Tomorrow"
+ }
+
+ // Weather icon
+ icon := m.getWeatherIcon(commonWeather.Icon)
+
+ // Temperature range
+ tempRange := fmt.Sprintf("%.0f/%.0f°C", maxTemp, minTemp)
+
+ // Precipitation
+ precip := ""
+ if totalPrecip > 0 {
+ precip = fmt.Sprintf("💧 %.1f mm", totalPrecip)
+ }
+
+ row := lipgloss.JoinHorizontal(lipgloss.Left,
+ lipgloss.NewStyle().Width(12).Render(dayLabel),
+ lipgloss.NewStyle().Width(3).Render(icon),
+ lipgloss.NewStyle().Width(15).Render(commonWeather.Description),
+ lipgloss.NewStyle().Width(12).Render(tempRange),
+ lipgloss.NewStyle().Width(12).Render(precip),
+ )
+
+ rows = append(rows, row)
+ }
+
+ title := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("15")).
+ Bold(true).
+ Render("Daily Forecast")
+
+ return lipgloss.NewStyle().
+ Padding(0, 1).
+ Margin(0, 0, 1, 0).
+ Render(lipgloss.JoinVertical(lipgloss.Left,
+ title,
+ "",
+ lipgloss.JoinVertical(lipgloss.Left, rows...),
+ ))
+}
+
+func (m Model) renderDetails() string {
+ current := m.response.Current
+
+ details := []string{
+ m.renderDetailRow("Wind Gust", fmt.Sprintf("%.1f km/h", current.WindGust*3.6)),
+ m.renderDetailRow("Dew Point", fmt.Sprintf("%.1f°C", current.DewPoint)),
+ m.renderDetailRow("Humidity", fmt.Sprintf("%d%%", current.Humidity)),
+ m.renderDetailRow("Pressure", fmt.Sprintf("%d hPa", current.Pressure)),
+ }
+
+ return lipgloss.NewStyle().
+ Padding(0, 1).
+ Render(lipgloss.JoinVertical(lipgloss.Left, details...))
+}
+
+func (m Model) renderMetric(label, value string) string {
+ return lipgloss.JoinHorizontal(lipgloss.Left,
+ lipgloss.NewStyle().
+ Foreground(lipgloss.Color("246")).
+ Width(15).
+ Render(label+":"),
+ lipgloss.NewStyle().
+ Foreground(lipgloss.Color("15")).
+ Bold(true).
+ Render(value),
+ )
+}
+
+func (m Model) renderDetailRow(label, value string) string {
+ return lipgloss.NewStyle().
+ Foreground(lipgloss.Color("240")).
+ Render(fmt.Sprintf("%s: %s", label, value))
+}
+
+func (m Model) getTempColor(temp float64) lipgloss.Color {
+ // Color gradient from blue (cold) to red (hot)
+ if temp < -10 {
+ return lipgloss.Color("27") // Dark blue
+ } else if temp < 0 {
+ return lipgloss.Color("39") // Light blue
+ } else if temp < 10 {
+ return lipgloss.Color("50") // Green
+ } else if temp < 20 {
+ return lipgloss.Color("190") // Yellow
+ } else if temp < 30 {
+ return lipgloss.Color("208") // Orange
+ } else {
+ return lipgloss.Color("196") // Red
+ }
+}
+
+func (m Model) getWeatherIcon(icon string) string {
+ // Map OpenWeatherMap icons to Unicode/emoji
+ iconMap := map[string]string{
+ "01d": "☀️", // clear sky day
+ "01n": "🌙", // clear sky night
+ "02d": "⛅", // few clouds day
+ "02n": "☁️", // few clouds night
+ "03d": "☁️", // scattered clouds
+ "03n": "☁️",
+ "04d": "☁️", // broken clouds
+ "04n": "☁️",
+ "09d": "🌧️", // shower rain
+ "09n": "🌧️",
+ "10d": "🌦️", // rain day
+ "10n": "🌧️", // rain night
+ "11d": "⛈️", // thunderstorm day
+ "11n": "⛈️", // thunderstorm night
+ "13d": "❄️", // snow
+ "13n": "❄️",
+ "50d": "🌫️", // mist
+ "50n": "🌫️",
+ }
+
+ if icon, ok := iconMap[icon]; ok {
+ return icon
+ }
+ return "🌡️" // default thermometer
+}
+
+func (m Model) loadingView() string {
+ return lipgloss.Place(
+ m.width, m.height,
+ lipgloss.Center, lipgloss.Center,
+ lipgloss.JoinVertical(lipgloss.Center,
+ m.spinner.View(),
+ "Loading weather data...",
+ ),
+ )
+}
+
+func (m Model) errorView() string {
+ return lipgloss.Place(
+ m.width, m.height,
+ lipgloss.Center, lipgloss.Center,
+ lipgloss.NewStyle().
+ Foreground(lipgloss.Color("196")).
+ Render(fmt.Sprintf("Error: %v\n\nPress 'r' to retry or 'q' to quit", m.err)),
+ )
+}
+
+func (m Model) placeholderView() string {
+ return lipgloss.Place(
+ m.width, m.height,
+ lipgloss.Center, lipgloss.Center,
+ "No weather data available\n\nPress 'r' to refresh or 'q' to quit",
+ )
+}
+
+func (m Model) footerView() string {
+ var helpItems []string
+
+ if m.showHourly {
+ helpItems = append(helpItems, "H: Daily view")
+ } else {
+ helpItems = append(helpItems, "H: Hourly view")
+ }
+
+ helpItems = append(helpItems,
+ "←→: Change day",
+ "R: Refresh",
+ "Q: Quit",
+ )
+
+ if m.refreshMinutes > 0 {
+ helpItems = append(helpItems, fmt.Sprintf("Auto-refresh: %dm", m.refreshMinutes))
+ }
+
+ if m.refreshing {
+ helpItems = append(helpItems, m.spinner.View()+" Refreshing...")
+ }
+
+ helpBar := strings.Join(helpItems, " │ ")
+
+ return lipgloss.NewStyle().
+ Foreground(lipgloss.Color("240")).
+ Padding(0, 1).
+ Render(helpBar)
+}
+
+// refreshMsg is a message sent when data refresh completes
+type refreshMsg struct {
+ response *OWMResponse
+ err error
+}
+
+// refreshData fetches new data
+func (m *Model) refreshData() tea.Cmd {
+ return func() tea.Msg {
+ client := NewFMIClient()
+ response, err := client.GetForecast(m.location)
+ return refreshMsg{response: response, err: err}
+ }
+}
diff --git a/weather_mapper.go b/weather_mapper.go
index 7f01217..9bfab01 100644
--- a/weather_mapper.go
+++ b/weather_mapper.go
@@ -1,7 +1,9 @@
-// weather_mapper.go - Maps FMI weather symbols to OWM format
+// weather_mapper.go - Fixed with exported map
package main
-import "time"
+import (
+ "time"
+)
// WeatherMapper handles weather symbol mapping
type WeatherMapper struct {
@@ -11,36 +13,7 @@ type WeatherMapper struct {
// NewWeatherMapper creates a new weather mapper
func NewWeatherMapper() *WeatherMapper {
return &WeatherMapper{
- symbolMap: map[int]Weather{
- 1: {800, "Clear", "clear sky", "01"},
- 2: {801, "Clouds", "few clouds", "02"},
- 3: {802, "Clouds", "scattered clouds", "03"},
- 4: {803, "Clouds", "broken clouds", "04"},
- 5: {804, "Clouds", "overcast clouds", "04"},
- 6: {701, "Mist", "mist", "50"},
- 7: {701, "Mist", "mist", "50"},
- 8: {741, "Fog", "fog", "50"},
- 9: {741, "Fog", "fog", "50"},
- 10: {741, "Fog", "fog", "50"},
- 11: {300, "Drizzle", "light intensity drizzle", "09"},
- 12: {301, "Drizzle", "drizzle", "09"},
- 13: {302, "Drizzle", "heavy intensity drizzle", "09"},
- 21: {520, "Rain", "light intensity shower rain", "09"},
- 22: {521, "Rain", "shower rain", "09"},
- 23: {522, "Rain", "heavy intensity shower rain", "09"},
- 24: {511, "Rain", "freezing rain", "13"},
- 25: {500, "Rain", "light rain", "10"},
- 26: {501, "Rain", "moderate rain", "10"},
- 27: {502, "Rain", "heavy intensity rain", "10"},
- 30: {500, "Rain", "light rain", "10"},
- 31: {501, "Rain", "moderate rain", "10"},
- 32: {502, "Rain", "heavy intensity rain", "10"},
- 40: {600, "Snow", "light snow", "13"},
- 41: {600, "Snow", "light snow", "13"},
- 42: {601, "Snow", "snow", "13"},
- 43: {602, "Snow", "heavy snow", "13"},
- 91: {200, "Thunderstorm", "thunderstorm with light rain", "11"},
- },
+ symbolMap: FmiToOwm,
}
}
@@ -61,3 +34,35 @@ func (wm *WeatherMapper) Map(symbol int, forecastTime, sunrise, sunset time.Time
return weather
}
+
+// FmiToOwm is the global FMI to OpenWeatherMap mapping
+var FmiToOwm = map[int]Weather{
+ 1: {800, "Clear", "clear sky", "01"},
+ 2: {801, "Clouds", "few clouds", "02"},
+ 3: {802, "Clouds", "scattered clouds", "03"},
+ 4: {803, "Clouds", "broken clouds", "04"},
+ 5: {804, "Clouds", "overcast clouds", "04"},
+ 6: {701, "Mist", "mist", "50"},
+ 7: {701, "Mist", "mist", "50"},
+ 8: {741, "Fog", "fog", "50"},
+ 9: {741, "Fog", "fog", "50"},
+ 10: {741, "Fog", "fog", "50"},
+ 11: {300, "Drizzle", "light intensity drizzle", "09"},
+ 12: {301, "Drizzle", "drizzle", "09"},
+ 13: {302, "Drizzle", "heavy intensity drizzle", "09"},
+ 21: {520, "Rain", "light intensity shower rain", "09"},
+ 22: {521, "Rain", "shower rain", "09"},
+ 23: {522, "Rain", "heavy intensity shower rain", "09"},
+ 24: {511, "Rain", "freezing rain", "13"},
+ 25: {500, "Rain", "light rain", "10"},
+ 26: {501, "Rain", "moderate rain", "10"},
+ 27: {502, "Rain", "heavy intensity rain", "10"},
+ 30: {500, "Rain", "light rain", "10"},
+ 31: {501, "Rain", "moderate rain", "10"},
+ 32: {502, "Rain", "heavy intensity rain", "10"},
+ 40: {600, "Snow", "light snow", "13"},
+ 41: {600, "Snow", "light snow", "13"},
+ 42: {601, "Snow", "snow", "13"},
+ 43: {602, "Snow", "heavy snow", "13"},
+ 91: {200, "Thunderstorm", "thunderstorm with light rain", "11"},
+}