Merge pull request #53 from stevedylandev/feat/add-weather-to-posts 0da518b6
add weather to posts
Steve Simkins · 2026-06-07 13:08 12 file(s) · +317 −14
TODO.md +0 −1
1 -
- [ ] Fix input for sipp so it reflects jotts
apps/posts/app.go +3 −0
38 38
	MetaImage       *string
39 39
	Lang            string
40 40
	Tags            *string
41 +
	Weather         *string
41 42
	Content         string
42 43
	Status          string
43 44
	CreatedAt       string
251 252
	SiteURL         string
252 253
	HeaderHTML      template.HTML
253 254
	FooterHTML      template.HTML
255 +
	Weather 				Weather
254 256
}
255 257
256 258
type pagePageData struct {
305 307
	OGImageURL      string
306 308
	CustomHeader    string
307 309
	CustomFooter    string
310 +
	DefaultLocation string
308 311
	Success         bool
309 312
}
310 313
apps/posts/db.go +41 −8
5 5
	"errors"
6 6
	"strings"
7 7
	"time"
8 +
	"fmt"
8 9
9 10
	"github.com/stevedylandev/andromeda/pkg/auth"
10 11
)
22 23
    meta_image      TEXT,
23 24
    lang            TEXT NOT NULL DEFAULT 'en',
24 25
    tags            TEXT,
26 +
		weather					TEXT,
25 27
    content         TEXT NOT NULL,
26 28
    status          TEXT NOT NULL DEFAULT 'draft',
27 29
    created_at      TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
69 71
	{"custom_footer", `<div>
70 72
<a href="/feed.xml" class="rss-link" title="RSS Feed"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path fill="currentColor" d="M104.08 151.92A67.52 67.52 0 0 1 124 200a4 4 0 0 1-8 0a60 60 0 0 0-60-60a4 4 0 0 1 0-8a67.52 67.52 0 0 1 48.08 19.92M56 84a4 4 0 0 0 0 8a108 108 0 0 1 108 108a4 4 0 0 0 8 0A116 116 0 0 0 56 84m116 0A162.92 162.92 0 0 0 56 36a4 4 0 0 0 0 8a155 155 0 0 1 110.31 45.69A155 155 0 0 1 212 200a4 4 0 0 0 8 0a162.92 162.92 0 0 0-48-116M60 188a8 8 0 1 0 8 8a8 8 0 0 0-8-8"/></svg></a>
71 73
</div>`},
74 +
	{"default_location", ""},
72 75
}
73 76
74 77
func seedDefaultSettings(db *sql.DB) {
77 80
	}
78 81
}
79 82
80 -
const postCols = `id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at`
83 +
const postCols = `id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, weather, content, status, created_at, updated_at`
81 84
82 85
func scanPost(s interface{ Scan(...any) error }) (*Post, error) {
83 86
	var p Post
84 -
	var title, alias, canonicalURL, publishedDate, metaDesc, metaImage, tags sql.NullString
87 +
	var title, alias, canonicalURL, publishedDate, metaDesc, metaImage, tags, weather sql.NullString
85 88
	err := s.Scan(&p.ID, &p.ShortID, &title, &p.Slug, &alias, &canonicalURL,
86 -
		&publishedDate, &metaDesc, &metaImage, &p.Lang, &tags, &p.Content,
89 +
		&publishedDate, &metaDesc, &metaImage, &p.Lang, &tags, &weather, &p.Content,
87 90
		&p.Status, &p.CreatedAt, &p.UpdatedAt)
88 91
	if errors.Is(err, sql.ErrNoRows) {
89 92
		return nil, nil
119 122
		v := tags.String
120 123
		p.Tags = &v
121 124
	}
125 +
	if weather.Valid {
126 +
		v := weather.String
127 +
		p.Weather = &v
128 +
	}
122 129
	return &p, nil
123 130
}
124 131
134 141
	MetaImage       *string
135 142
	Lang            string
136 143
	Tags            *string
144 +
	Weather         *string
137 145
}
138 146
139 147
func nullable(p *string) any {
150 158
	}
151 159
	in.PublishedDate = normalizePubDatePtr(in.PublishedDate)
152 160
	res, err := db.Exec(
153 -
		`INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags)
154 -
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
161 +
		`INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, weather)
162 +
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
155 163
		shortID, nullable(in.Title), in.Slug, in.Content, in.Status,
