package main import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" ) type ForecastPeriod struct { StartTime time.Time `json:"start_time"` IsDaytime bool `json:"is_daytime"` Temp int `json:"temperature"` Unit string `json:"unit"` Summary string `json:"summary"` Pop *int `json:"pop,omitempty"` } type NWSClient struct { client *http.Client userAgent string } func NewNWSClient(userAgent string) *NWSClient { return &NWSClient{ client: &http.Client{Timeout: 15 * time.Second}, userAgent: userAgent, } } func (c *NWSClient) FetchForecast(ctx context.Context, loc Location, unit string) (ForecastPeriod, error) { pointsURL := fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", loc.Lat, loc.Lon) var pointsResp struct { Properties struct { ForecastHourly string `json:"forecastHourly"` } `json:"properties"` } if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil { return ForecastPeriod{}, err } if pointsResp.Properties.ForecastHourly == "" { return ForecastPeriod{}, fmt.Errorf("missing forecast hourly URL from NWS") } var forecastResp struct { Properties struct { Periods []struct { StartTime time.Time `json:"startTime"` IsDaytime bool `json:"isDaytime"` Temp int `json:"temperature"` Unit string `json:"temperatureUnit"` Summary string `json:"shortForecast"` Pop struct { Value *int `json:"value"` } `json:"probabilityOfPrecipitation"` } `json:"periods"` } `json:"properties"` } if err := c.getJSON(ctx, pointsResp.Properties.ForecastHourly, &forecastResp); err != nil { return ForecastPeriod{}, err } if len(forecastResp.Properties.Periods) == 0 { return ForecastPeriod{}, fmt.Errorf("no forecast periods returned by NWS") } period := forecastResp.Properties.Periods[0] finalUnit := period.Unit finalTemp := period.Temp if unit == "C" && strings.EqualFold(period.Unit, "F") { finalTemp = int((float64(period.Temp) - 32) * 5.0 / 9.0) finalUnit = "C" } return ForecastPeriod{ StartTime: period.StartTime, IsDaytime: period.IsDaytime, Temp: finalTemp, Unit: finalUnit, Summary: period.Summary, Pop: period.Pop.Value, }, nil } func (c *NWSClient) FetchRadarStation(ctx context.Context, loc Location) (string, error) { pointsURL := fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", loc.Lat, loc.Lon) var pointsResp struct { Properties struct { RadarStation string `json:"radarStation"` } `json:"properties"` } if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil { return "", err } station := strings.TrimSpace(pointsResp.Properties.RadarStation) if station == "" { return "", fmt.Errorf("missing radar station from NWS points") } return station, nil } func (c *NWSClient) getJSON(ctx context.Context, url string, target any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } req.Header.Set("User-Agent", c.userAgent) req.Header.Set("Accept", "application/geo+json") resp, err := c.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode > 299 { return fmt.Errorf("request failed with status %d", resp.StatusCode) } decoder := json.NewDecoder(resp.Body) if err := decoder.Decode(target); err != nil { return err } return nil } func FormatOutput(loc Location, period ForecastPeriod, includeDesc, pretty bool) string { conditionText := titleCase(period.Summary) temp := formatTemperature(period, pretty) popText, popIcon := formatPop(period, pretty) if pretty { conditionIcon := iconForForecast(period.Summary, period.IsDaytime) condition := conditionIcon if includeDesc { condition = fmt.Sprintf("%s %s", conditionIcon, conditionText) } parts := []string{ condition, temp, popIcon + popText, "in", loc.Label(), } return joinParts(parts...) } condition := conditionText if includeDesc { condition = fmt.Sprintf("%s", conditionText) } parts := []string{ condition, temp, popText, "in", loc.Label(), } return joinParts(parts...) } func titleCase(input string) string { parts := strings.Fields(strings.ToLower(input)) for i, part := range parts { if part == "and" || part == "or" || part == "the" || part == "a" || part == "an" { continue } parts[i] = strings.ToUpper(part[:1]) + part[1:] } return strings.Join(parts, " ") } func iconForForecast(summary string, isDay bool) string { text := strings.ToLower(summary) if strings.Contains(text, "thunder") { return "\ue31d" } if isPrecip(text) { if strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") { return "\ue31a" } return "\ue318" } if isMostlySunny(text) { if isDay { return "\ue30c" } return "\ue379" } if isPartlySunny(text) { if isDay { return "\ue30c" } return "\ue379" } if isClear(text) { if isDay { return "\ue30d" } return "\ue32b" } if isCloudy(text) { return "\ue312" } if isDay { return "\ue30d" } return "\ue32b" } func formatPop(period ForecastPeriod, pretty bool) (string, string) { popValue := 0 if period.Pop != nil { popValue = *period.Pop } if pretty { icon := precipitationIcon(period.Summary, period.IsDaytime) return fmt.Sprintf(" %d%%", popValue), icon } return fmt.Sprintf("%d%%", popValue), "" } func precipitationIcon(summary string, isDay bool) string { text := strings.ToLower(summary) if strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") { return "\uf2dc" } if strings.Contains(text, "rain") || strings.Contains(text, "shower") || strings.Contains(text, "drizzle") { return "\ue371" } return "\ue371" } func isPrecip(text string) bool { return strings.Contains(text, "rain") || strings.Contains(text, "shower") || strings.Contains(text, "drizzle") || strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") || strings.Contains(text, "ice") || strings.Contains(text, "hail") } func isClear(text string) bool { return strings.Contains(text, "clear") || strings.Contains(text, "sunny") } func isMostlySunny(text string) bool { return strings.Contains(text, "mostly sunny") || strings.Contains(text, "mostly clear") } func isPartlySunny(text string) bool { return strings.Contains(text, "partly sunny") || strings.Contains(text, "partly cloudy") } func isCloudy(text string) bool { return strings.Contains(text, "cloudy") || strings.Contains(text, "overcast") } func formatTemperature(period ForecastPeriod, pretty bool) string { unit := strings.ToUpper(strings.TrimSpace(period.Unit)) if unit == "" { unit = "F" } return fmt.Sprintf("%d°%s", period.Temp, unit) } func joinParts(parts ...string) string { trimmed := make([]string, 0, len(parts)) for _, part := range parts { if strings.TrimSpace(part) == "" { continue } trimmed = append(trimmed, part) } return strings.Join(trimmed, " ") }