chore: added templating for weather 77b51757
Steve · 2026-06-07 12:47 7 file(s) · +132 −17
TODO.md +1 −6
4 4
    - If no value is given, create value using default location with fresh API call
5 5
  - Store as new column in db called `weather`
6 6
    - `"{conditions},{temperature},{city},{state}"`
7 -
  - Need DB migration
8 -
  - Show in available attributes
9 -
  - Handle null state `Error fetching forecast: Get "": unsupported protocol scheme ""`
10 -
  - Handle Edits
11 -
  - Create display icon mapping
12 -
  
7 +
  - Customize the conditions mapping vs literal placement
apps/posts/app.go +1 −0
252 252
	SiteURL         string
253 253
	HeaderHTML      template.HTML
254 254
	FooterHTML      template.HTML
255 +
	Weather 				Weather
255 256
}
256 257
257 258
type pagePageData struct {
apps/posts/handlers_admin.go +9 −0
152 152
	if t := strings.TrimSpace(attrs.PublishedDate); t != "" {
153 153
		pubDate = &t
154 154
	}
155 +
		defaultLocation, err := getSetting(a.DB, "default_location")
156 +
	if err != nil {
157 +
		defaultLocation = ""
158 +
	}
159 +
	weather := getWeather(defaultLocation)
160 +
	if newWeather := strings.TrimSpace(attrs.Weather); newWeather != "" {
161 +
		weather = newWeather
162 +
	}
155 163
	in := PostInput{
156 164
		Title: optStr(title), Slug: slug, Content: r.FormValue("content"),
157 165
		Status: status, Alias: optStr(attrs.Alias),
159 167
		MetaDescription: optStr(attrs.MetaDescription),
160 168
		MetaImage:       optStr(attrs.MetaImage),
161 169
		Lang:            lang, Tags: optStr(attrs.Tags),
170 +
		Weather:				 optStr(weather),
162 171
	}
163 172
	if _, err := updatePost(a.DB, shortID, in); err != nil {
164 173
		a.Log.Error("update post", "err", err)
apps/posts/handlers_public.go +11 −1
4 4
	"html/template"
5 5
	"net/http"
6 6
	"strings"
7 +
	"fmt"
7 8
)
8 9
9 10
func (a *App) site() siteContext {
95 96
		http.Error(w, "Not found", http.StatusNotFound)
96 97
		return
97 98
	}
99 +
	var weather Weather
100 +
	if post.Weather != nil {
101 +
		w, err := formatWeather(*post.Weather)
102 +
		if err != nil {
103 +
			fmt.Printf("Problem formatting weather: %s", err.Error())
104 +
		} else {
105 +
			weather = w
106 +
		}
107 +
	}
98 108
	ctx := a.site()
99 109
	rendered := renderMarkdown(post.Content)
100 110
	a.renderPage(w, "post.html", postPageData{
101 111
		BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Post: *post,
102 112
		RenderedContent: template.HTML(rendered),
103 113
		FaviconURL:      ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
104 -
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
114 +
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, Weather: weather,
105 115
	})
106 116
}
107 117
apps/posts/static/styles.css +12 −1
79 79
80 80
.post-date {
81 81
  font-size: 12px;
82 -
  opacity: 0.5;
83 82
}
84 83
85 84
.post-excerpt {
92 91
  display: flex;
93 92
  gap: 0.4rem;
94 93
  flex-wrap: wrap;
94 +
}
95 +
96 +
.post-weather {
97 +
  display: flex;
98 +
  align-items: center;
99 +
  gap: 0.75rem;
100 +
  font-size: 12px;
101 +
  opacity: 0.5;
102 +
}
103 +
104 +
.post-weather svg {
105 +
  height: 16px;
95 106
}
96 107
97 108
/* Post header (public single) */
apps/posts/templates/post.html +11 −1
28 28
    {{if .Post.MetaDescription}}
29 29
      <p class="post-description">{{.Post.MetaDescriptionStr}}</p>
30 30
    {{end}}