156 164
		nullable(in.Alias), nullable(in.CanonicalURL), nullable(in.PublishedDate),
157 -
		nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags),
165 +
		nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), nullable(in.Weather),
158 166
	)
159 167
	if err != nil {
160 168
		return nil, err
214 222
	res, err := db.Exec(
215 223
		`UPDATE posts SET title = ?, slug = ?, content = ?, status = ?, alias = ?, canonical_url = ?,
216 224
		 published_date = CASE WHEN ? = 'published' THEN COALESCE(?, published_date, strftime('%Y-%m-%dT%H:%M:%SZ','now')) ELSE ? END,
217 -
		 meta_description = ?, meta_image = ?, lang = ?, tags = ?,
225 +
		 meta_description = ?, meta_image = ?, lang = ?, tags = ?, weather = ?,
218 226
		 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE short_id = ?`,
219 227
		nullable(in.Title), in.Slug, in.Content, in.Status, nullable(in.Alias), nullable(in.CanonicalURL),
220 228
		in.Status, nullable(in.PublishedDate), nullable(in.PublishedDate),
221 -
		nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), shortID,
229 +
		nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), nullable(in.Weather), shortID,
222 230
	)
223 231
	if err != nil {
224 232
		return nil, err
471 479
	return nil
472 480
}
473 481
482 +
// migrateWeather updates tables to have new optional weather column
483 +
func migrateWeather(db *sql.DB) error {
484 +
	var count int
485 +
	if err := db.QueryRow(
486 +
		`SELECT COUNT(*) FROM pragma_table_info('posts') WHERE name = 'weather'`,
487 +
	).Scan(&count); err != nil {
488 +
		return err
489 +
	}
490 +
	if count == 0 {
491 +
		_, err := db.Exec(`ALTER TABLE posts ADD COLUMN weather TEXT`)
492 +
		return err
493 +
	}
494 +
	return nil
495 +
}
496 +
474 497
func normalizePubDatePtr(p *string) *string {
475 498
	if p == nil {
476 499
		return nil
479 502
		return &v
480 503
	}
481 504
	return p
505 +
}
506 +
507 +
func runMigrations(db *sql.DB) error {
508 +
    if err := migrateTimestamps(db); err != nil {
509 +
        return fmt.Errorf("migrate timestamps: %w", err)
510 +
    }
511 +
    if err := migrateWeather(db); err != nil {
512 +
        return fmt.Errorf("add weather column: %w", err)
513 +
    }
514 +
    return nil
482 515
}
483 516
484 517
func _useStrings() {
apps/posts/handlers_admin.go +20 −0
77 77
	if l := strings.TrimSpace(attrs.Lang); l != "" {
78 78
		lang = l
79 79
	}
80 +
	defaultLocation, err := getSetting(a.DB, "default_location")
81 +
	if err != nil {
82 +
		defaultLocation = ""
83 +
	}
84 +
	weather := getWeather(defaultLocation)
85 +
	if newWeather := strings.TrimSpace(attrs.Weather); newWeather != "" {
86 +
		weather = newWeather
87 +
	}
80 88
	pub := strings.TrimSpace(attrs.PublishedDate)
81 89
	if pub == "" {
82 90
		pub = nowDatetime()
88 96
		MetaDescription: optStr(attrs.MetaDescription),
89 97
		MetaImage:       optStr(attrs.MetaImage),
90 98
		Lang:            lang, Tags: optStr(attrs.Tags),
99 +
		Weather: 				 optStr(weather),
91 100
	}
92 101
	if _, err := createPost(a.DB, in); err != nil {
93 102
		a.Log.Error("create post", "err", err)
143 152
	if t := strings.TrimSpace(attrs.PublishedDate); t != "" {
144 153
		pubDate = &t
145 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 +
	}
146 163
	in := PostInput{
147 164
		Title: optStr(title), Slug: slug, Content: r.FormValue("content"),
148 165
		Status: status, Alias: optStr(attrs.Alias),
150 167
		MetaDescription: optStr(attrs.MetaDescription),
151 168
		MetaImage:       optStr(attrs.MetaImage),
152 169
		Lang:            lang, Tags: optStr(attrs.Tags),
170 +
		Weather:				 optStr(weather),
153 171
	}
154 172
	if _, err := updatePost(a.DB, shortID, in); err != nil {
155 173
		a.Log.Error("update post", "err", err)
263 281
		OGImageURL:      get("og_image_url"),
264 282
		CustomHeader:    get("custom_header"),
265 283
		CustomFooter:    get("custom_footer"),
284 +
		DefaultLocation: get("default_location"),
266 285
		Success:         r.URL.Query().Get("success") == "true",
267 286
	})
268 287
}
281 300
	_ = setSetting(a.DB, "og_image_url", strings.TrimSpace(r.FormValue("og_image_url")))
