diff --git a/README.md b/README.md index 3b0eb21..471f9f4 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,12 @@ which yield the following: ```json { - "date": "Thursday, 2025/06/19", - "temperature": "33°C", + "date": "Thursday, 2025/07/31", + "temperature": "29°C", + "min": "19°C", + "max": "29°C", "condition": "Clear", - "feelsLike": "36°C", + "feelsLike": "29°C", "emoji": "☀️" } ``` @@ -55,10 +57,12 @@ which yields: ```json { - "date": "Thursday, 2025/06/19", - "temperature": "65°F", + "date": "Thursday, 2025/07/31", + "temperature": "61°F", + "min": "51°F", + "max": "61°F", "condition": "Clear", - "feelsLike": "68°F", + "feelsLike": "61°F", "emoji": "☀️" } ``` @@ -105,6 +109,8 @@ As in the previous examples, you can append the `i` query parameter to get resul in imperial units. ## Forecast + +### Daily The `/forecast/:city` endpoint allows you to get the weather forecast of the next 4 days. For example: @@ -128,7 +134,8 @@ which yields: "arrow": "↗️", "direction": "SSW", "speed": "14.7 km/h" - } + }, + "rainProbability": "100%" }, { "date": "Wednesday, 2025/05/07", @@ -141,7 +148,8 @@ which yields: "arrow": "↘️", "direction": "NNW", "speed": "13.9 km/h" - } + }, + "rainProbability": "100%" } ] } @@ -150,6 +158,48 @@ which yields: As in the previous examples, you can append the `i` query parameter to get results in imperial units. +### Hourly +You can also get the hourly forecast of a time window of 9 hours by appending +the `h`(hourly) query parameter to the URL: + +```sh +curl -s 'http://127.0.0.1:3000/forecast/tapei?h' | jq +``` + +```json +{ + "forecast": [ + { + "time": "2:00 PM", + "temperature": "26°C", + "condition": "Clouds", + "emoji": "☁️", + "wind": { + "arrow": "↘️", + "direction": "NW", + "speed": "23.3 km/h" + }, + "rainProbability": "0%" + }, + { + "time": "3:00 PM", + "temperature": "27°C", + "condition": "Clouds", + "emoji": "☁️", + "wind": { + "arrow": "↘️", + "direction": "NW", + "speed": "20.2 km/h" + }, + "rainProbability": "0%" + } + ] +} +``` + +As in the previous examples, you can append the `i` query parameter to get results +in imperial units(**tip**: you can mix both parameter using `&`). + ## Moon The `/moon` endpoint provides the current moon phase and its emoji representation: diff --git a/compose.yml b/compose.yml index 35e2513..6d58598 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: build: . container_name: "zephyr" environment: - ZEPHYR_ADDR: 127.0.0.1 # Listen address + ZEPHYR_ADDR: 0.0.0.0 # Listen address ZEPHYR_PORT: 3000 # Listen port ZEPHYR_TOKEN: "" # OpenWeatherMap API Key ZEPHYR_CACHE_TTL: 3 # Cache time-to-live in hour @@ -12,4 +12,4 @@ services: volumes: - "/etc/localtime:/etc/localtime:ro" ports: - - "3000:3000" \ No newline at end of file + - "3000:3000" diff --git a/controller/controller.go b/controller/controller.go index 1b4343b..81b77b6 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -58,20 +58,46 @@ func fmtWind(windSpeed string, isImperial bool) string { } func fmtKey(key string) string { - // Format cache/database keys by replacing whitespaces with '+' token - // and making them uppercase - return strings.ToUpper(strings.ReplaceAll(key, " ", "+")) + // Cache/database key is formatted by: + // 1. Removing leading and trailing whitespaces + // 2. Replacing in-between spaces using the '+' token + // 3. Making the key uppercase + return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(key), " ", "+")) } -func deepCopyForecast(original types.Forecast) types.Forecast { - // Copy the outer structure - fc_copy := original +func fmtDailyForecast(forecast *types.DailyForecast, isImperial bool) { + for idx := range forecast.Forecast { + val := &forecast.Forecast[idx] + val.Min = fmtTemperature(val.Min, isImperial) + val.Max = fmtTemperature(val.Max, isImperial) + val.FeelsLike = fmtTemperature(val.FeelsLike, isImperial) + val.Wind.Speed = fmtWind(val.Wind.Speed, isImperial) + } +} - // Allocate enough space - fc_copy.Forecast = make([]types.ForecastEntity, len(original.Forecast)) +func fmtHourlyForecast(forecast *types.HourlyForecast, isImperial bool) { + for idx := range forecast.Forecast { + val := &forecast.Forecast[idx] + val.Temperature = fmtTemperature(val.Temperature, isImperial) + val.Wind.Speed = fmtWind(val.Wind.Speed, isImperial) + } +} - // Copy inner structure - copy(fc_copy.Forecast, original.Forecast) +func deepCopyForecast[T types.DailyForecast | types.HourlyForecast](original T) T { + var fc_copy T + + switch any(original).(type) { + case types.DailyForecast: + orig := any(original).(types.DailyForecast) + fc_copy = any(types.DailyForecast{ + Forecast: append([]types.DailyForecastEntity(nil), orig.Forecast...), + }).(T) + case types.HourlyForecast: + orig := any(original).(types.HourlyForecast) + fc_copy = any(types.HourlyForecast{ + Forecast: append([]types.HourlyForecastEntity(nil), orig.Forecast...), + }).(T) + } return fc_copy } @@ -93,6 +119,8 @@ func GetWeather(res http.ResponseWriter, req *http.Request, cache *types.Cache[t if found { // Format weather object and then return it cachedValue.Temperature = fmtTemperature(cachedValue.Temperature, isImperial) + cachedValue.Min = fmtTemperature(cachedValue.Min, isImperial) + cachedValue.Max = fmtTemperature(cachedValue.Max, isImperial) cachedValue.FeelsLike = fmtTemperature(cachedValue.FeelsLike, isImperial) jsonValue(res, cachedValue) @@ -119,6 +147,8 @@ func GetWeather(res http.ResponseWriter, req *http.Request, cache *types.Cache[t // Format weather object and then return it weather.Temperature = fmtTemperature(weather.Temperature, isImperial) + weather.Min = fmtTemperature(weather.Min, isImperial) + weather.Max = fmtTemperature(weather.Max, isImperial) weather.FeelsLike = fmtTemperature(weather.FeelsLike, isImperial) jsonValue(res, weather) @@ -219,7 +249,13 @@ func GetWind(res http.ResponseWriter, req *http.Request, cache *types.Cache[type } } -func GetForecast(res http.ResponseWriter, req *http.Request, cache *types.Cache[types.Forecast], vars *types.Variables) { +func GetForecast( + res http.ResponseWriter, + req *http.Request, + dCache *types.Cache[types.DailyForecast], + hCache *types.Cache[types.HourlyForecast], + vars *types.Variables, +) { if req.Method != http.MethodGet { jsonError(res, "error", "method not allowed", http.StatusMethodNotAllowed) return @@ -232,49 +268,54 @@ func GetForecast(res http.ResponseWriter, req *http.Request, cache *types.Cache[ // Check whether the 'i' parameter(imperial mode) is specified isImperial := req.URL.Query().Has("i") - cachedValue, found := cache.GetEntry(fmtKey(cityName), vars.TimeToLive) - if found { - forecast := deepCopyForecast(cachedValue) - - // Format forecast object and then return it - for idx := range forecast.Forecast { - val := &forecast.Forecast[idx] - - val.Min = fmtTemperature(val.Min, isImperial) - val.Max = fmtTemperature(val.Max, isImperial) - val.FeelsLike = fmtTemperature(val.FeelsLike, isImperial) - val.Wind.Speed = fmtWind(val.Wind.Speed, isImperial) + // Check whether the 'h' parameter(hourly forecast) is specified + if req.URL.Query().Has("h") { + cachedValue, found := hCache.GetEntry(fmtKey(cityName), vars.TimeToLive) + if found { + forecast := deepCopyForecast(cachedValue) + fmtHourlyForecast(&forecast, isImperial) + jsonValue(res, forecast) + return } - jsonValue(res, forecast) - } else { - // Get city coordinates city, err := model.GetCoordinates(cityName, vars.Token) if err != nil { jsonError(res, "error", err.Error(), http.StatusBadRequest) return } - // Get city forecast - forecast, err := model.GetForecast(&city, vars.Token) + forecast, err := model.GetForecast[types.HourlyForecast](&city, vars.Token, model.HOURLY) if err != nil { jsonError(res, "error", err.Error(), http.StatusBadRequest) return } - // Add result to cache - cache.AddEntry(deepCopyForecast(forecast), fmtKey(cityName)) - - // Format forecast object and then return it - for idx := range forecast.Forecast { - val := &forecast.Forecast[idx] - - val.Min = fmtTemperature(val.Min, isImperial) - val.Max = fmtTemperature(val.Max, isImperial) - val.FeelsLike = fmtTemperature(val.FeelsLike, isImperial) - val.Wind.Speed = fmtWind(val.Wind.Speed, isImperial) + hCache.AddEntry(deepCopyForecast(forecast), fmtKey(cityName)) + fmtHourlyForecast(&forecast, isImperial) + jsonValue(res, forecast) + } else { // Daily forecast(default) + cachedValue, found := dCache.GetEntry(fmtKey(cityName), vars.TimeToLive) + if found { + forecast := deepCopyForecast(cachedValue) + fmtDailyForecast(&forecast, isImperial) + jsonValue(res, forecast) + return } + city, err := model.GetCoordinates(cityName, vars.Token) + if err != nil { + jsonError(res, "error", err.Error(), http.StatusBadRequest) + return + } + + forecast, err := model.GetForecast[types.DailyForecast](&city, vars.Token, model.DAILY) + if err != nil { + jsonError(res, "error", err.Error(), http.StatusBadRequest) + return + } + + dCache.AddEntry(deepCopyForecast(forecast), fmtKey(cityName)) + fmtDailyForecast(&forecast, isImperial) jsonValue(res, forecast) } } diff --git a/main.go b/main.go index b0643e4..cf1059c 100644 --- a/main.go +++ b/main.go @@ -46,7 +46,7 @@ func main() { }) http.HandleFunc("/forecast/", func(res http.ResponseWriter, req *http.Request) { - controller.GetForecast(res, req, &cache.ForecastCache, &vars) + controller.GetForecast(res, req, &cache.DailyForecastCache, &cache.HourlyForecastCache, &vars) }) http.HandleFunc("/moon", func(res http.ResponseWriter, req *http.Request) { diff --git a/model/forecastModel.go b/model/forecastModel.go index 51222d1..f1dea52 100644 --- a/model/forecastModel.go +++ b/model/forecastModel.go @@ -2,6 +2,7 @@ package model import ( "encoding/json" + "math" "net/http" "net/url" "strconv" @@ -11,7 +12,14 @@ import ( "github.com/ceticamarco/zephyr/types" ) -// Structures representing the JSON response +type FCType int + +const ( + DAILY FCType = iota + HOURLY +) + +// Structures representing the daily forecast type dailyRes struct { Temp struct { Min float64 `json:"min"` @@ -27,85 +35,182 @@ type dailyRes struct { } `json:"weather"` WindSpeed float64 `json:"wind_speed"` WindDeg float64 `json:"wind_deg"` + RainProb float64 `json:"pop"` Timestamp int64 `json:"dt"` } -type forecastRes struct { +type dailyForecastRes struct { Daily []dailyRes `json:"daily"` } -func getForecastEntity(dailyForecast dailyRes) types.ForecastEntity { - // Format UNIX timestamp as 'YYYY-MM-DD' - utcTime := time.Unix(int64(dailyForecast.Timestamp), 0) - weatherDate := types.ZephyrDate{Date: utcTime.UTC()} +// Structure representing the hourly forecast +type hourlyRes struct { + Temperature float64 `json:"temp"` + Weather []struct { + Title string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + WindSpeed float64 `json:"wind_speed"` + WindDeg float64 `json:"wind_deg"` + RainProb float64 `json:"pop"` + Timestamp int64 `json:"dt"` +} - // Set condition accordingly to weather description - var condition string - switch dailyForecast.Weather[0].Description { - case "few clouds": - condition = "SunWithCloud" - case "broken clouds": - condition = "CloudWithSun" +type hourlyForecastRes struct { + Hourly []hourlyRes `json:"hourly"` +} + +func getForecastEntity[T types.DailyForecastEntity | types.HourlyForecastEntity, K dailyRes | hourlyRes](forecast K) T { + switch fc := any(forecast).(type) { + case dailyRes: + // Format UNIX timestamp as 'YYYY-MM-DD' + utcTime := time.Unix(int64(fc.Timestamp), 0) + weatherDate := types.ZephyrDate{Date: utcTime.UTC()} + + // Set condition accordingly to weather description + var condition string + switch fc.Weather[0].Description { + case "few clouds": + condition = "SunWithCloud" + case "broken clouds": + condition = "CloudWithSun" + default: + condition = fc.Weather[0].Title + } + + // Get emoji from weather condition + emoji := GetEmoji(condition, false) + + // Get cardinal direction and wind arrow + windDirection, windArrow := GetCardinalDir(fc.WindDeg) + + // Round rain probability to the nearest integer + rainProb := int64(math.Round(fc.RainProb * 100)) + + return any(types.DailyForecastEntity{ + Date: weatherDate, + Min: strconv.FormatFloat(fc.Temp.Min, 'f', -1, 64), + Max: strconv.FormatFloat(fc.Temp.Max, 'f', -1, 64), + Condition: fc.Weather[0].Title, + Emoji: emoji, + FeelsLike: strconv.FormatFloat(fc.FeelsLike.Day, 'f', -1, 64), + Wind: types.Wind{ + Arrow: windArrow, + Direction: windDirection, + Speed: strconv.FormatFloat(fc.WindSpeed, 'f', 2, 64), + }, + RainProb: strconv.FormatInt(rainProb, 10) + "%", + }).(T) + case hourlyRes: + // Format UNIX timestamp as 'YYYY-MM-DD' + utcTime := time.Unix(int64(fc.Timestamp), 0) + weatherTime := types.ZephyrTime{Time: utcTime.UTC()} + + // Set condition accordingly to weather condition + var condition string + switch fc.Weather[0].Description { + case "few clouds": + condition = "SunWithCloud" + case "broken clouds": + condition = "CloudWithSun" + default: + condition = fc.Weather[0].Title + } + + // Get emoji from weather condition + isNight := strings.HasSuffix(fc.Weather[0].Icon, "n") + emoji := GetEmoji(condition, isNight) + + // Get cardinal direction and wind arrow + windDirection, windArrow := GetCardinalDir(fc.WindDeg) + + // Round rain probability to the nearest integer + rainProb := int64(math.Round(fc.RainProb * 100)) + + return any(types.HourlyForecastEntity{ + Time: weatherTime, + Temperature: strconv.FormatFloat(fc.Temperature, 'f', -1, 64), + Condition: fc.Weather[0].Title, + Emoji: emoji, + Wind: types.Wind{ + Arrow: windArrow, + Direction: windDirection, + Speed: strconv.FormatFloat(fc.WindSpeed, 'f', 2, 64), + }, + RainProb: strconv.FormatInt(rainProb, 10) + "%", + }).(T) default: - condition = dailyForecast.Weather[0].Title - } - - // Get emoji from weather condition - isNight := strings.HasSuffix(dailyForecast.Weather[0].Icon, "n") - emoji := GetEmoji(condition, isNight) - - // Get cardinal direction and wind arrow - windDirection, windArrow := GetCardinalDir(dailyForecast.WindDeg) - - return types.ForecastEntity{ - Date: weatherDate, - Min: strconv.FormatFloat(dailyForecast.Temp.Min, 'f', -1, 64), - Max: strconv.FormatFloat(dailyForecast.Temp.Max, 'f', -1, 64), - Condition: dailyForecast.Weather[0].Title, - Emoji: emoji, - FeelsLike: strconv.FormatFloat(dailyForecast.FeelsLike.Day, 'f', -1, 64), - Wind: types.Wind{ - Arrow: windArrow, - Direction: windDirection, - Speed: strconv.FormatFloat(dailyForecast.WindSpeed, 'f', 2, 64), - }, + var zero T + return zero } } -func GetForecast(city *types.City, apiKey string) (types.Forecast, error) { - url, err := url.Parse(WTR_URL) +func GetForecast[T types.DailyForecast | types.HourlyForecast](city *types.City, apiKey string, fcType FCType) (T, error) { + var forecast T + + baseURL, err := url.Parse(WTR_URL) if err != nil { - return types.Forecast{}, err + var zero T + return zero, err } - params := url.Query() + params := baseURL.Query() params.Set("lat", strconv.FormatFloat(city.Lat, 'f', -1, 64)) params.Set("lon", strconv.FormatFloat(city.Lon, 'f', -1, 64)) params.Set("appid", apiKey) params.Set("units", "metric") - params.Set("exclude", "current,minutely,hourly,alerts") - url.RawQuery = params.Encode() + switch fcType { + case DAILY: + params.Set("exclude", "current,minutely,hourly,alerts") + baseURL.RawQuery = params.Encode() - res, err := http.Get(url.String()) - if err != nil { - return types.Forecast{}, err - } - defer res.Body.Close() + res, err := http.Get(baseURL.String()) + if err != nil { + var zero T + return zero, err + } + defer res.Body.Close() - var forecastRes forecastRes - if err := json.NewDecoder(res.Body).Decode(&forecastRes); err != nil { - return types.Forecast{}, err + var dailyRes dailyForecastRes + if err := json.NewDecoder(res.Body).Decode(&dailyRes); err != nil { + var zero T + return zero, err + } + + // We skip the first element since it represents the current day + // We also ignore forecasts after the fourth day + var forecastEntities []types.DailyForecastEntity + for _, val := range dailyRes.Daily[1:5] { + forecastEntities = append(forecastEntities, getForecastEntity[types.DailyForecastEntity](val)) + } + forecast = any(types.DailyForecast{Forecast: forecastEntities}).(T) + + case HOURLY: + params.Set("exclude", "current,minutely,daily,alerts") + baseURL.RawQuery = params.Encode() + + res, err := http.Get(baseURL.String()) + if err != nil { + var zero T + return zero, err + } + defer res.Body.Close() + + var hourlyRes hourlyForecastRes + if err := json.NewDecoder(res.Body).Decode(&hourlyRes); err != nil { + var zero T + return zero, err + } + + // Get hourly forecast of a time window of 9 hours + var forecastEntries []types.HourlyForecastEntity + for _, val := range hourlyRes.Hourly[:9] { + forecastEntries = append(forecastEntries, getForecastEntity[types.HourlyForecastEntity](val)) + } + forecast = any(types.HourlyForecast{Forecast: forecastEntries}).(T) } - // We skip the first element since it represents the current day - // We ignore forecasts after the fourth day - var forecast []types.ForecastEntity - for _, val := range forecastRes.Daily[1:5] { - forecast = append(forecast, getForecastEntity(val)) - } - - return types.Forecast{ - Forecast: forecast, - }, nil + return any(forecast).(T), nil } diff --git a/model/geoModel.go b/model/geoModel.go index 145a1a4..40b5d2d 100644 --- a/model/geoModel.go +++ b/model/geoModel.go @@ -34,7 +34,7 @@ func GetCoordinates(cityName string, apiKey string) (types.City, error) { } if len(geoArr) == 0 { - return types.City{}, errors.New("Cannot find this city") + return types.City{}, errors.New("cannot find this city") } return types.City{ diff --git a/model/statisticsModel.go b/model/statisticsModel.go index 7bb70b0..ca248ad 100644 --- a/model/statisticsModel.go +++ b/model/statisticsModel.go @@ -12,7 +12,7 @@ import ( func GetStatistics(cityName string, statDB *types.StatDB) (types.StatResult, error) { // Check whether there are sufficient and updated records for the given location if statDB.IsKeyInvalid(cityName) { - return types.StatResult{}, errors.New("Insufficient or outdated data to perform statistical analysis") + return types.StatResult{}, errors.New("insufficient or outdated data to perform statistical analysis") } extractTemps := func(weatherArr []types.Weather) ([]float64, error) { diff --git a/model/weatherModel.go b/model/weatherModel.go index 5e029ee..101ba0f 100644 --- a/model/weatherModel.go +++ b/model/weatherModel.go @@ -53,7 +53,7 @@ func GetWeather(city *types.City, apiKey string) (types.Weather, error) { params.Set("lon", strconv.FormatFloat(city.Lon, 'f', -1, 64)) params.Set("appid", apiKey) params.Set("units", "metric") - params.Set("exclude", "minutely,hourly,daily,alerts") + params.Set("exclude", "minutely,hourly,alerts") url.RawQuery = params.Encode() @@ -63,7 +63,7 @@ func GetWeather(city *types.City, apiKey string) (types.Weather, error) { } defer res.Body.Close() - // Structure representing the JSON response + // Structure representing the *current* weather type WeatherRes struct { Current struct { FeelsLike float64 `json:"feels_like"` @@ -75,6 +75,12 @@ func GetWeather(city *types.City, apiKey string) (types.Weather, error) { Icon string `json:"icon"` } `json:"weather"` } `json:"current"` + Daily []struct { + Temp struct { + Min float64 `json:"min"` + Max float64 `json:"max"` + } `json:"temp"` + } `json:"daily"` } var weather WeatherRes @@ -104,6 +110,8 @@ func GetWeather(city *types.City, apiKey string) (types.Weather, error) { return types.Weather{ Date: weatherDate, Temperature: strconv.FormatFloat(weather.Current.Temperature, 'f', -1, 64), + Min: strconv.FormatFloat(weather.Daily[0].Temp.Min, 'f', -1, 64), + Max: strconv.FormatFloat(weather.Daily[0].Temp.Max, 'f', -1, 64), FeelsLike: strconv.FormatFloat(weather.Current.FeelsLike, 'f', -1, 64), Condition: weather.Current.Weather[0].Title, Emoji: emoji, diff --git a/types/cache.go b/types/cache.go index 05f8ef3..8929707 100644 --- a/types/cache.go +++ b/types/cache.go @@ -7,7 +7,7 @@ import ( // cacheType, representing the abstract value of a CacheEntity type cacheType interface { - Weather | Metrics | Wind | Forecast | Moon + Weather | Metrics | Wind | DailyForecast | HourlyForecast | Moon } // CacheEntity, representing the value of the cache @@ -23,20 +23,22 @@ type Cache[T cacheType] struct { // Caches, representing a grouping of the various caches type Caches struct { - WeatherCache Cache[Weather] - MetricsCache Cache[Metrics] - WindCache Cache[Wind] - ForecastCache Cache[Forecast] - MoonCache CacheEntity[Moon] + WeatherCache Cache[Weather] + MetricsCache Cache[Metrics] + WindCache Cache[Wind] + DailyForecastCache Cache[DailyForecast] + HourlyForecastCache Cache[HourlyForecast] + MoonCache CacheEntity[Moon] } func InitCache() *Caches { return &Caches{ - WeatherCache: Cache[Weather]{Data: make(map[string]CacheEntity[Weather])}, - MetricsCache: Cache[Metrics]{Data: make(map[string]CacheEntity[Metrics])}, - WindCache: Cache[Wind]{Data: make(map[string]CacheEntity[Wind])}, - ForecastCache: Cache[Forecast]{Data: make(map[string]CacheEntity[Forecast])}, - MoonCache: CacheEntity[Moon]{element: Moon{}, timestamp: time.Time{}}, + WeatherCache: Cache[Weather]{Data: make(map[string]CacheEntity[Weather])}, + MetricsCache: Cache[Metrics]{Data: make(map[string]CacheEntity[Metrics])}, + WindCache: Cache[Wind]{Data: make(map[string]CacheEntity[Wind])}, + DailyForecastCache: Cache[DailyForecast]{Data: make(map[string]CacheEntity[DailyForecast])}, + HourlyForecastCache: Cache[HourlyForecast]{Data: make(map[string]CacheEntity[HourlyForecast])}, + MoonCache: CacheEntity[Moon]{element: Moon{}, timestamp: time.Time{}}, } } diff --git a/types/date.go b/types/date.go index 0feab16..3965c22 100644 --- a/types/date.go +++ b/types/date.go @@ -33,3 +33,32 @@ func (date ZephyrDate) MarshalJSON() ([]byte, error) { return []byte("\"" + fmtDate + "\""), nil } + +type ZephyrTime struct { + Time time.Time +} + +func (t *ZephyrTime) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), "\"") + if s == "" { + return nil + } + + var err error + t.Time, err = time.Parse("15:04", s) + if err != nil { + return err + } + + return nil +} + +func (t ZephyrTime) MarshalJSON() ([]byte, error) { + if t.Time.IsZero() { + return []byte("\"\""), nil + } + + fmtTime := t.Time.Format("3:04 PM") + + return []byte("\"" + fmtTime + "\""), nil +} diff --git a/types/forecast.go b/types/forecast.go index 5554954..68e6a4d 100644 --- a/types/forecast.go +++ b/types/forecast.go @@ -1,8 +1,8 @@ package types -// The ForecastEntity data type, representing the weather forecast +// The DailyForecastEntity data type, representing the weather forecast // of a single day -type ForecastEntity struct { +type DailyForecastEntity struct { Date ZephyrDate `json:"date"` Min string `json:"min"` Max string `json:"max"` @@ -10,9 +10,26 @@ type ForecastEntity struct { Emoji string `json:"emoji"` FeelsLike string `json:"feelsLike"` Wind Wind `json:"wind"` + RainProb string `json:"rainProbability"` } -// The Forecast data type, representing a set of ForecastEntity -type Forecast struct { - Forecast []ForecastEntity `json:"forecast"` +// The DailyForecast data type, representing a set of DailyForecastEntity +type DailyForecast struct { + Forecast []DailyForecastEntity `json:"forecast"` +} + +// The HourlyForecastEntity data type, representing the weather forecast +// of a single hour +type HourlyForecastEntity struct { + Time ZephyrTime `json:"time"` + Temperature string `json:"temperature"` + Condition string `json:"condition"` + Emoji string `json:"emoji"` + Wind Wind `json:"wind"` + RainProb string `json:"rainProbability"` +} + +// The HourlyForecast data type, representing a set of HourlyForecastEntity +type HourlyForecast struct { + Forecast []HourlyForecastEntity `json:"forecast"` } diff --git a/types/weather.go b/types/weather.go index 92711a1..8be6f4e 100644 --- a/types/weather.go +++ b/types/weather.go @@ -4,6 +4,8 @@ package types type Weather struct { Date ZephyrDate `json:"date"` Temperature string `json:"temperature"` + Min string `json:"min"` + Max string `json:"max"` Condition string `json:"condition"` FeelsLike string `json:"feelsLike"` Emoji string `json:"emoji"`