31 +
    <div class="post-weather">
31 32
    {{if .Post.PublishedDate}}
33 +
      <span>
32 34
      <time class="post-date">{{.Post.PublishedDateStr}}</time>
35 +
      </span>
36 +
      <span>•</span>
33 37
    {{end}}
34 38
    {{if .Post.Weather}}
35 -
      <p class="post-description">{{.Post.Weather}}</p>
39 +
      {{.Weather.Icon}}
40 +
      <p class="weather-conditions">{{.Weather.Conditions}}</p>
41 +
      <p class="weather-temp">{{.Weather.Temperature}}</p>
42 +
43 +
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M128 68a36 36 0 1 0 36 36a36 36 0 0 0-36-36m0 64a28 28 0 1 1 28-28a28 28 0 0 1-28 28m0-112a84.09 84.09 0 0 0-84 84c0 30.42 14.17 62.79 41 93.62a250 250 0 0 0 40.73 37.66a4 4 0 0 0 4.58 0A250 250 0 0 0 171 197.62c26.81-30.83 41-63.2 41-93.62a84.09 84.09 0 0 0-84-84m37.1 172.23A254.6 254.6 0 0 1 128 227a254.6 254.6 0 0 1-37.1-34.81C73.15 171.8 52 139.9 52 104a76 76 0 0 1 152 0c0 35.9-21.15 67.8-38.9 88.23"/></svg>
44 +
      <p class="weather-location">{{.Weather.Location}}</p>
36 45
    {{end}}
46 +
    </div>
37 47
    {{if .Post.Tags}}
38 48
      <div class="post-tags">
39 49
        {{range .Post.TagList}}