282 301
	_ = setSetting(a.DB, "custom_header", r.FormValue("custom_header"))
283 302
	_ = setSetting(a.DB, "custom_footer", r.FormValue("custom_footer"))
303 +
	_ = setSetting(a.DB, "default_location", r.FormValue("default_location"))
284 304
	http.Redirect(w, r, "/admin/settings?success=true", http.StatusSeeOther)
285 305
}
286 306
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/main.go +2 −2
23 23
		log.Fatal(err)
24 24
	}
25 25
	defer db.Close()
26 -
	if err := migrateTimestamps(db); err != nil {
27 -
		log.Fatalf("migrate timestamps: %v", err)
26 +
	if err := runMigrations(db); err != nil {
27 +
		log.Fatalf("migrations: %v", err)
28 28
	}
29 29
	seedDefaultSettings(db)
30 30
apps/posts/static/styles.css +19 −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 +
  flex-wrap: wrap;
100 +
  gap: 0.75rem;
101 +
  font-size: 12px;
102 +
  opacity: 0.5;
103 +
}
104 +
105 +
.post-weather svg {
106 +
  height: 16px;
107 +
}
108 +
109 +
.weather-location {
110 +
  display: flex;
111 +
  align-items: center;
112 +
  gap: 0.75rem;
95 113
}
96 114
97 115
/* Post header (public single) */
apps/posts/templates/admin_post_form.html +4 −1
13 13
{{end}}{{if $p.Tags}}tags: {{$p.TagsStr}}
14 14
{{end}}{{if $p.Alias}}alias: {{$p.AliasStr}}
15 15
{{end}}{{if $p.MetaImage}}meta_image: {{$p.MetaImageStr}}
16 -
{{end}}{{if $p.MetaDescription}}description: {{$p.MetaDescriptionStr}}{{end}}</textarea>
16 +
{{end}}{{if $p.MetaDescription}}description: {{$p.MetaDescriptionStr}}
17 +
{{end}}{{if $p.Weather}}weather: {{$p.Weather}}{{end}}</textarea>
17 18
      <details class="available-fields">
18 19
        <summary>available fields</summary>
19 20
        <div class="fields-list">
25 26
          <span>alias: /old/path</span>
26 27
          <span>meta_image: https://example.com/image.jpg</span>
27 28
          <span>description: A short summary of the post</span>
29 +
          <span>weather: Conditions,Degrees,City,State<span>
28 30
        </div>
29 31
      </details>
30 32
      <label for="content">content</label>
53 55
          <span>alias: /old/path</span>
54 56
          <span>meta_image: https://example.com/image.jpg</span>
55 57
          <span>description: A short summary of the post</span>
58 +
          <span>weather: Conditions,Degrees,City,State<span>
56 59
        </div>
57 60
      </details>
58 61
      <label for="content">content</label>
apps/posts/templates/admin_settings.html +2 −0
14 14
    <input type="text" id="favicon_url" name="favicon_url" value="{{.FaviconURL}}" placeholder="https://example.com/favicon.png">
15 15
    <label for="og_image_url">default OG image URL (used when posts don't have their own)</label>
16 16
    <input type="text" id="og_image_url" name="og_image_url" value="{{.OGImageURL}}" placeholder="https://example.com/og.png">
17 +
    <label for="default_location">default location for weather metadata</label>
18 +
    <input type="text" id="default_location" name="default_location" value="{{.DefaultLocation}}" placeholder="lat,long">
17 19
    <label for="intro_content">intro content (markdown, shown on homepage — use &#123;&#123;latest_posts&#125;&#125; to embed recent posts)</label>
18 20
    <textarea id="intro_content" name="intro_content" class="post-content">{{.IntroContent}}</textarea>
19 21
    <div class="switch-row">
apps/posts/templates/post.html +15 −0
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}}
38 +
    {{if .Post.Weather}}
39 +
      {{.Weather.Icon}}
