chore: mvp of weather metadata feature f12453a3
Steve · 2026-06-06 23:34 10 file(s) · +183 −12
TODO.md +12 −1
1 -
- [ ] Fix input for sipp so it reflects jotts
1 +
- [ ] Add current weather to /now posts
2 +
  - General flow
3 +
    - Creating a new post with attribute `weather` and value `{conditions},{temperature},{city},{state}`
4 +
    - If no value is given, create value using default location with fresh API call
5 +
  - Store as new column in db called `weather`
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 +
  
apps/posts/app.go +2 −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
305 306
	OGImageURL      string
306 307
	CustomHeader    string
307 308
	CustomFooter    string
309 +
	DefaultLocation string
308 310
	Success         bool
309 311
}
310 312
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 +11 −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)
263 272
		OGImageURL:      get("og_image_url"),
264 273
		CustomHeader:    get("custom_header"),
265 274
		CustomFooter:    get("custom_footer"),
275 +
		DefaultLocation: get("default_location"),
266 276
		Success:         r.URL.Query().Get("success") == "true",
267 277
	})
268 278
}
281 291
	_ = setSetting(a.DB, "og_image_url", strings.TrimSpace(r.FormValue("og_image_url")))
282 292
	_ = setSetting(a.DB, "custom_header", r.FormValue("custom_header"))
283 293
	_ = setSetting(a.DB, "custom_footer", r.FormValue("custom_footer"))
294 +
	_ = setSetting(a.DB, "default_location", r.FormValue("default_location"))
284 295
	http.Redirect(w, r, "/admin/settings?success=true", http.StatusSeeOther)
285 296
}
286 297
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/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 +3 −0
31 31
    {{if .Post.PublishedDate}}
32 32
      <time class="post-date">{{.Post.PublishedDateStr}}</time>
33 33
    {{end}}
34 +
    {{if .Post.Weather}}
35 +
      <p class="post-description">{{.Post.Weather}}</p>
36 +
    {{end}}
34 37
    {{if .Post.Tags}}
35 38
      <div class="post-tags">
36 39
        {{range .Post.TagList}}
apps/posts/util.go +62 −0
1 1
package main
2 2
3 3
import (
4 +
	"fmt"
5 +
	"net/http"
4 6
	"strings"
5 7
	"time"
8 +
	"encoding/json"
9 +
	"strconv"
6 10
)
7 11
8 12
type parsedAttributes struct {
15 19
	Lang            string
16 20
	Tags            string
17 21
	Status          string
22 +
	Weather         string
23 +
}
24 +
25 +
type WeatherPointResponse struct {
26 +
	Properties struct {
27 +
		ForecastHourly   string `json:"forecastHourly"`
28 +
		RelativeLocation struct {
29 +
			Properties struct {
30 +
				City  string `json:"city"`
31 +
				State string `json:"state"`
32 +
			} `json:"properties"`
33 +
		} `json:"relativeLocation"`
34 +
	} `json:"properties"`
35 +
}
36 +
37 +
type Period struct {
38 +
    Temperature  int    `json:"temperature"`
39 +
    ShortForecast string `json:"shortForecast"`
40 +
}
41 +
42 +
type WeatherForecastResponse struct {
43 +
    Properties struct {
44 +
        Periods []Period `json:"periods"`
45 +
    } `json:"properties"`
18 46
}
19 47
20 48
func parseAttributes(text string) parsedAttributes {
45 73
			a.Tags = value
46 74
		case "status":
47 75
			a.Status = value
76 +
		case "weather":
77 +
			a.Weather = value
48 78
		}
49 79
	}
50 80
	return a
317 347
	}
318 348
	return strings.ToUpper(cleaned[:1]) + cleaned[1:]
319 349
}
350 +
351 +
func getWeather(location string) string {
352 +
	// Fetch Points data using lat,long
353 +
	pointURL := fmt.Sprintf("https://api.weather.gov/points/%s", location)
354 +
	resp, err := http.Get(pointURL)
355 +
	if err != nil {
356 +
		fmt.Printf("Error fetching pointUrl: %s", err.Error())
357 +
		return ""
358 +
	}
359 +
	defer resp.Body.Close()
360 +
	var weatherPoint WeatherPointResponse
361 +
	json.NewDecoder(resp.Body).Decode(&weatherPoint)
362 +
	forecastURL := weatherPoint.Properties.ForecastHourly
363 +
	city := weatherPoint.Properties.RelativeLocation.Properties.City
364 +
	state := weatherPoint.Properties.RelativeLocation.Properties.State
365 +
366 +
	// Forcast using points data
367 +
	forecastResp, err := http.Get(forecastURL)
368 +
	if err != nil {
369 +
		fmt.Printf("Error fetching forecast: %s", err.Error())
370 +
		return ""
371 +
	}
372 +
	defer resp.Body.Close()
373 +
	var weatherForecast WeatherForecastResponse
374 +
	json.NewDecoder(forecastResp.Body).Decode(&weatherForecast)
375 +
	temp := strconv.Itoa(weatherForecast.Properties.Periods[0].Temperature)
376 +
	conditions := weatherForecast.Properties.Periods[0].ShortForecast
377 +
378 +
	weather := fmt.Sprintf("%s,%s,%s,%s", conditions, temp, city,state)
379 +
	return weather
380 +
}
381 +
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