apps/posts/util.go +87 −8
1 1
package main
2 2
3 3
import (
4 +
	"encoding/json"
4 5
	"fmt"
6 +
	"html/template"
5 7
	"net/http"
8 +
	"strconv"
6 9
	"strings"
7 10
	"time"
8 -
	"encoding/json"
9 -
	"strconv"
10 11
)
11 12
12 13
type parsedAttributes struct {
35 36
}
36 37
37 38
type Period struct {
38 -
    Temperature  int    `json:"temperature"`
39 -
    ShortForecast string `json:"shortForecast"`
39 +
	Temperature   int    `json:"temperature"`
40 +
	ShortForecast string `json:"shortForecast"`
40 41
}
41 42
42 43
type WeatherForecastResponse struct {
43 -
    Properties struct {
44 -
        Periods []Period `json:"periods"`
45 -
    } `json:"properties"`
44 +
	Properties struct {
45 +
		Periods []Period `json:"periods"`
46 +
	} `json:"properties"`
47 +
}
48 +
49 +
type Weather struct {
50 +
	Icon        template.HTML
51 +
	Conditions  string
52 +
	Temperature string
53 +
	Location    string
46 54
}
47 55
48 56
func parseAttributes(text string) parsedAttributes {
349 357
}
350 358
351 359
func getWeather(location string) string {
360 +
	if location == "" {
361 +
		return ""
362 +
	}
352 363
	// Fetch Points data using lat,long
353 364
	pointURL := fmt.Sprintf("https://api.weather.gov/points/%s", location)
354 365
	resp, err := http.Get(pointURL)
375 386
	temp := strconv.Itoa(weatherForecast.Properties.Periods[0].Temperature)
376 387
	conditions := weatherForecast.Properties.Periods[0].ShortForecast
377 388
378 -
	weather := fmt.Sprintf("%s,%s,%s,%s", conditions, temp, city,state)
389 +
	weather := fmt.Sprintf("%s,%s,%s,%s", conditions, temp, city, state)
379 390
	return weather
380 391
}
381 392
393 +
func formatWeather(weather string) (Weather, error) {
394 +
	parts := strings.Split(weather, ",")
395 +
	if len(parts) < 4 {
396 +
		return Weather{}, fmt.Errorf("unexpected weather format: %q", weather)
397 +
	}
398 +
	for i := range parts {
399 +
		parts[i] = strings.TrimSpace(parts[i]) // handles ", " vs ","
400 +
	}
401 +
402 +
	conditions := parts[0]
403 +
	return Weather{
404 +
		Conditions:  conditions,
405 +
		Temperature: fmt.Sprintf("%s°F", parts[1]),
406 +
		Location:    fmt.Sprintf("%s, %s", parts[2], parts[3]),
407 +
		Icon:        weatherIcons[categorize(conditions)],
408 +
	}, nil
409 +
}
410 +
411 +
type WeatherCategory int
412 +
413 +
const (
414 +
	CatStorm WeatherCategory = iota
415 +
	CatSleet
416 +
	CatSnow
417 +
	CatRain
418 +
	CatFog
419 +
	CatPartlyCloudy
420 +
	CatCloudy
421 +
	CatClear
422 +
	CatUnknown
423 +
)
424 +
425 +
var categoryRules = []struct {
426 +
	category WeatherCategory
427 +
	keywords []string
428 +
}{
429 +
	{CatStorm, []string{"thunder", "tstorm", "storm"}},
430 +
	{CatSleet, []string{"sleet", "freez", "frzg", "mix"}},
431 +
	{CatSnow, []string{"snow", "flurr"}},
432 +
	{CatRain, []string{"rain", "shower", "drizzle"}},
433 +
	{CatFog, []string{"fog", "mist", "haze"}},
434 +
	{CatPartlyCloudy, []string{"partly", "variable"}},
435 +
	{CatCloudy, []string{"overcast", "cloud"}},
436 +
	{CatClear, []string{"clear", "sunny", "sun", "fair"}},
437 +
}
438 +
439 +
func categorize(conditions string) WeatherCategory {
440 +
	c := strings.ToLower(conditions)
441 +
	for _, rule := range categoryRules {
442 +
		for _, kw := range rule.keywords {
443 +
			if strings.Contains(c, kw) {
444 +
				return rule.category
445 +
			}
446 +
		}
447 +
	}
448 +
	return CatUnknown
449 +
}
450 +
451 +
var weatherIcons = map[WeatherCategory]template.HTML{
452 +
	CatPartlyCloudy: `<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M164 76a71.9 71.9 0 0 0-22.14 3.48A51.8 51.8 0 0 0 129 63.83l11.56-16.51a4 4 0 0 0-6.56-4.59l-11.55 16.51A52 52 0 0 0 96 52c-1.71 0-3.4.09-5.06.25l-3.5-19.85a4 4 0 0 0-7.88 1.39l3.5 19.84A52.2 52.2 0 0 0 55.85 71L39.32 59.42A4 4 0 0 0 34.73 66l16.53 11.54A51.63 51.63 0 0 0 44 104c0 1.69.09 3.37.25 5l-19.85 3.5a4 4 0 0 0 .69 7.94a4 4 0 0 0 .7-.06l19.85-3.5A52.1 52.1 0 0 0 54 134.6A48 48 0 0 0 84 220h80a72 72 0 0 0 0-144M52 104a44 44 0 0 1 82.33-21.61a72.23 72.23 0 0 0-38.82 43A48.3 48.3 0 0 0 84 124a47.76 47.76 0 0 0-23.4 6.11A44 44 0 0 1 52 104m112 108H84a40 40 0 1 1 9.43-78.88A71.6 71.6 0 0 0 92 143.77a4 4 0 0 0 8 .46a64.3 64.3 0 0 1 2-12.67c0-.12.07-.24.09-.36A64.06 64.06 0 1 1 164 212"/></svg>`,
453 +
	CatClear:        `<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M124 40v-8a4 4 0 0 1 8 0v8a4 4 0 0 1-8 0m64 88a60 60 0 1 1-60-60a60.07 60.07 0 0 1 60 60m-8 0a52 52 0 1 0-52 52a52.06 52.06 0 0 0 52-52M61.17 66.83a4 4 0 0 0 5.66-5.66l-8-8a4 4 0 0 0-5.66 5.66Zm0 122.34l-8 8a4 4 0 0 0 5.66 5.66l8-8a4 4 0 0 0-5.66-5.66m136-136l-8 8a4 4 0 0 0 5.66 5.66l8-8a4 4 0 1 0-5.66-5.66m-2.34 136a4 4 0 0 0-5.66 5.66l8 8a4 4 0 0 0 5.66-5.66ZM40 124h-8a4 4 0 0 0 0 8h8a4 4 0 0 0 0-8m88 88a4 4 0 0 0-4 4v8a4 4 0 0 0 8 0v-8a4 4 0 0 0-4-4m96-88h-8a4 4 0 0 0 0 8h8a4 4 0 0 0 0-8"/></svg>`,
454 +
	CatCloudy:       `<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M160 44a84.11 84.11 0 0 0-76.41 49.12A60.7 60.7 0 0 0 72 92a60 60 0 0 0 0 120h88a84 84 0 0 0 0-168m0 160H72a52 52 0 1 1 8.55-103.3A83.7 83.7 0 0 0 76 128a4 4 0 0 0 8 0a76 76 0 1 1 76 76"/></svg>`,
455 +
	CatStorm:        `<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M156 20a72.19 72.19 0 0 0-68.49 49.39A48 48 0 1 0 76 164h44.94l-20.37 33.94A4 4 0 0 0 104 204h32.94l-20.37 33.94a4 4 0 0 0 6.86 4.12l24-40A4 4 0 0 0 144 196h-32.94l19.2-32H156a72 72 0 0 0 0-144m0 136H76a40 40 0 1 1 9.43-78.88A71.6 71.6 0 0 0 84 87.77a4 4 0 0 0 8 .46A64.06 64.06 0 1 1 156 156"/></svg>`,
456 +
	CatRain:         `<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="m155.33 194.22l-32 48a4 4 0 1 1-6.66-4.44l32-48a4 4 0 0 1 6.66 4.44M228 92a72.08 72.08 0 0 1-72 72h-25.86l-30.81 46.22a4 4 0 1 1-6.66-4.44L120.53 164H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`,
457 +
	CatSnow:         `<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M84 196a8 8 0 1 1-8-8a8 8 0 0 1 8 8m32 8a8 8 0 1 0 8 8a8 8 0 0 0-8-8m48-16a8 8 0 1 0 8 8a8 8 0 0 0-8-8m-96 40a8 8 0 1 0 8 8a8 8 0 0 0-8-8m88 0a8 8 0 1 0 8 8a8 8 0 0 0-8-8m72-136a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`,
458 +
	CatSleet:        `<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M84 196a8 8 0 1 1-8-8a8 8 0 0 1 8 8m32 8a8 8 0 1 0 8 8a8 8 0 0 0-8-8m48-16a8 8 0 1 0 8 8a8 8 0 0 0-8-8m-96 40a8 8 0 1 0 8 8a8 8 0 0 0-8-8m88 0a8 8 0 1 0 8 8a8 8 0 0 0-8-8m72-136a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 92m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 156h80a64.07 64.07 0 0 0 64-64"/></svg>`,
459 +
	CatFog:          `<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 256 256"><!-- Icon from Phosphor by Phosphor Icons - https://github.com/phosphor-icons/core/blob/main/LICENSE --><path fill="currentColor" d="M120 204H72a4 4 0 0 1 0-8h48a4 4 0 0 1 0 8m64-8h-24a4 4 0 0 0 0 8h24a4 4 0 0 0 0-8m-24 32h-56a4 4 0 0 0 0 8h56a4 4 0 0 0 0-8m68-128a72.08 72.08 0 0 1-72 72H76a48 48 0 1 1 11.51-94.61A72.08 72.08 0 0 1 228 100m-8 0a64.06 64.06 0 0 0-128-3.77a4 4 0 0 1-8-.46a71.6 71.6 0 0 1 1.42-10.65A40 40 0 1 0 76 164h80a64.07 64.07 0 0 0 64-64"/></svg>`,
460 +
}