orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package tools
2
3import (
4 "context"
5 "fmt"
6 "math"
7 "net/url"
8 "sort"
9 "strings"
10 "time"
11)
12
13// The readings behind a widget. These are called from the page rather than from
14// a turn, so a chart can change range without the model being involved and a
15// reopened conversation draws today's numbers.
16
17// Range is one of the four spans the chart offers. Yahoo wants a range and an
18// interval together and the pair has to be chosen rather than derived: a year
19// at five minute bars is 20,000 points nobody can see and a day at daily bars
20// is one.
21type Range struct {
22 Key string
23 Label string
24 Range string
25 Interval string
26}
27
28var Ranges = []Range{
29 {"1d", "1D", "1d", "5m"},
30 {"1w", "1W", "5d", "30m"},
31 {"1m", "1M", "1mo", "1d"},
32 {"1y", "1Y", "1y", "1d"},
33}
34
35func RangeByKey(k string) Range {
36 for _, r := range Ranges {
37 if r.Key == k {
38 return r
39 }
40 }
41 return Ranges[0]
42}
43
44type Point struct {
45 T int64 `json:"t"`
46 C float64 `json:"c"`
47}
48
49type Series struct {
50 Symbol string `json:"symbol"`
51 Name string `json:"name,omitempty"`
52 Currency string `json:"currency,omitempty"`
53 Range string `json:"range"`
54 Price float64 `json:"price"`
55 Previous float64 `json:"previous"`
56 Change float64 `json:"change"`
57 Percent float64 `json:"percent"`
58 Points []Point `json:"points"`
59
60 // Intraday is the one range whose baseline means yesterday's close rather
61 // than the first bar drawn, which is what the dotted rule on the chart is.
62 Intraday bool `json:"intraday"`
63}
64
65// Ticker reads one symbol over one range. Symbols arrive already in Yahoo's
66// form, indexes as ^GSPC and futures as GC=F and crypto as BTC-USD, because the
67// markets tool resolved them before the widget was ever recorded.
68func Ticker(ctx context.Context, d *Deps, symbol, rangeKey string) (Series, error) {
69 r := RangeByKey(rangeKey)
70 var out chartPayload
71 u := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=%s&interval=%s",
72 url.PathEscape(symbol), r.Range, r.Interval)
73 if err := getJSON(ctx, d, u, &out); err != nil {
74 return Series{}, err
75 }
76 return buildSeries(out, r, symbol)
77}
78
79// chartPayload is Yahoo's chart response, only the fields the panel draws.
80type chartPayload struct {
81 Chart struct {
82 Result []struct {
83 Meta struct {
84 Currency string `json:"currency"`
85 Symbol string `json:"symbol"`
86 Price float64 `json:"regularMarketPrice"`
87 PrevClose float64 `json:"chartPreviousClose"`
88 ShortName string `json:"shortName"`
89 LongName string `json:"longName"`
90 } `json:"meta"`
91 Timestamp []int64 `json:"timestamp"`
92 Indicators struct {
93 Quote []struct {
94 Close []*float64 `json:"close"`
95 } `json:"quote"`
96 } `json:"indicators"`
97 } `json:"result"`
98 } `json:"chart"`
99}
100
101// buildSeries is the arithmetic, split from the fetch so the part that decides
102// what a percentage means can be tested without a network.
103func buildSeries(out chartPayload, r Range, symbol string) (Series, error) {
104 if len(out.Chart.Result) == 0 {
105 return Series{}, fmt.Errorf("no chart for %q", symbol)
106 }
107 res := out.Chart.Result[0]
108
109 s := Series{
110 Symbol: strings.ToUpper(symbol), Currency: res.Meta.Currency,
111 Range: r.Key, Price: res.Meta.Price, Previous: res.Meta.PrevClose,
112 Intraday: r.Key == "1d",
113 }
114 s.Name = res.Meta.LongName
115 if s.Name == "" {
116 s.Name = res.Meta.ShortName
117 }
118
119 // A gap in the series is a bar the exchange never printed, so it is dropped
120 // rather than zeroed. A zero would draw a spike to the floor of the chart.
121 if len(res.Indicators.Quote) > 0 {
122 cl := res.Indicators.Quote[0].Close
123 for i := range cl {
124 if cl[i] == nil || i >= len(res.Timestamp) {
125 continue
126 }
127 s.Points = append(s.Points, Point{T: res.Timestamp[i], C: round4(*cl[i])})
128 }
129 }
130 if len(s.Points) == 0 {
131 return Series{}, fmt.Errorf("no prices for %q over %s", symbol, r.Label)
132 }
133
134 // The last bar is the price when the quote field is empty, which is what
135 // happens on an index and on anything out of hours.
136 if s.Price == 0 {
137 s.Price = s.Points[len(s.Points)-1].C
138 }
139 // Over a longer span the change is measured from the first bar on the
140 // chart, since "up 18% this year" means against a year ago and not against
141 // yesterday. Only the intraday chart is measured from the previous close.
142 base := s.Previous
143 if !s.Intraday || base == 0 {
144 base = s.Points[0].C
145 if !s.Intraday {
146 s.Previous = base
147 }
148 }
149 if base > 0 {
150 s.Change = round4(s.Price - base)
151 s.Percent = round2((s.Price - base) / base * 100)
152 }
153 return s, nil
154}
155
156// ---------------------------------------------------------------- weather
157
158type Day struct {
159 Date string `json:"date"`
160 Weekday string `json:"weekday"`
161 HighF float64 `json:"high_f"`
162 LowF float64 `json:"low_f"`
163 FeelsHigh float64 `json:"feels_high_f"`
164 FeelsLow float64 `json:"feels_low_f"`
165 PrecipPct float64 `json:"precip_pct"`
166 UV float64 `json:"uv"`
167 Code int `json:"code"`
168 Summary string `json:"summary"`
169}
170
171type Report struct {
172 Place string `json:"place"`
173 Days []Day `json:"days"`
174
175 NowF float64 `json:"now_f"`
176 FeelsF float64 `json:"feels_f"`
177 Humidity float64 `json:"humidity"`
178 WindMPH float64 `json:"wind_mph"`
179 Code int `json:"code"`
180 Summary string `json:"summary"`
181
182 AQI float64 `json:"aqi"`
183 AQIBand string `json:"aqi_band,omitempty"`
184 PM25 float64 `json:"pm25"`
185 HasAir bool `json:"has_air"`
186
187 Pollen float64 `json:"pollen"`
188 PollenBand string `json:"pollen_band,omitempty"`
189 PollenTop string `json:"pollen_top,omitempty"`
190 HasPollen bool `json:"has_pollen"`
191}
192
193// Forecast is the daily outlook plus the two readings that are not in it. The air
194// call and the pollen call are separate hosts and either can be missing without
195// the panel being wrong, so each failure only costs its own tile.
196func Forecast(ctx context.Context, d *Deps, lat, lon float64, place, zip, country string, days int) (Report, error) {
197 if days < 1 || days > 14 {
198 days = 7
199 }
200 var w struct {
201 Current struct {
202 Temp float64 `json:"temperature_2m"`
203 Feels float64 `json:"apparent_temperature"`
204 Humidity float64 `json:"relative_humidity_2m"`
205 Wind float64 `json:"wind_speed_10m"`
206 Code int `json:"weather_code"`
207 } `json:"current"`
208 Daily struct {
209 Time []string `json:"time"`
210 Max []float64 `json:"temperature_2m_max"`
211 Min []float64 `json:"temperature_2m_min"`
212 FeelsMax []float64 `json:"apparent_temperature_max"`
213 FeelsMin []float64 `json:"apparent_temperature_min"`
214 Precip []float64 `json:"precipitation_probability_max"`
215 UV []float64 `json:"uv_index_max"`
216 Code []int `json:"weather_code"`
217 } `json:"daily"`
218 }
219 u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f"+
220 "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code"+
221 "&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,"+
222 "precipitation_probability_max,uv_index_max,weather_code"+
223 "&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days=%d", lat, lon, days)
224 if err := getJSON(ctx, d, u, &w); err != nil {
225 return Report{}, err
226 }
227
228 rep := Report{Place: place, NowF: round1(w.Current.Temp), FeelsF: round1(w.Current.Feels),
229 Humidity: round1(w.Current.Humidity), WindMPH: round1(w.Current.Wind),
230 Code: w.Current.Code, Summary: wmo(w.Current.Code)}
231
232 for i := range w.Daily.Time {
233 dd := Day{Date: w.Daily.Time[i]}
234 if t, e := time.Parse("2006-01-02", w.Daily.Time[i]); e == nil {
235 dd.Weekday = t.Format("Mon")
236 }
237 at := func(s []float64) float64 {
238 if i < len(s) {
239 return round1(s[i])
240 }
241 return 0
242 }
243 dd.HighF, dd.LowF = at(w.Daily.Max), at(w.Daily.Min)
244 dd.FeelsHigh, dd.FeelsLow = at(w.Daily.FeelsMax), at(w.Daily.FeelsMin)
245 dd.PrecipPct, dd.UV = at(w.Daily.Precip), at(w.Daily.UV)
246 if i < len(w.Daily.Code) {
247 dd.Code = w.Daily.Code[i]
248 dd.Summary = wmo(dd.Code)
249 }
250 rep.Days = append(rep.Days, dd)
251 }
252
253 rep.airQuality(ctx, d, lat, lon)
254 rep.pollen(ctx, d, zip, country)
255 return rep, nil
256}
257
258func (rep *Report) airQuality(ctx context.Context, d *Deps, lat, lon float64) {
259 var air struct {
260 Current struct {
261 AQI *float64 `json:"us_aqi"`
262 PM25 *float64 `json:"pm2_5"`
263 } `json:"current"`
264 }
265 u := fmt.Sprintf("https://air-quality-api.open-meteo.com/v1/air-quality?latitude=%f&longitude=%f"+
266 "¤t=us_aqi,pm2_5&timezone=auto", lat, lon)
267 if err := getJSON(ctx, d, u, &air); err != nil || air.Current.AQI == nil {
268 return
269 }
270 rep.AQI, rep.HasAir = math.Round(*air.Current.AQI), true
271 rep.AQIBand = aqiBand(rep.AQI)
272 if air.Current.PM25 != nil {
273 rep.PM25 = round1(*air.Current.PM25)
274 }
275}
276
277// pollen is pollen.com, which is the source dash settled on for the same
278// reason: open-meteo carries pollen for Europe only and returns null for every
279// US location. It is keyed on a zip, which the geocode already knew, and it
280// refuses a request that arrives without a Referer.
281func (rep *Report) pollen(ctx context.Context, d *Deps, zip, country string) {
282 if zip == "" || (country != "" && country != "US") {
283 return
284 }
285 var p struct {
286 Location struct {
287 Periods []struct {
288 Type string `json:"Type"`
289 Index float64 `json:"Index"`
290 Triggers []struct {
291 Name string `json:"Name"`
292 } `json:"Triggers"`
293 } `json:"periods"`
294 } `json:"Location"`
295 }
296 u := "https://www.pollen.com/api/forecast/current/pollen/" + url.PathEscape(zip)
297 if err := getJSONHeaders(ctx, d, u, map[string]string{
298 "Referer": "https://www.pollen.com/forecast/current/pollen/" + zip,
299 }, &p); err != nil {
300 return
301 }
302 for _, per := range p.Location.Periods {
303 if !strings.EqualFold(per.Type, "Today") {
304 continue
305 }
306 rep.Pollen, rep.HasPollen = round1(per.Index), true
307 rep.PollenBand = pollenBand(per.Index)
308 names := make([]string, 0, len(per.Triggers))
309 for _, t := range per.Triggers {
310 names = append(names, t.Name)
311 }
312 sort.Strings(names)
313 rep.PollenTop = strings.Join(names, ", ")
314 return
315 }
316}
317
318// pollenBand is pollen.com's own 0 to 12 scale, the same bands dash reads it on.
319func pollenBand(i float64) string {
320 switch {
321 case i < 2.4:
322 return "low"
323 case i < 4.8:
324 return "low-medium"
325 case i < 7.2:
326 return "medium"
327 case i < 9.7:
328 return "medium-high"
329 default:
330 return "high"
331 }
332}
333
334// aqiBand is the EPA's own naming for the US AQI breakpoints.
335func aqiBand(v float64) string {
336 switch {
337 case v <= 50:
338 return "good"
339 case v <= 100:
340 return "moderate"
341 case v <= 150:
342 return "unhealthy for some"
343 case v <= 200:
344 return "unhealthy"
345 case v <= 300:
346 return "very unhealthy"
347 default:
348 return "hazardous"
349 }
350}
351
352// wmo names the weather code open-meteo answers with. The list is theirs and
353// the wording is shortened to what fits a tile.
354func wmo(c int) string {
355 switch c {
356 case 0:
357 return "clear"
358 case 1:
359 return "mostly clear"
360 case 2:
361 return "partly cloudy"
362 case 3:
363 return "overcast"
364 case 45, 48:
365 return "fog"
366 case 51, 53, 55:
367 return "drizzle"
368 case 56, 57:
369 return "freezing drizzle"
370 case 61, 63, 65:
371 return "rain"
372 case 66, 67:
373 return "freezing rain"
374 case 71, 73, 75:
375 return "snow"
376 case 77:
377 return "snow grains"
378 case 80, 81, 82:
379 return "showers"
380 case 85, 86:
381 return "snow showers"
382 case 95:
383 return "thunderstorms"
384 case 96, 99:
385 return "thunderstorms, hail"
386 }
387 return ""
388}
389
390func round2(v float64) float64 { return math.Round(v*100) / 100 }