Files
7am/main.go

701 lines
70 KiB
Go
Raw Normal View History

2025-05-10 00:36:38 +01:00
package main
import (
"context"
2025-05-10 13:04:39 +01:00
"database/sql"
2025-05-10 00:36:38 +01:00
"embed"
_ "embed"
2025-05-10 13:04:39 +01:00
"encoding/json"
"errors"
2025-05-10 15:21:48 +01:00
"flag"
2025-05-10 00:36:38 +01:00
"fmt"
2025-05-10 13:04:39 +01:00
"github.com/SherClockHolmes/webpush-go"
2025-05-10 00:36:38 +01:00
"github.com/go-co-op/gocron/v2"
2025-05-10 13:04:39 +01:00
"github.com/google/uuid"
2025-05-10 00:36:38 +01:00
"github.com/joho/godotenv"
"google.golang.org/genai"
"html/template"
"log"
2025-05-11 11:46:59 +01:00
"log/slog"
2025-05-10 00:36:38 +01:00
"mime"
2025-05-10 13:04:39 +01:00
_ "modernc.org/sqlite"
2025-05-10 00:36:38 +01:00
"net/http"
"os"
"path/filepath"
2025-05-10 18:27:29 +01:00
"slices"
2025-05-10 00:36:38 +01:00
"strings"
"sync"
"time"
)
type location struct {
2025-05-11 11:14:38 +01:00
tz *time.Location
lat float32
lon float32
2025-05-10 22:44:47 +01:00
ianaName string
2025-05-11 11:14:38 +01:00
displayName string
2025-05-10 00:36:38 +01:00
}
2025-05-10 15:13:31 +01:00
// pageTemplate stores all pre-compiled HTML templates for the application
2025-05-10 00:36:38 +01:00
type pageTemplate struct {
summary *template.Template
}
2025-05-10 15:13:31 +01:00
// summaryTemplateData stores template data for summary.html
2025-05-10 00:36:38 +01:00
type summaryTemplateData struct {
2025-05-11 00:08:20 +01:00
Summary string
Location string
LocationName string
2025-05-10 00:36:38 +01:00
}
2025-05-10 15:13:31 +01:00
// updateSubscription is the request body for creating/updating registration
2025-05-10 13:04:39 +01:00
type updateSubscription struct {
2025-05-10 18:27:29 +01:00
Subscription webpush.Subscription `json:"subscription"`
Locations []string `json:"locations"`
RemoveLocations []string `json:"removeLocations"`
2025-05-10 13:04:39 +01:00
}
2025-05-10 15:13:31 +01:00
// registeredSubscription represents a registered webpush subscription.
2025-05-10 13:04:39 +01:00
type registeredSubscription struct {
ID uuid.UUID `json:"id"`
Subscription *webpush.Subscription `json:"-"`
Locations []string `json:"locations"`
}
2025-05-10 18:35:06 +01:00
type webpushNotificationPayload struct {
Summary string `json:"summary"`
Location string `json:"location"`
}
2025-05-12 12:09:58 +01:00
type metAPIData struct {
Properties struct {
TimeSeries []map[string]any `json:"timeseries"`
} `json:"properties"`
}
2025-05-10 13:04:39 +01:00
type state struct {
2025-05-11 11:14:38 +01:00
ctx context.Context
db *sql.DB
metAPIUserAgent string
genai *genai.Client
template pageTemplate
2025-05-10 13:04:39 +01:00
2025-05-10 22:44:47 +01:00
usePlaceholder bool
2025-05-10 15:13:31 +01:00
// summaries maps location keys to their latest weather summary
summaries sync.Map
// summaryChans stores a map of location key to the corresponding summary channel
// which is used to track summary updates
2025-05-10 13:04:39 +01:00
summaryChans map[string]chan string
2025-05-10 15:13:31 +01:00
// subscriptions maps location keys to the list of registered subscriptions
// that are subscribed to updates for the location
subscriptions map[string][]*registeredSubscription
2025-05-10 15:13:31 +01:00
// subscriptionsMutex syncs writes to subscriptions
2025-05-10 13:04:39 +01:00
subscriptionsMutex sync.Mutex
vapidSubject string
2025-05-10 15:13:31 +01:00
// vapidPublicKey is the base64 url encoded VAPID public key
vapidPublicKey string
// vapidPrivateKey is the base64 url encoded VAPID private key
2025-05-10 13:04:39 +01:00
vapidPrivateKey string
}
2025-05-10 00:36:38 +01:00
//go:embed web
var webDir embed.FS
2025-05-11 11:14:38 +01:00
var envKeys = []string{"GEMINI_API_KEY", "MET_API_USER_AGENT", "VAPID_SUBJECT", "VAPID_PRIVATE_KEY_BASE64", "VAPID_PUBLIC_KEY_BASE64"}
2025-05-10 22:44:47 +01:00
2025-05-11 16:53:44 +01:00
//go:embed prompt.txt
var prompt string
2025-05-10 22:44:47 +01:00
var placeholderWeather = map[string]string{
"london": "{\"Headline\":{\"EffectiveDate\":\"2025-05-11T08:00:00+01:00\",\"EffectiveEpochDate\":1746946800,\"Severity\":4,\"Text\":\"Pleasant Sunday\",\"Category\":\"mild\",\"EndDate\":null,\"EndEpochDate\":null,\"MobileLink\":\"http://www.accuweather.com/en/gb/london/ec4a-2/daily-weather-forecast/328328?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/gb/london/ec4a-2/daily-weather-forecast/328328?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00+01:00\",\"EpochDate\":1746856800,\"Sun\":{\"Rise\":\"2025-05-10T05:17:00+01:00\",\"EpochRise\":1746850620,\"Set\":\"2025-05-10T20:38:00+01:00\",\"EpochSet\":1746905880},\"Moon\":{\"Rise\":\"2025-05-10T18:40:00+01:00\",\"EpochRise\":1746898800,\"Set\":\"2025-05-11T04:21:00+01:00\",\"EpochSet\":1746933660,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":50,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":69,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":49,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"},\"Maximum\":{\"Value\":70,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":49,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"},\"Maximum\":{\"Value\":66,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"HoursOfSun\":12.8,\"DegreeDaySummary\":{\"Heating\":{\"Value\":5,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":0,\"Category\":\"Good\",\"CategoryValue\":1,\"Type\":\"Ozone\"},{\"Name\":\"Grass\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Mold\",\"Value\":32767,\"Category\":\"High\",\"CategoryValue\":3},{\"Name\":\"Ragweed\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"UVIndex\",\"Value\":7,\"Category\":\"High\",\"CategoryValue\":3}],\"Day\":{\"Icon\":1,\"IconPhrase\":\"Sunny\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Sunshine, breezy and pleasant\",\"LongPhrase\":\"Breezy and pleasant with sunshine\",\"PrecipitationProbability\":1,\"ThunderstormProbability\":0,\"RainProbability\":1,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":13.8,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":97,\"Localized\":\"E\",\"English\":\"E\"}},\"WindGust\":{\"Speed\":{\"Value\":32.2,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":88,\"Localized\":\"E\",\"English\":\"E\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":6,\"Evapotranspiration\":{\"Value\":0.18,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":7999.7,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":27,\"Maximum\":71,\"Average\":39},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":46,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":53,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":49,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":50,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":61,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":57,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":38,\"IconPhrase\":\"Mostly cloudy\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Increasing cloudiness\",\"LongPhrase\":\"Increasing cloudiness\",\"PrecipitationProbability\":1,\"ThunderstormProbability\":0,\"RainProbability\":1,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":6.9,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":69,\"Localized\":\"ENE\",\"English\":\"ENE\"}},\"WindGust\":{\"Speed\":{\"Value\":20.7,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":106,\"Localized\":\"ESE\",\"English\":\"ESE\"}},\"
"sf": "{\"Headline\":{\"EffectiveDate\":\"2025-05-10T08:00:00-07:00\",\"EffectiveEpochDate\":1746889200,\"Severity\":4,\"Text\":\"Pleasant today\",\"Category\":\"mild\",\"EndDate\":null,\"EndEpochDate\":null,\"MobileLink\":\"http://www.accuweather.com/en/us/san-francisco-ca/94103/daily-weather-forecast/347629?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/us/san-francisco-ca/94103/daily-weather-forecast/347629?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00-07:00\",\"EpochDate\":1746885600,\"Sun\":{\"Rise\":\"2025-05-10T06:04:00-07:00\",\"EpochRise\":1746882240,\"Set\":\"2025-05-10T20:08:00-07:00\",\"EpochSet\":1746932880},\"Moon\":{\"Rise\":\"2025-05-10T18:41:00-07:00\",\"EpochRise\":1746927660,\"Set\":\"2025-05-11T05:13:00-07:00\",\"EpochSet\":1746965580,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":53,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":71,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":48,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"},\"Maximum\":{\"Value\":73,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":48,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"},\"Maximum\":{\"Value\":67,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"HoursOfSun\":10.2,\"DegreeDaySummary\":{\"Heating\":{\"Value\":3,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":49,\"Category\":\"Good\",\"CategoryValue\":1,\"Type\":\"Particle Pollution\"},{\"Name\":\"Grass\",\"Value\":12,\"Category\":\"Moderate\",\"CategoryValue\":2},{\"Name\":\"Mold\",\"Value\":3250,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":5,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":7,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"UVIndex\",\"Value\":10,\"Category\":\"Very High\",\"CategoryValue\":4}],\"Day\":{\"Icon\":2,\"IconPhrase\":\"Mostly sunny\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Sunshine and pleasant\",\"LongPhrase\":\"Sunny to partly cloudy and pleasant\",\"PrecipitationProbability\":1,\"ThunderstormProbability\":0,\"RainProbability\":1,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":11.5,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":254,\"Localized\":\"WSW\",\"English\":\"WSW\"}},\"WindGust\":{\"Speed\":{\"Value\":29.9,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":257,\"Localized\":\"WSW\",\"English\":\"WSW\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":28,\"Evapotranspiration\":{\"Value\":0.15,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":8489.7,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":51,\"Maximum\":91,\"Average\":65},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":53,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":61,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":57,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":56,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":66,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":62,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":35,\"IconPhrase\":\"Partly cloudy\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Partly cloudy\",\"LongPhrase\":\"Partly cloudy\",\"PrecipitationProbability\":0,\"ThunderstormProbability\":0,\"RainProbability\":0,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":11.5,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":268,\"Localized\":\"W\",\"English\":\"W\"}},\"WindGust\":{\"Speed\":{\"Value\":19.6,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":264,\"Localized
"sj": "{\"Headline\":{\"EffectiveDate\":\"2025-05-11T08:00:00-07:00\",\"EffectiveEpochDate\":1746975600,\"Severity\":4,\"Text\":\"Pleasant tomorrow\",\"Category\":\"mild\",\"EndDate\":null,\"EndEpochDate\":null,\"MobileLink\":\"http://www.accuweather.com/en/us/san-jose-ca/95110/daily-weather-forecast/347630?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/us/san-jose-ca/95110/daily-weather-forecast/347630?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00-07:00\",\"EpochDate\":1746885600,\"Sun\":{\"Rise\":\"2025-05-10T06:03:00-07:00\",\"EpochRise\":1746882180,\"Set\":\"2025-05-10T20:05:00-07:00\",\"EpochSet\":1746932700},\"Moon\":{\"Rise\":\"2025-05-10T18:38:00-07:00\",\"EpochRise\":1746927480,\"Set\":\"2025-05-11T05:11:00-07:00\",\"EpochSet\":1746965460,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":53,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":84,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":53,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cool\"},\"Maximum\":{\"Value\":87,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Very Warm\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":53,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cool\"},\"Maximum\":{\"Value\":81,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"HoursOfSun\":12.7,\"DegreeDaySummary\":{\"Heating\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":4,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":70,\"Category\":\"Moderate\",\"CategoryValue\":2,\"Type\":\"Ozone\"},{\"Name\":\"Grass\",\"Value\":12,\"Category\":\"Moderate\",\"CategoryValue\":2},{\"Name\":\"Mold\",\"Value\":3250,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":5,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":7,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"UVIndex\",\"Value\":10,\"Category\":\"Very High\",\"CategoryValue\":4}],\"Day\":{\"Icon\":1,\"IconPhrase\":\"Sunny\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Sunny\",\"LongPhrase\":\"Sunny\",\"PrecipitationProbability\":0,\"ThunderstormProbability\":0,\"RainProbability\":0,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":8.1,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":321,\"Localized\":\"NW\",\"English\":\"NW\"}},\"WindGust\":{\"Speed\":{\"Value\":21.9,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":321,\"Localized\":\"NW\",\"English\":\"NW\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":9,\"Evapotranspiration\":{\"Value\":0.23,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":8666.2,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":22,\"Maximum\":74,\"Average\":36},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":55,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":60,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":58,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":61,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":72,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":67,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":35,\"IconPhrase\":\"Partly cloudy\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Partly cloudy\",\"LongPhrase\":\"Partly cloudy\",\"PrecipitationProbability\":0,\"ThunderstormProbability\":0,\"RainProbability\":0,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":5.8,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":313,\"Localized\":\"NW\",\"English\":\"NW\"}},\"WindGust\":{\"Speed\":{\"Value\":18.4,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":318,\"Localized\":\"NW\",\"English\":\"NW\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\
"la": "{\"Headline\":{\"EffectiveDate\":\"2025-05-10T08:00:00-07:00\",\"EffectiveEpochDate\":1746889200,\"Severity\":4,\"Text\":\"Record-breaking high temperatures today\",\"Category\":\"record heat\",\"EndDate\":\"2025-05-10T20:00:00-07:00\",\"EndEpochDate\":1746932400,\"MobileLink\":\"http://www.accuweather.com/en/us/los-angeles-ca/90012/daily-weather-forecast/347625?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/us/los-angeles-ca/90012/daily-weather-forecast/347625?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00-07:00\",\"EpochDate\":1746885600,\"Sun\":{\"Rise\":\"2025-05-10T05:55:00-07:00\",\"EpochRise\":1746881700,\"Set\":\"2025-05-10T19:44:00-07:00\",\"EpochSet\":1746931440},\"Moon\":{\"Rise\":\"2025-05-10T18:17:00-07:00\",\"EpochRise\":1746926220,\"Set\":\"2025-05-11T05:03:00-07:00\",\"EpochSet\":1746964980,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":66,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":100,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":64,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"},\"Maximum\":{\"Value\":106,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Very Hot\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":64,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"},\"Maximum\":{\"Value\":98,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Hot\"}},\"HoursOfSun\":12.5,\"DegreeDaySummary\":{\"Heating\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":18,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":73,\"Category\":\"Moderate\",\"CategoryValue\":2,\"Type\":\"Ozone\"},{\"Name\":\"Grass\",\"Value\":2,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Mold\",\"Value\":3250,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":50,\"Category\":\"Moderate\",\"CategoryValue\":2},{\"Name\":\"UVIndex\",\"Value\":11,\"Category\":\"Extreme\",\"CategoryValue\":5}],\"Day\":{\"Icon\":1,\"IconPhrase\":\"Sunny\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Sunny; record-breaking heat\",\"LongPhrase\":\"Sunny and hot with the temperature breaking the record of 95 set in 1934; caution advised if outside for extended periods of time\",\"PrecipitationProbability\":1,\"ThunderstormProbability\":0,\"RainProbability\":1,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":4.6,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":228,\"Localized\":\"SW\",\"English\":\"SW\"}},\"WindGust\":{\"Speed\":{\"Value\":18.4,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":246,\"Localized\":\"WSW\",\"English\":\"WSW\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":4,\"Evapotranspiration\":{\"Value\":0.27,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":8761.5,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":18,\"Maximum\":52,\"Average\":26},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":66,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":71,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":68,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":74,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":86,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":82,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":35,\"IconPhrase\":\"Partly cloudy\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Partly cloudy\",\"LongPhrase\":\"Partly cloudy\",\"PrecipitationProbability\":0,\"ThunderstormProbability\":0,\"RainProbability\":0,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":4.6,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":160,\"Localized\":\"SS
"nyc": "{\"Headline\":{\"EffectiveDate\":\"2025-05-11T08:00:00-04:00\",\"EffectiveEpochDate\":1746964800,\"Severity\":4,\"Text\":\"Pleasant tomorrow\",\"Category\":\"mild\",\"EndDate\":null,\"EndEpochDate\":null,\"MobileLink\":\"http://www.accuweather.com/en/us/new-york-ny/10021/daily-weather-forecast/14-349727_1_al?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/us/new-york-ny/10021/daily-weather-forecast/14-349727_1_al?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00-04:00\",\"EpochDate\":1746874800,\"Sun\":{\"Rise\":\"2025-05-10T05:44:00-04:00\",\"EpochRise\":1746870240,\"Set\":\"2025-05-10T20:01:00-04:00\",\"EpochSet\":1746921660},\"Moon\":{\"Rise\":\"2025-05-10T18:24:00-04:00\",\"EpochRise\":1746915840,\"Set\":\"2025-05-11T04:49:00-04:00\",\"EpochSet\":1746953340,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":57,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":71,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":58,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cool\"},\"Maximum\":{\"Value\":74,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":58,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cool\"},\"Maximum\":{\"Value\":67,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"HoursOfSun\":9.8,\"DegreeDaySummary\":{\"Heating\":{\"Value\":1,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":54,\"Category\":\"Moderate\",\"CategoryValue\":2,\"Type\":\"Nitrogen Dioxide\"},{\"Name\":\"Grass\",\"Value\":2,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Mold\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":5,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":50,\"Category\":\"Moderate\",\"CategoryValue\":2},{\"Name\":\"UVIndex\",\"Value\":9,\"Category\":\"Very High\",\"CategoryValue\":4}],\"Day\":{\"Icon\":3,\"IconPhrase\":\"Partly sunny\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Partly sunny; breezy, warmer\",\"LongPhrase\":\"Partly sunny, breezy and warmer\",\"PrecipitationProbability\":0,\"ThunderstormProbability\":0,\"RainProbability\":0,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":10.4,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":300,\"Localized\":\"WNW\",\"English\":\"WNW\"}},\"WindGust\":{\"Speed\":{\"Value\":24.2,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":296,\"Localized\":\"WNW\",\"English\":\"WNW\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":37,\"Evapotranspiration\":{\"Value\":0.15,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":7814.5,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":26,\"Maximum\":63,\"Average\":41},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":47,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":53,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":51,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":51,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":62,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":58,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":33,\"IconPhrase\":\"Clear\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Clear\",\"LongPhrase\":\"Clear\",\"PrecipitationProbability\":1,\"ThunderstormProbability\":0,\"RainProbability\":1,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":3.5,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":268,\"Localized\":\"W\",\"English\":\"W\"}},\"WindGust\":{\"Speed\":{\"Value\":11.5,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":281,\"Localized\":\"W\",\"English\"
"tokyo": "{\"Headline\":{\"EffectiveDate\":\"2025-05-11T19:00:00+09:00\",\"EffectiveEpochDate\":1746957600,\"Severity\":5,\"Text\":\"Rain Sunday night\",\"Category\":\"rain\",\"EndDate\":\"2025-05-12T07:00:00+09:00\",\"EndEpochDate\":1747000800,\"MobileLink\":\"http://www.accuweather.com/en/jp/tokyo/226396/daily-weather-forecast/226396?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/jp/tokyo/226396/daily-weather-forecast/226396?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-11T07:00:00+09:00\",\"EpochDate\":1746914400,\"Sun\":{\"Rise\":\"2025-05-11T04:40:00+09:00\",\"EpochRise\":1746906000,\"Set\":\"2025-05-11T18:35:00+09:00\",\"EpochSet\":1746956100},\"Moon\":{\"Rise\":\"2025-05-11T17:24:00+09:00\",\"EpochRise\":1746951840,\"Set\":\"2025-05-12T03:55:00+09:00\",\"EpochSet\":1746989700,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":62,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":81,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":58,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cool\"},\"Maximum\":{\"Value\":82,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Very Warm\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":58,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cool\"},\"Maximum\":{\"Value\":77,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"HoursOfSun\":1,\"DegreeDaySummary\":{\"Heating\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":6,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":0,\"Category\":\"Good\",\"CategoryValue\":1,\"Type\":\"Ozone\"},{\"Name\":\"Grass\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Mold\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"UVIndex\",\"Value\":5,\"Category\":\"Moderate\",\"CategoryValue\":2}],\"Day\":{\"Icon\":4,\"IconPhrase\":\"Intermittent clouds\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Breezy in the afternoon\",\"LongPhrase\":\"Sun through high clouds and less humid; breezy in the afternoon\",\"PrecipitationProbability\":9,\"ThunderstormProbability\":0,\"RainProbability\":9,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":10.4,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":261,\"Localized\":\"W\",\"English\":\"W\"}},\"WindGust\":{\"Speed\":{\"Value\":17.3,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":179,\"Localized\":\"S\",\"English\":\"S\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":99,\"Evapotranspiration\":{\"Value\":0.13,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":512,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":42,\"Maximum\":70,\"Average\":51},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":62,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":65,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":64,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":64,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":71,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":68,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":18,\"IconPhrase\":\"Rain\",\"HasPrecipitation\":true,\"PrecipitationType\":\"Rain\",\"PrecipitationIntensity\":\"Light\",\"ShortPhrase\":\"On-and-off rain and drizzle\",\"LongPhrase\":\"On-and-off rain and drizzle\",\"PrecipitationProbability\":84,\"ThunderstormProbability\":0,\"RainProbability\":84,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":9.2,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":233,\"Localized\":\"SW\",\"English\":\"SW\"}},\"WindGust\":{\"Sp
2025-05-10 22:56:51 +01:00
"warsaw": "{\"Headline\":{\"EffectiveDate\":\"2025-05-10T23:00:00+02:00\",\"EffectiveEpochDate\":1746910800,\"Severity\":5,\"Text\":\"Expect showers late Saturday evening\",\"Category\":\"rain\",\"EndDate\":\"2025-05-11T05:00:00+02:00\",\"EndEpochDate\":1746932400,\"MobileLink\":\"http://www.accuweather.com/en/pl/warsaw/274663/daily-weather-forecast/274663?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/pl/warsaw/274663/daily-weather-forecast/274663?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00+02:00\",\"EpochDate\":1746853200,\"Sun\":{\"Rise\":\"2025-05-10T04:50:00+02:00\",\"EpochRise\":1746845400,\"Set\":\"2025-05-10T20:16:00+02:00\",\"EpochSet\":1746900960},\"Moon\":{\"Rise\":\"2025-05-10T18:14:00+02:00\",\"EpochRise\":1746893640,\"Set\":\"2025-05-11T03:54:00+02:00\",\"EpochSet\":1746928440,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":36,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":49,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":34,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cold\"},\"Maximum\":{\"Value\":46,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":34,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cold\"},\"Maximum\":{\"Value\":45,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"}},\"HoursOfSun\":4.8,\"DegreeDaySummary\":{\"Heating\":{\"Value\":22,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":0,\"Category\":\"Good\",\"CategoryValue\":1,\"Type\":\"Ozone\"},{\"Name\":\"Grass\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Mold\",\"Value\":300,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"UVIndex\",\"Value\":2,\"Category\":\"Low\",\"CategoryValue\":1}],\"Day\":{\"Icon\":12,\"IconPhrase\":\"Showers\",\"HasPrecipitation\":true,\"PrecipitationType\":\"Rain\",\"PrecipitationIntensity\":\"Light\",\"ShortPhrase\":\"Cold; spotty morning showers\",\"LongPhrase\":\"A few morning showers; otherwise, low clouds and cold\",\"PrecipitationProbability\":80,\"ThunderstormProbability\":16,\"RainProbability\":80,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":11.5,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":344,\"Localized\":\"NNW\",\"English\":\"NNW\"}},\"WindGust\":{\"Speed\":{\"Value\":27.6,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":350,\"Localized\":\"N\",\"English\":\"N\"}},\"TotalLiquid\":{\"Value\":0.05,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0.05,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":1.5,\"HoursOfRain\":1.5,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":88,\"Evapotranspiration\":{\"Value\":0.06,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":2939.2,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":59,\"Maximum\":94,\"Average\":74},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":41,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":46,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":44,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":47,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":49,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":48,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":39,\"IconPhrase\":\"Partly cloudy w/ showers\",\"HasPrecipitation\":true,\"PrecipitationType\":\"Rain\",\"PrecipitationIntensity\":\"Light\",\"ShortPhrase\":\"A shower or two this evening\",\"LongPhrase\":\"A couple of brief showers late this evening; clearing and chilly\",\"PrecipitationProbability\":49,\"ThunderstormProbability\":10,\"RainProbability\":49,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Sp
"zurich": "{\"Headline\":{\"EffectiveDate\":\"2025-05-11T08:00:00+02:00\",\"EffectiveEpochDate\":1746943200,\"Severity\":4,\"Text\":\"Pleasant Sunday\",\"Category\":\"mild\",\"EndDate\":null,\"EndEpochDate\":null,\"MobileLink\":\"http://www.accuweather.com/en/ch/zurich/316622/daily-weather-forecast/316622?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/ch/zurich/316622/daily-weather-forecast/316622?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00+02:00\",\"EpochDate\":1746853200,\"Sun\":{\"Rise\":\"2025-05-10T05:56:00+02:00\",\"EpochRise\":1746849360,\"Set\":\"2025-05-10T20:49:00+02:00\",\"EpochSet\":1746902940},\"Moon\":{\"Rise\":\"2025-05-10T18:54:00+02:00\",\"EpochRise\":1746896040,\"Set\":\"2025-05-11T04:58:00+02:00\",\"EpochSet\":1746932280,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":43,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":68,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":46,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"},\"Maximum\":{\"Value\":75,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":46,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"},\"Maximum\":{\"Value\":66,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"HoursOfSun\":12.3,\"DegreeDaySummary\":{\"Heating\":{\"Value\":10,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":0,\"Category\":\"Good\",\"CategoryValue\":1,\"Type\":\"Ozone\"},{\"Name\":\"Grass\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Mold\",\"Value\":517,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"UVIndex\",\"Value\":8,\"Category\":\"Very High\",\"CategoryValue\":4}],\"Day\":{\"Icon\":2,\"IconPhrase\":\"Mostly sunny\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Mostly sunny and milder\",\"LongPhrase\":\"Mostly sunny and milder\",\"PrecipitationProbability\":4,\"ThunderstormProbability\":0,\"RainProbability\":4,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":3.5,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":62,\"Localized\":\"ENE\",\"English\":\"ENE\"}},\"WindGust\":{\"Speed\":{\"Value\":8.1,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":70,\"Localized\":\"ENE\",\"English\":\"ENE\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":21,\"Evapotranspiration\":{\"Value\":0.15,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":8153.8,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":42,\"Maximum\":70,\"Average\":55},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":44,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":57,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":52,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":48,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":64,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":59,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":33,\"IconPhrase\":\"Clear\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Clear and chilly\",\"LongPhrase\":\"Clear and chilly\",\"PrecipitationProbability\":3,\"ThunderstormProbability\":0,\"RainProbability\":3,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":3.5,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":43,\"Localized\":\"NE\",\"English\":\"NE\"}},\"WindGust\":{\"Speed\":{\"Value\":8.1,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":43,\"Localized\":\"NE\",\"English\":\"NE\"}},\"TotalLiquid\":{\"Value\
"berlin": "{\"Headline\":{\"EffectiveDate\":\"2025-05-11T20:00:00+02:00\",\"EffectiveEpochDate\":1746986400,\"Severity\":7,\"Text\":\"Cool Sunday night\",\"Category\":\"cold\",\"EndDate\":\"2025-05-12T08:00:00+02:00\",\"EndEpochDate\":1747029600,\"MobileLink\":\"http://www.accuweather.com/en/de/berlin/10178/daily-weather-forecast/178087?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/de/berlin/10178/daily-weather-forecast/178087?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00+02:00\",\"EpochDate\":1746853200,\"Sun\":{\"Rise\":\"2025-05-10T05:19:00+02:00\",\"EpochRise\":1746847140,\"Set\":\"2025-05-10T20:47:00+02:00\",\"EpochSet\":1746902820},\"Moon\":{\"Rise\":\"2025-05-10T18:46:00+02:00\",\"EpochRise\":1746895560,\"Set\":\"2025-05-11T04:23:00+02:00\",\"EpochSet\":1746930180,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":44,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":68,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":41,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Chilly\"},\"Maximum\":{\"Value\":69,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":41,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Cold\"},\"Maximum\":{\"Value\":65,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Pleasant\"}},\"HoursOfSun\":6.4,\"DegreeDaySummary\":{\"Heating\":{\"Value\":9,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":0,\"Category\":\"Good\",\"CategoryValue\":1,\"Type\":\"Ozone\"},{\"Name\":\"Grass\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Mold\",\"Value\":330,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"UVIndex\",\"Value\":3,\"Category\":\"Moderate\",\"CategoryValue\":2}],\"Day\":{\"Icon\":4,\"IconPhrase\":\"Intermittent clouds\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Times of clouds and sun\",\"LongPhrase\":\"Times of clouds and sun\",\"PrecipitationProbability\":25,\"ThunderstormProbability\":0,\"RainProbability\":25,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":8.1,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":359,\"Localized\":\"N\",\"English\":\"N\"}},\"WindGust\":{\"Speed\":{\"Value\":23,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":79,\"Localized\":\"E\",\"English\":\"E\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":72,\"Evapotranspiration\":{\"Value\":0.1,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":4596.7,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":49,\"Maximum\":75,\"Average\":61},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":50,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":57,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":54,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":54,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":64,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":59,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":34,\"IconPhrase\":\"Mostly clear\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Mainly clear\",\"LongPhrase\":\"Mainly clear\",\"PrecipitationProbability\":4,\"ThunderstormProbability\":0,\"RainProbability\":4,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":8.1,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":65,\"Localized\":\"ENE\",\"English\":\"ENE\"}},\"WindGust\":{\"Speed\":{\"Value\":19.6,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":90,\"Localized\":\"E\",\"English\":\"E\"}}
"dubai": "{\"Headline\":{\"EffectiveDate\":\"2025-05-11T01:00:00+04:00\",\"EffectiveEpochDate\":1746910800,\"Severity\":7,\"Text\":\"Warm late Saturday night\",\"Category\":\"heat\",\"EndDate\":\"2025-05-11T07:00:00+04:00\",\"EndEpochDate\":1746932400,\"MobileLink\":\"http://www.accuweather.com/en/ae/dubai/323091/daily-weather-forecast/323091?lang=en-us\",\"Link\":\"http://www.accuweather.com/en/ae/dubai/323091/daily-weather-forecast/323091?lang=en-us\"},\"DailyForecasts\":[{\"Date\":\"2025-05-10T07:00:00+04:00\",\"EpochDate\":1746846000,\"Sun\":{\"Rise\":\"2025-05-10T05:37:00+04:00\",\"EpochRise\":1746841020,\"Set\":\"2025-05-10T18:54:00+04:00\",\"EpochSet\":1746888840},\"Moon\":{\"Rise\":\"2025-05-10T17:04:00+04:00\",\"EpochRise\":1746882240,\"Set\":\"2025-05-11T04:28:00+04:00\",\"EpochSet\":1746923280,\"Phase\":\"WaxingGibbous\",\"Age\":13},\"Temperature\":{\"Minimum\":{\"Value\":81,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":100,\"Unit\":\"F\",\"UnitType\":18}},\"RealFeelTemperature\":{\"Minimum\":{\"Value\":88,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Very Warm\"},\"Maximum\":{\"Value\":108,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Dangerous Heat\"}},\"RealFeelTemperatureShade\":{\"Minimum\":{\"Value\":88,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Very Warm\"},\"Maximum\":{\"Value\":103,\"Unit\":\"F\",\"UnitType\":18,\"Phrase\":\"Very Hot\"}},\"HoursOfSun\":11.5,\"DegreeDaySummary\":{\"Heating\":{\"Value\":0,\"Unit\":\"F\",\"UnitType\":18},\"Cooling\":{\"Value\":26,\"Unit\":\"F\",\"UnitType\":18}},\"AirAndPollen\":[{\"Name\":\"AirQuality\",\"Value\":0,\"Category\":\"Good\",\"CategoryValue\":1,\"Type\":\"Ozone\"},{\"Name\":\"Grass\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Mold\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Ragweed\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"Tree\",\"Value\":0,\"Category\":\"Low\",\"CategoryValue\":1},{\"Name\":\"UVIndex\",\"Value\":12,\"Category\":\"Extreme\",\"CategoryValue\":5}],\"Day\":{\"Icon\":2,\"IconPhrase\":\"Mostly sunny\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Mostly sunny and very warm\",\"LongPhrase\":\"Mostly sunny and very warm\",\"PrecipitationProbability\":0,\"ThunderstormProbability\":0,\"RainProbability\":0,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":8.1,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":29,\"Localized\":\"NNE\",\"English\":\"NNE\"}},\"WindGust\":{\"Speed\":{\"Value\":29.9,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":340,\"Localized\":\"NNW\",\"English\":\"NNW\"}},\"TotalLiquid\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Rain\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Snow\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"Ice\":{\"Value\":0,\"Unit\":\"in\",\"UnitType\":1},\"HoursOfPrecipitation\":0,\"HoursOfRain\":0,\"HoursOfSnow\":0,\"HoursOfIce\":0,\"CloudCover\":15,\"Evapotranspiration\":{\"Value\":0.26,\"Unit\":\"in\",\"UnitType\":1},\"SolarIrradiance\":{\"Value\":8508.5,\"Unit\":\"W/m²\",\"UnitType\":33},\"RelativeHumidity\":{\"Minimum\":40,\"Maximum\":74,\"Average\":52},\"WetBulbTemperature\":{\"Minimum\":{\"Value\":75,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":80,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":78,\"Unit\":\"F\",\"UnitType\":18}},\"WetBulbGlobeTemperature\":{\"Minimum\":{\"Value\":82,\"Unit\":\"F\",\"UnitType\":18},\"Maximum\":{\"Value\":90,\"Unit\":\"F\",\"UnitType\":18},\"Average\":{\"Value\":86,\"Unit\":\"F\",\"UnitType\":18}}},\"Night\":{\"Icon\":33,\"IconPhrase\":\"Clear\",\"HasPrecipitation\":false,\"ShortPhrase\":\"Clear and very warm\",\"LongPhrase\":\"Clear and very warm\",\"PrecipitationProbability\":0,\"ThunderstormProbability\":0,\"RainProbability\":0,\"SnowProbability\":0,\"IceProbability\":0,\"Wind\":{\"Speed\":{\"Value\":4.6,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":25,\"Localized\":\"NNE\",\"English\":\"NNE\"}},\"WindGust\":{\"Speed\":{\"Value\":11.5,\"Unit\":\"mi/h\",\"UnitType\":9},\"Direction\":{\"Degrees\":340
2025-05-10 22:44:47 +01:00
}
2025-05-10 00:36:38 +01:00
var supportedLocations = map[string]location{
2025-05-11 11:14:38 +01:00
"london": {nil, 51.507351, -0.127758, "Europe/London", "London"},
"sf": {nil, 37.774929, -122.419418, "America/Los_Angeles", "San Francisco"},
"sj": {nil, 37.338207, -121.886330, "America/Los_Angeles", "San Jose"},
"la": {nil, 34.052235, -118.243683, "America/Los_Angeles", "Los Angeles"},
"nyc": {nil, 40.712776, -74.005974, "America/New_York", "New York City"},
"tokyo": {nil, 35.689487, 139.691711, "Asia/Tokyo", "Tokyo"},
"warsaw": {nil, 52.229675, 21.012230, "Europe/Warsaw", "Warsaw"},
"zurich": {nil, 47.369019, 8.538030, "Europe/Zurich", "Zurich"},
"berlin": {nil, 52.520008, 13.404954, "Europe/Berlin", "Berlin"},
"dubai": {nil, 25.204849, 55.270782, "Asia/Dubai", "Dubai"},
2025-05-10 16:21:43 +01:00
}
2025-05-10 00:36:38 +01:00
func main() {
2025-05-10 15:21:48 +01:00
port := flag.Int("port", 8080, "the port that the server should listen on")
2025-05-10 15:28:55 +01:00
genKeys := flag.Bool("generate-vapid-keys", false, "generate a new vapid key pair, which will be outputted to stdout.")
2025-05-10 22:44:47 +01:00
usePlaceholder := flag.Bool("use-placeholder", false, "use placeholder data instead of real API data.")
2025-05-10 15:28:55 +01:00
flag.Parse()
if *genKeys {
generateKeys()
return
}
2025-05-10 15:21:48 +01:00
2025-05-11 11:46:59 +01:00
slog.Info("starting 7am...")
2025-05-10 17:35:51 +01:00
_ = godotenv.Load()
err := checkEnv()
2025-05-10 00:36:38 +01:00
if err != nil {
2025-05-10 17:35:51 +01:00
log.Fatal(err)
2025-05-10 00:36:38 +01:00
}
2025-05-11 11:53:17 +01:00
wd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
p := filepath.Join(wd, "data")
err = os.MkdirAll(p, os.ModePerm)
if err != nil {
log.Fatal(err)
}
slog.Info("data directory created", "path", p)
2025-05-10 13:04:39 +01:00
db, err := initDB()
if err != nil {
log.Fatalf("failed to initialize db: %e\n", err)
}
2025-05-10 00:36:38 +01:00
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
genaiClient, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: os.Getenv("GEMINI_API_KEY"),
Backend: genai.BackendGeminiAPI,
})
if err != nil {
log.Fatalf("failed to initialize gemini client: %e\n", err)
}
summaryHTML, _ := webDir.ReadFile("web/summary.html")
summaryPageTemplate, _ := template.New("summary.html").Parse(string(summaryHTML))
state := state{
2025-05-11 11:14:38 +01:00
ctx: ctx,
db: db,
metAPIUserAgent: os.Getenv("MET_API_USER_AGENT"),
2025-05-10 00:36:38 +01:00
template: pageTemplate{
summary: summaryPageTemplate,
},
summaries: sync.Map{},
summaryChans: map[string]chan string{},
genai: genaiClient,
2025-05-10 13:04:39 +01:00
2025-05-10 22:44:47 +01:00
usePlaceholder: *usePlaceholder,
subscriptions: map[string][]*registeredSubscription{},
2025-05-10 13:04:39 +01:00
vapidSubject: os.Getenv("VAPID_SUBJECT"),
2025-05-10 13:04:39 +01:00
vapidPublicKey: os.Getenv("VAPID_PUBLIC_KEY_BASE64"),
vapidPrivateKey: os.Getenv("VAPID_PRIVATE_KEY_BASE64"),
2025-05-10 00:36:38 +01:00
}
var schedulers []gocron.Scheduler
2025-05-10 15:13:31 +01:00
// schedule periodic updates of weather summary for each supported location
2025-05-10 00:36:38 +01:00
for locKey, loc := range supportedLocations {
l, err := time.LoadLocation(loc.ianaName)
if err != nil {
log.Fatal(err)
}
2025-05-11 11:14:38 +01:00
loc.tz = l
2025-05-10 00:36:38 +01:00
s, err := gocron.NewScheduler(gocron.WithLocation(l))
if err != nil {
log.Fatal(err)
}
_, err = s.NewJob(
2025-05-11 00:12:23 +01:00
gocron.DailyJob(1, gocron.NewAtTimes(gocron.NewAtTime(7, 0, 0))),
2025-05-11 11:14:38 +01:00
gocron.NewTask(updateSummary, &state, locKey, &loc),
2025-05-10 13:04:39 +01:00
)
2025-05-10 00:36:38 +01:00
if err != nil {
log.Fatal(err)
}
schedulers = append(schedulers, s)
2025-05-10 13:04:39 +01:00
c := make(chan string)
state.subscriptions[locKey] = []*registeredSubscription{}
2025-05-10 13:04:39 +01:00
state.summaryChans[locKey] = c
2025-05-10 15:13:31 +01:00
// listen for summary updates, and publish updates to all update subscribers via web push
2025-05-10 13:04:39 +01:00
go listenForSummaryUpdates(&state, locKey)
2025-05-10 00:36:38 +01:00
s.Start()
2025-05-11 11:46:59 +01:00
slog.Info("update job scheduled", "location", locKey)
2025-05-10 00:36:38 +01:00
}
2025-05-10 14:58:41 +01:00
err = loadSubscriptions(&state)
if err != nil {
log.Fatalf("failed to load existing subscriptions: %e\n", err)
}
2025-05-10 13:04:39 +01:00
2025-05-10 00:36:38 +01:00
http.HandleFunc("/", handleHTTPRequest(&state))
2025-05-10 15:21:48 +01:00
2025-05-11 11:46:59 +01:00
slog.Info("server starting", "port", *port)
2025-05-10 15:21:48 +01:00
err = http.ListenAndServe(fmt.Sprintf(":%d", *port), nil)
2025-05-10 14:58:41 +01:00
if err != nil {
log.Printf("failed to start http server: %e\n", err)
}
2025-05-10 00:36:38 +01:00
for _, s := range schedulers {
s.Shutdown()
}
2025-05-11 11:46:59 +01:00
slog.Info("7am shut down")
2025-05-10 00:36:38 +01:00
}
2025-05-10 15:28:55 +01:00
func generateKeys() {
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
log.Fatal(err)
}
fmt.Println("all keys are base64 url encoded.")
fmt.Printf("public key: %v\n", pub)
fmt.Printf("private key: %v\n", priv)
}
2025-05-10 17:35:51 +01:00
func checkEnv() error {
var missing []string
for _, k := range envKeys {
v := os.Getenv(k)
if v == "" {
missing = append(missing, k)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing env: %v", strings.Join(missing, ", "))
}
return nil
}
2025-05-10 00:36:38 +01:00
func handleHTTPRequest(state *state) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
path := strings.TrimPrefix(request.URL.Path, "/")
2025-05-10 13:04:39 +01:00
if path == "" {
if request.Method == "" || request.Method == "GET" {
index, _ := webDir.ReadFile("web/index.html")
writer.Write(index)
} else {
writer.WriteHeader(http.StatusMethodNotAllowed)
}
2025-05-11 13:11:46 +01:00
} else if path == "instructions" {
f, _ := webDir.ReadFile("web/instructions.html")
writer.Write(f)
2025-05-10 13:04:39 +01:00
} else if path == "vapid" {
if request.Method == "" || request.Method == "GET" {
writer.Write([]byte(state.vapidPublicKey))
} else {
writer.WriteHeader(http.StatusMethodNotAllowed)
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
} else if strings.HasPrefix(path, "registrations") {
if path == "registrations" && request.Method == "POST" {
defer request.Body.Close()
update := updateSubscription{}
err := json.NewDecoder(request.Body).Decode(&update)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
reg, err := registerSubscription(state, &update)
if err != nil {
2025-05-11 11:46:59 +01:00
slog.Error("web push subscription registration failed", "error", err)
2025-05-10 13:04:39 +01:00
writer.WriteHeader(http.StatusBadRequest)
return
}
err = json.NewEncoder(writer).Encode(reg)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
2025-05-11 11:46:59 +01:00
} else {
slog.Info("new web push registration", "id", reg.ID)
2025-05-10 13:04:39 +01:00
}
2025-05-10 14:58:41 +01:00
} else if request.Method == "PATCH" || request.Method == "DELETE" {
2025-05-10 13:04:39 +01:00
parts := strings.Split(path, "/")
if len(parts) < 2 {
writer.WriteHeader(http.StatusMethodNotAllowed)
return
}
regID, err := uuid.Parse(parts[1])
if err != nil {
writer.WriteHeader(http.StatusNotFound)
return
}
2025-05-10 14:58:41 +01:00
switch request.Method {
case "PATCH":
defer request.Body.Close()
2025-05-10 13:04:39 +01:00
2025-05-10 14:58:41 +01:00
update := updateSubscription{}
err = json.NewDecoder(request.Body).Decode(&update)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
2025-05-10 13:04:39 +01:00
2025-05-10 14:58:41 +01:00
reg, err := updateRegisteredSubscription(state, regID, &update)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writer.WriteHeader(http.StatusNotFound)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
2025-05-10 13:04:39 +01:00
} else {
2025-05-10 14:58:41 +01:00
json.NewEncoder(writer).Encode(reg)
2025-05-11 11:46:59 +01:00
slog.Info("web push registration updated", "id", reg.ID, "locations", strings.Join(reg.Locations, ","))
2025-05-10 14:58:41 +01:00
}
case "DELETE":
err = deleteSubscription(state, regID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writer.WriteHeader(http.StatusNotFound)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
} else {
writer.WriteHeader(http.StatusNoContent)
2025-05-11 11:46:59 +01:00
slog.Info("web push registration deleted", "id", regID)
2025-05-10 13:04:39 +01:00
}
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
} else {
writer.WriteHeader(http.StatusMethodNotAllowed)
}
} else {
if request.Method != "" && request.Method != "GET" {
writer.WriteHeader(http.StatusMethodNotAllowed)
return
2025-05-10 00:36:38 +01:00
}
summary, ok := state.summaries.Load(path)
if ok {
2025-05-11 00:08:20 +01:00
loc := supportedLocations[path]
state.template.summary.Execute(writer, summaryTemplateData{summary.(string), path, loc.displayName})
2025-05-10 00:36:38 +01:00
} else {
f, err := webDir.ReadFile("web/" + path)
if err != nil {
2025-05-10 14:58:41 +01:00
writer.WriteHeader(http.StatusNotFound)
2025-05-10 00:36:38 +01:00
} else {
m := mime.TypeByExtension(filepath.Ext(path))
if m != "" {
writer.Header().Set("Content-Type", m)
}
writer.Write(f)
}
}
}
}
}
2025-05-10 13:04:39 +01:00
func initDB() (*sql.DB, error) {
2025-05-10 19:14:37 +01:00
db, err := sql.Open("sqlite", "file:data/data.sqlite")
2025-05-10 13:04:39 +01:00
if err != nil {
log.Fatalln("failed to initialize database")
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS subscriptions(
id TEXT PRIMARY KEY,
locations TEXT NOT NULL,
subscription_json TEXT NOT NULL
);
`)
if err != nil {
return nil, err
}
return db, nil
}
func loadSubscriptions(state *state) error {
rows, err := state.db.Query(`SELECT id, locations, subscription_json FROM subscriptions;`)
if err != nil {
return err
}
defer rows.Close()
2025-05-10 13:04:39 +01:00
for rows.Next() {
var id string
var locations string
var j string
err := rows.Scan(&id, &locations, &j)
2025-05-10 00:36:38 +01:00
if err != nil {
2025-05-11 11:46:59 +01:00
slog.Warn("unable to load a subscription", "error", err)
2025-05-10 13:04:39 +01:00
continue
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
s := webpush.Subscription{}
err = json.Unmarshal([]byte(j), &s)
if err != nil {
2025-05-11 11:46:59 +01:00
slog.Warn("invalid web push subscription json encountered", "id", id, "error", err)
2025-05-10 13:04:39 +01:00
continue
}
2025-05-10 00:36:38 +01:00
reg := &registeredSubscription{
2025-05-10 13:04:39 +01:00
ID: uuid.MustParse(id),
Locations: strings.Split(locations, ","),
Subscription: &s,
}
for _, l := range reg.Locations {
state.subscriptions[l] = append(state.subscriptions[l], reg)
2025-05-10 00:36:38 +01:00
}
}
2025-05-10 13:04:39 +01:00
return nil
}
func updateRegisteredSubscription(state *state, id uuid.UUID, update *updateSubscription) (*registeredSubscription, error) {
j, err := json.Marshal(update.Subscription)
if err != nil {
return nil, err
}
rows, err := state.db.Query("SELECT locations FROM subscriptions WHERE id = ?", id)
if err != nil {
return nil, err
}
rows.Next()
var locStr string
err = rows.Scan(&locStr)
if err != nil {
return nil, err
}
rows.Close()
2025-05-10 18:27:29 +01:00
// not very proud of this one
// ideally the list of locations should be stored in a separate table
// but since the list is very small, and im too lazy to bring in a separate table
// this should be fine for now
locs := strings.Split(locStr, ",")
locs = append(locs, update.Locations...)
2025-05-10 18:27:29 +01:00
locs = slices.DeleteFunc(locs, func(l string) bool {
return slices.Contains(update.RemoveLocations, l)
})
locs = slices.Compact(locs)
2025-05-10 13:04:39 +01:00
_, err = state.db.Exec(
"UPDATE subscriptions SET subscription_json = ?, locations = ? WHERE id = ?",
string(j), strings.Join(locs, ","), id,
2025-05-10 13:04:39 +01:00
)
if err != nil {
return nil, err
}
reg := &registeredSubscription{
2025-05-10 13:04:39 +01:00
ID: id,
Subscription: &update.Subscription,
Locations: locs,
}
state.subscriptionsMutex.Lock()
for _, l := range update.Locations {
state.subscriptions[l] = append(state.subscriptions[l], reg)
}
2025-05-10 18:27:29 +01:00
for _, l := range update.RemoveLocations {
state.subscriptions[l] = slices.DeleteFunc(state.subscriptions[l], func(s *registeredSubscription) bool {
return s.ID == reg.ID
})
}
state.subscriptionsMutex.Unlock()
return reg, nil
2025-05-10 13:04:39 +01:00
}
func registerSubscription(state *state, sub *updateSubscription) (*registeredSubscription, error) {
j, err := json.Marshal(sub.Subscription)
if err != nil {
2025-05-11 11:46:59 +01:00
return nil, fmt.Errorf("invalid web push subscription object: %w", err)
2025-05-10 13:04:39 +01:00
}
id, err := uuid.NewV7()
if err != nil {
2025-05-11 11:46:59 +01:00
return nil, fmt.Errorf("unable to generate id for subscription: %w", err)
2025-05-10 13:04:39 +01:00
}
2025-05-10 18:27:29 +01:00
locs := slices.Compact(sub.Locations)
2025-05-10 13:04:39 +01:00
_, err = state.db.Exec(
"INSERT INTO subscriptions (id, locations, subscription_json) VALUES (?, ?, ?);",
2025-05-10 18:27:29 +01:00
id, strings.Join(locs, ","), string(j),
2025-05-10 13:04:39 +01:00
)
if err != nil {
2025-05-11 11:46:59 +01:00
return nil, fmt.Errorf("unable to insert into subscriptions table: %w", err)
2025-05-10 13:04:39 +01:00
}
reg := registeredSubscription{
ID: id,
Subscription: &sub.Subscription,
2025-05-10 18:27:29 +01:00
Locations: locs,
2025-05-10 13:04:39 +01:00
}
state.subscriptionsMutex.Lock()
2025-05-10 13:04:39 +01:00
for _, l := range sub.Locations {
state.subscriptions[l] = append(state.subscriptions[l], &reg)
2025-05-10 13:04:39 +01:00
}
state.subscriptionsMutex.Unlock()
2025-05-10 13:04:39 +01:00
return &reg, nil
2025-05-10 00:36:38 +01:00
}
2025-05-10 14:58:41 +01:00
func deleteSubscription(state *state, regID uuid.UUID) error {
_, err := state.db.Exec("DELETE FROM subscriptions WHERE id = ?", regID)
return err
}
2025-05-11 11:14:38 +01:00
func updateSummary(state *state, locKey string, loc *location) {
2025-05-11 11:46:59 +01:00
slog.Info("updating weather summary", "location", locKey)
2025-05-10 00:36:38 +01:00
2025-05-12 12:09:58 +01:00
today := time.Now().In(loc.tz)
2025-05-10 22:44:47 +01:00
var weatherJSON string
if state.usePlaceholder {
weatherJSON = placeholderWeather[locKey]
} else {
2025-05-11 11:14:38 +01:00
req, err := http.NewRequest("GET", fmt.Sprintf("https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=%v&lon=%v", loc.lat, loc.lon), nil)
if err != nil {
2025-05-11 11:46:59 +01:00
slog.Error("failed to query weather data", "location", locKey, "error", err)
2025-05-11 11:14:38 +01:00
return
}
req.Header.Set("User-Agent", state.metAPIUserAgent)
resp, err := http.DefaultClient.Do(req)
2025-05-10 22:44:47 +01:00
if err != nil {
2025-05-11 11:46:59 +01:00
slog.Error("failed to query weather data", "location", locKey, "error", err)
2025-05-10 22:44:47 +01:00
return
}
2025-05-10 00:36:38 +01:00
2025-05-10 22:44:47 +01:00
defer resp.Body.Close()
2025-05-12 12:09:58 +01:00
data := metAPIData{}
err = json.NewDecoder(resp.Body).Decode(&data)
2025-05-10 22:44:47 +01:00
if err != nil {
2025-05-12 12:09:58 +01:00
slog.Error("failed to decode received weather data", "location", locKey, "error", err)
return
}
y, m, d := today.Date()
t := slices.DeleteFunc(data.Properties.TimeSeries, func(series map[string]any) bool {
if ts, ok := series["time"].(string); ok {
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return false
}
ty, tm, td := t.In(loc.tz).Date()
return !(y == ty && m == tm && d == td)
}
return false
})
b, err := json.Marshal(t)
if err != nil {
slog.Error("failed to marshal processed time series data", "location", locKey, "error", err)
2025-05-10 22:44:47 +01:00
return
}
weatherJSON = string(b)
2025-05-10 00:36:38 +01:00
}
result, err := state.genai.Models.GenerateContent(state.ctx, "gemini-2.0-flash", []*genai.Content{{
Parts: []*genai.Part{
2025-05-12 12:09:58 +01:00
{Text: fmt.Sprintf(prompt, today.Format("2006-02-01"), loc.displayName, loc.displayName)},
2025-05-10 22:44:47 +01:00
{Text: weatherJSON},
2025-05-10 00:36:38 +01:00
},
}}, nil)
if err != nil {
2025-05-11 11:46:59 +01:00
slog.Error("failed to generate weather summary", "location", locKey, "error", err)
2025-05-10 00:36:38 +01:00
return
}
summary := result.Text()
c := state.summaryChans[locKey]
state.summaries.Store(locKey, summary)
2025-05-10 13:04:39 +01:00
if len(state.subscriptions[locKey]) > 0 {
2025-05-10 00:36:38 +01:00
c <- summary
}
2025-05-11 11:46:59 +01:00
slog.Info("updated weather summary", "location", locKey)
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
func listenForSummaryUpdates(state *state, locKey string) {
c := state.summaryChans[locKey]
2025-05-10 15:13:31 +01:00
opts := webpush.Options{
Subscriber: state.vapidSubject,
2025-05-10 15:13:31 +01:00
VAPIDPublicKey: state.vapidPublicKey,
VAPIDPrivateKey: state.vapidPrivateKey,
TTL: 30,
}
2025-05-10 13:04:39 +01:00
for {
select {
case summary := <-c:
2025-05-10 18:35:06 +01:00
payload := webpushNotificationPayload{
Summary: summary,
Location: locKey,
}
b, err := json.Marshal(&payload)
if err != nil {
2025-05-11 11:46:59 +01:00
slog.Error("failed to create web push notification payload", "location", locKey, "error", err)
2025-05-10 18:35:06 +01:00
continue
}
2025-05-11 11:46:59 +01:00
subs := state.subscriptions[locKey]
slog.Info("pushing weather summary to subscribers", "count", len(subs), "location", locKey)
2025-05-10 14:58:41 +01:00
var wg sync.WaitGroup
2025-05-11 11:46:59 +01:00
for _, sub := range subs {
2025-05-10 14:58:41 +01:00
wg.Add(1)
go func() {
defer wg.Done()
2025-05-10 18:35:06 +01:00
_, err := webpush.SendNotificationWithContext(state.ctx, b, sub.Subscription, &opts)
2025-05-10 14:58:41 +01:00
if err != nil {
2025-05-11 11:46:59 +01:00
slog.Warn("unable to send web push to subscription", "id", sub.ID, "location", locKey, "error", err)
2025-05-10 14:58:41 +01:00
}
}()
2025-05-10 13:04:39 +01:00
}
2025-05-10 14:58:41 +01:00
wg.Wait()
2025-05-11 11:46:59 +01:00
slog.Info("pushed weather summary to subscribers", "count", len(subs), "location", locKey)
2025-05-10 13:04:39 +01:00
case <-state.ctx.Done():
return
}
}
}