40 +
      <p class="weather-conditions">{{.Weather.Conditions}}</p>
41 +
      <p class="weather-temp">{{.Weather.Temperature}}</p>
42 +
43 +
      <div class="weather-location">
44 +
      <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>
45 +
      <p>{{.Weather.Location}}</p>
46 +
      </div>
47 +
    {{end}}
48 +
    </div>
34 49
    {{if .Post.Tags}}
35 50
      <div class="post-tags">
36 51
        {{range .Post.TagList}}
apps/posts/util.go +156 −0
1 1
package main
2 2
3 3
import (
4 +
	"encoding/json"
5 +
	"fmt"
6 +
	"html/template"
7 +
	"net/http"
8 +
	"regexp"
9 +
	"strconv"
4 10
	"strings"
5 11
	"time"
6 12
)
15 21
	Lang            string
16 22
	Tags            string
17 23
	Status          string
24 +
	Weather         string
25 +
}
26 +
27 +
type WeatherPointResponse struct {
28 +
	Properties struct {
29 +
		ForecastHourly   string `json:"forecastHourly"`
30 +
		RelativeLocation struct {
31 +
			Properties struct {
32 +
				City  string `json:"city"`
33 +
				State string `json:"state"`
34 +
			} `json:"properties"`
35 +
		} `json:"relativeLocation"`
36 +
	} `json:"properties"`
37 +
}
38 +
39 +
type Period struct {
40 +
	Temperature   int    `json:"temperature"`
41 +
	ShortForecast string `json:"shortForecast"`
42 +
}
43 +
44 +
type WeatherForecastResponse struct {
45 +
	Properties struct {
46 +
		Periods []Period `json:"periods"`
47 +
	} `json:"properties"`
48 +
}
49 +
50 +
type Weather struct {
51 +
	Icon        template.HTML
52 +
	Conditions  string
53 +
	Temperature string
54 +
	Location    string
18 55
}
19 56
20 57
func parseAttributes(text string) parsedAttributes {
45 82
			a.Tags = value
46 83
		case "status":
47 84
			a.Status = value
85 +
		case "weather":
86 +
			a.Weather = value
48 87
		}
49 88
	}
50 89
	return a
317 356
	}
318 357
	return strings.ToUpper(cleaned[:1]) + cleaned[1:]
