2 files changed,
92 insertions(+),
16 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-05-08 20:32:52 +0300
Authored at:
2026-05-08 20:31:34 +0300
Change ID:
tklwwrsooxmoqkwmuzpmnwsmpzlpwwlk
Parent:
50b546d
M
sources/weather/weather.go
··· 65 65 return 66 66 } 67 67 68 - content, updated, err := buildMorningBriefing(forecast, air) 68 + content, updated, err := buildWeatherBriefing(forecast, air) 69 69 if err != nil { 70 70 http.Error(rw, "failed to build weather briefing", http.StatusBadGateway) 71 71 return ··· 81 81 WithUpdated(updated) 82 82 83 83 feed.Add(app.FeedEntry{ 84 - Title: "Morning weather briefing", 84 + Title: "Weather briefing", 85 85 ID: fmt.Sprintf("%s-%s", feedID, updated.Format("20060102")), 86 - Content: formatBriefingHTML(content), 87 - ContentType: "html", 86 + Content: formatBriefingXHTML(content), 87 + ContentType: "xhtml", 88 88 Updated: updated, 89 89 }) 90 90 ··· 322 322 return strings.TrimSpace(part) 323 323 } 324 324 325 -func formatBriefingHTML(content string) string { 326 - return "<pre>" + html.EscapeString(content) + "</pre>" 325 +func formatBriefingXHTML(content string) string { 326 + blocks := strings.Split(content, "\n\n") 327 + overview := []string{} 328 + timeline := []string{} 329 + meta := []string{} 330 + 331 + if len(blocks) > 0 { 332 + overview = nonEmptyLines(blocks[0]) 333 + } 334 + if len(blocks) > 1 { 335 + timeline = nonEmptyLines(blocks[1]) 336 + } 337 + if len(blocks) > 2 { 338 + meta = nonEmptyLines(strings.Join(blocks[2:], "\n\n")) 339 + } 340 + 341 + var b strings.Builder 342 + b.WriteString("<article>") 343 + 344 + if len(overview) > 0 { 345 + b.WriteString("<section><h2>Overview</h2>") 346 + b.WriteString("<p>" + html.EscapeString(overview[0]) + "</p>") 347 + if len(overview) > 1 { 348 + b.WriteString("<ul>") 349 + for _, line := range overview[1:] { 350 + b.WriteString("<li>" + html.EscapeString(line) + "</li>") 351 + } 352 + b.WriteString("</ul>") 353 + } 354 + b.WriteString("</section>") 355 + } 356 + 357 + if len(timeline) > 0 { 358 + b.WriteString("<section><h2>Timeline</h2><ul>") 359 + for _, line := range timeline { 360 + timestamp, rest, ok := strings.Cut(strings.TrimSpace(line), " ") 361 + if ok { 362 + b.WriteString("<li><strong>" + html.EscapeString(strings.TrimSpace(timestamp)) + "</strong> " + html.EscapeString(strings.TrimSpace(rest)) + "</li>") 363 + continue 364 + } 365 + b.WriteString("<li>" + html.EscapeString(line) + "</li>") 366 + } 367 + b.WriteString("</ul></section>") 368 + } 369 + 370 + if len(meta) > 0 { 371 + b.WriteString("<section><h2>Source</h2>") 372 + for _, line := range meta { 373 + b.WriteString("<p>" + html.EscapeString(line) + "</p>") 374 + } 375 + b.WriteString("</section>") 376 + } 377 + 378 + b.WriteString("</article>") 379 + return b.String() 327 380 } 328 381 329 -func buildMorningBriefing(forecast forecastResponse, air airQualityResponse) (string, time.Time, error) { 382 +func nonEmptyLines(block string) []string { 383 + lines := strings.Split(block, "\n") 384 + out := make([]string, 0, len(lines)) 385 + for _, line := range lines { 386 + line = strings.TrimSpace(line) 387 + if line == "" { 388 + continue 389 + } 390 + out = append(out, line) 391 + } 392 + return out 393 +} 394 + 395 +func buildWeatherBriefing(forecast forecastResponse, air airQualityResponse) (string, time.Time, error) { 330 396 loc := time.Local 331 397 if tz := strings.TrimSpace(forecast.Timezone); tz != "" { 332 398 zone, err := time.LoadLocation(tz)
M
sources/weather/weather_test.go
··· 67 67 } 68 68 }` 69 69 70 -func TestWeatherHandlerRendersMorningBriefing(t *testing.T) { 70 +func TestWeatherHandlerRendersWeatherBriefing(t *testing.T) { 71 71 forecastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 72 72 w.Header().Set("Content-Type", "application/json") 73 73 _, _ = w.Write([]byte(forecastFixture)) ··· 105 105 t.Fatalf("expected atom feed, got %q", got) 106 106 } 107 107 108 + raw := rr.Body.String() 108 109 var feed app.AtomFeed 109 - is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) 110 + is.Err(t, xml.NewDecoder(strings.NewReader(raw)).Decode(&feed), nil) 110 111 is.Equal(t, len(feed.Entries), 1) 111 112 is.Equal(t, feed.Title, "Weather forecast for Kyiv") 112 - is.Equal(t, feed.Entries[0].Content.Type, "html") 113 - is.Equal(t, strings.HasPrefix(feed.Entries[0].Content.Value, "<pre>"), true) 113 + is.Equal(t, feed.Entries[0].Title, "Weather briefing") 114 + is.Equal(t, feed.Entries[0].Content.Type, "xhtml") 114 115 115 - content := feed.Entries[0].Content.Value 116 + content := raw 117 + if !strings.Contains(content, `<content type="xhtml">`) { 118 + t.Fatalf("missing xhtml content wrapper in response:\n%s", content) 119 + } 120 + if !strings.Contains(content, "<article>") || !strings.Contains(content, "<h2>Overview</h2>") { 121 + t.Fatalf("missing overview structure in content:\n%s", content) 122 + } 123 + if !strings.Contains(content, "<h2>Timeline</h2>") { 124 + t.Fatalf("missing timeline structure in content:\n%s", content) 125 + } 116 126 if !strings.Contains(content, "+13° / +22° | Cloudy with showers in the afternoon") { 117 127 t.Fatalf("missing summary line in content:\n%s", content) 118 128 } ··· 132 142 t.Fatalf("missing air quality line in content:\n%s", content) 133 143 } 134 144 for _, line := range []string{ 135 - "08:00 +14° ☁ Cloudy", 136 - "12:00 +18° 🌥 Partly cloudy", 137 - "16:00 +21° 🌧 Rain showers", 138 - "20:00 +16° 🌥 Partly cloudy", 145 + "<strong>08:00</strong> +14° ☁ Cloudy", 146 + "<strong>12:00</strong> +18° 🌥 Partly cloudy", 147 + "<strong>16:00</strong> +21° 🌧 Rain showers", 148 + "<strong>20:00</strong> +16° 🌥 Partly cloudy", 139 149 } { 140 150 if !strings.Contains(content, line) { 141 151 t.Fatalf("missing timeline line %q in content:\n%s", line, content)