Files
7am/main.go

676 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"
"io"
"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-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 11:14:38 +01:00
var prompt = `The current date and time is %v 7:00am. Provide a short summary of the weather forecast only for today in JSON in %v below.
Keep it concise. Suggest how to deal with the weather, such as how to dress for the weather, and whether they need an umbrella, but err on the side of caution.
Use celsius and fahrenheit but not Kelvin for temperature.
Mention %v in the summary, but don't add anything else, as the summary will be displayed on a website.
Do not mention today's date or time in the summary.
2025-05-10 22:44:47 +01:00
The summary should be in plaintext for humans. Do not output in JSON.`
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
gocron.WithStartAt(gocron.WithStartImmediately()),
)
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)
}
} 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-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
b, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
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
}
weatherJSON = string(b)
2025-05-10 00:36:38 +01:00
}
2025-05-11 11:14:38 +01:00
date := time.Now().In(loc.tz).Format("2006-02-01")
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-11 11:14:38 +01:00
{Text: fmt.Sprintf(prompt, date, 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
}
}
}