319 358
}
359 +
360 +
var weatherClient = &http.Client{Timeout: 10 * time.Second}
361 +
362 +
var qualifierRE = regexp.MustCompile(`(?i)^(slight chance|chance|isolated|scattered|patchy|areas)\s+(of\s+)?`)
363 +
364 +
func getWeather(location string) string {
365 +
	if location == "" {
366 +
		return ""
367 +
	}
368 +
	// Fetch Points data using lat,long
369 +
	pointURL := fmt.Sprintf("https://api.weather.gov/points/%s", location)
370 +
	resp, err := weatherClient.Get(pointURL)
371 +
	if err != nil {
372 +
		fmt.Printf("Error fetching pointUrl: %s", err.Error())
373 +
		return ""
374 +
	}
375 +
	defer resp.Body.Close()
376 +
	var weatherPoint WeatherPointResponse
377 +
	if err := json.NewDecoder(resp.Body).Decode(&weatherPoint); err != nil {
378 +
		fmt.Printf("Error decoding pointUrl: %s", err.Error())
379 +
		return ""
380 +
	}
381 +
	forecastURL := weatherPoint.Properties.ForecastHourly
382 +
	city := weatherPoint.Properties.RelativeLocation.Properties.City
383 +
	state := weatherPoint.Properties.RelativeLocation.Properties.State
384 +
385 +
	// Forcast using points data
386 +
	forecastResp, err := weatherClient.Get(forecastURL)
387 +
	if err != nil {
388 +
		fmt.Printf("Error fetching forecast: %s", err.Error())
389 +
		return ""
390 +
	}
391 +
	defer forecastResp.Body.Close()
392 +
	var weatherForecast WeatherForecastResponse
393 +
	if err := json.NewDecoder(forecastResp.Body).Decode(&weatherForecast); err != nil {
394 +
		fmt.Printf("Error decoding forecast: %s", err.Error())
395 +
		return ""
396 +
	}
397 +
	if len(weatherForecast.Properties.Periods) == 0 {
398 +
		fmt.Printf("Error: no forecast periods returned for %q", location)
399 +
		return ""
400 +
	}
401 +
	period := weatherForecast.Properties.Periods[0]
402 +
	temp := strconv.Itoa(period.Temperature)
403 +
	formattedConditions := strings.TrimSpace(qualifierRE.ReplaceAllString(period.ShortForecast, ""))
404 +
	weather := fmt.Sprintf("%s,%s,%s,%s", formattedConditions, temp, city, state)
405 +
	return weather
406 +
}
407 +
408 +
func formatWeather(weather string) (Weather, error) {
409 +
	parts := strings.Split(weather, ",")
410 +
	if len(parts) < 4 {
411 +
		return Weather{}, fmt.Errorf("unexpected weather format: %q", weather)
412 +
	}
413 +
	for i := range parts {
414 +
		parts[i] = strings.TrimSpace(parts[i]) // handles ", " vs ","
415 +
	}
416 +
417 +
	conditions := parts[0]
418 +
	return Weather{
419 +
		Conditions:  conditions,
420 +
		Temperature: fmt.Sprintf("%s°F", parts[1]),
421 +
		Location:    fmt.Sprintf("%s, %s", parts[2], parts[3]),
422 +
		Icon:        weatherIcons[categorize(conditions)],
423 +
	}, nil
424 +
}
425 +
426 +
type WeatherCategory int
427 +
428 +
const (
429 +
	CatStorm WeatherCategory = iota
430 +
	CatSleet
431 +
	CatSnow
432 +
	CatRain
433 +
	CatFog
434 +
	CatPartlyCloudy
435 +
	CatCloudy
436 +
	CatClear
437 +
	CatUnknown
438 +
)
439 +
440 +
var categoryRules = []struct {
441 +
	category WeatherCategory
442 +
	keywords []string
443 +
}{
444 +
	{CatStorm, []string{"thunder", "tstorm", "storm"}},
445 +
	{CatSleet, []string{"sleet", "freez", "frzg", "mix"}},
446 +
	{CatSnow, []string{"snow", "flurr"}},
447 +
	{CatRain, []string{"rain", "shower", "drizzle"}},
448 +
	{CatFog, []string{"fog", "mist", "haze"}},
449 +
	{CatPartlyCloudy, []string{"partly", "variable"}},
450 +
	{CatCloudy, []string{"overcast", "cloud"}},
451 +
	{CatClear, []string{"clear", "sunny", "sun", "fair"}},
452 +
}
453 +
454 +
func categorize(conditions string) WeatherCategory {
455 +
	c := strings.ToLower(conditions)
456 +
	for _, rule := range categoryRules {
457 +
		for _, kw := range rule.keywords {
458 +
			if strings.Contains(c, kw) {
459 +
				return rule.category
460 +
			}
461 +
		}
462 +
	}
463 +
	return CatUnknown
464 +
}
465 +
466 +
var weatherIcons = map[WeatherCategory]template.HTML{
467 +
	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>`,
468 +
	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>`,
469 +
	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>`,
470 +
	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>`,
471 +
	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>`,
472 +
	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>`,
473 +
	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>`,
474 +
	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>`,
475 +
}
conditions.txt (added) +44 −0
1 +
Severe storm
2 +
Showers storms
3 +
Showers storms
4 +
Thunder storm
5 +
Rain Sleet
6 +
FrzgRn Snow
7 +
FrzgRn Snow
8 +
Chance Snow/Rain
9 +
Chance Snow/Rain
10 +
Rain and Snow
11 +
Rain or Snow
12 +
Freezing Rain
13 +
Rain likely
14 +
Snow showers
15 +
Showers likely
16 +
Chance showers
17 +
Isolated showers
18 +
Scattered showers
19 +
Chance rain
20 +
Rain
21 +
Mix
22 +
Sleet
23 +
Snow
24 +
Fog a.m.
25 +
Fog late
26 +
Fog
27 +
Very Cold
28 +
Very Hot
29 +
Hot
30 +
Overcast
31 +
Mostly Cloudy
32 +
Partly Cloudy
33 +
Cloudy
34 +
Partly Sunny
35 +
Mostly Sunny
36 +
Partly Sunny
37 +
Mostly Sunny
38 +
Partly Sunny
39 +
Mostly Sunny
40 +
Mostly Clear
41 +
Sunny
42 +
Clear
43 +
Fair
44 +
Variable Clouds