diff options
Diffstat (limited to '')
| -rw-r--r-- | converter.go | 24 | ||||
| -rw-r--r-- | go.mod | 27 | ||||
| -rw-r--r-- | go.sum | 47 | ||||
| -rw-r--r-- | main.go | 52 | ||||
| -rw-r--r-- | tui_model.go | 708 | ||||
| -rw-r--r-- | weather_mapper.go | 69 |
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"}} @@ -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 +) @@ -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= @@ -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"}, +} |
