Add a weather forecast

Requested by customer. Needs a command run in a cron to update the
forecast data from an URL, and only a single URL is supported by now.
This commit is contained in:
jordi fita mas 2025-01-28 21:37:30 +01:00
parent 7c6bac1986
commit d64e899e0f
11 changed files with 533 additions and 8 deletions

View File

@ -0,0 +1,89 @@
package main
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/database"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"time"
)
type Temperature struct {
Day float64 `json:"day"`
Min float64 `json:"min"`
}
type Weather struct {
ID int `json:"id"`
}
type Forecast struct {
Timestamp int64 `json:"dt"`
Weather []Weather `json:"weather"`
Temperature Temperature `json:"temp"`
}
type OpenWeatherMap struct {
Forecasts []Forecast `json:"list"`
}
func main() {
db, err := database.New(context.Background(), os.Getenv("CAMPER_DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
conn, err := db.Acquire(context.Background())
if err != nil {
log.Fatal(err)
}
defer conn.Release()
var stationURL string
if err := conn.QueryRow(context.Background(), `
select station_uri
from weather_forecast
`).Scan(
&stationURL,
); err != nil {
log.Fatal(err)
}
resp, err := http.Get(stationURL)
if err != nil {
log.Fatal(err)
}
var result OpenWeatherMap
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.Fatal(err)
}
if _, err := conn.Exec(context.Background(), "set role to admin"); err != nil {
log.Fatal(err)
}
for _, forecast := range result.Forecasts {
if _, err := db.Exec(context.Background(), `
update weather_forecast
set weather_condition_id = $1
, day_temperature = $2
, min_temperature = $3
, forecasted_at = $4
, updated_at = current_timestamp
where station_uri = $5
`,
strconv.Itoa(forecast.Weather[0].ID),
forecast.Temperature.Day,
forecast.Temperature.Min,
time.Unix(forecast.Timestamp, 0),
stationURL,
); err != nil {
log.Fatal(err)
}
}
}

View File

@ -0,0 +1,23 @@
-- Deploy camper:weather_forecast to pg
-- requires: schema_camper
-- requires: roles
-- requires: extension_uri
begin;
set search_path to camper, public;
create table weather_forecast (
station_uri uri primary key,
weather_condition_id text not null,
day_temperature numeric(5,2) not null,
min_temperature numeric(5,2) not null,
forecasted_at timestamp with time zone not null,
updated_at timestamp with time zone not null
);
grant select on table weather_forecast to guest;
grant select on table weather_forecast to employee;
grant select, insert, update, delete on table weather_forecast to admin;
commit;

View File

@ -11,6 +11,7 @@ import (
gotemplate "html/template"
"net/http"
"sort"
"time"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
@ -22,12 +23,14 @@ type PublicPage struct {
LocalizedAlternates []*LocalizedAlternate
Menu *siteMenu
CompanyAddress *address
WeatherForecast *WeatherForecast
OpeningDates gotemplate.HTML
}
func NewPublicPage() *PublicPage {
return &PublicPage{
CompanyAddress: &address{},
CompanyAddress: &address{},
WeatherForecast: &WeatherForecast{},
}
}
@ -70,6 +73,10 @@ func (p *PublicPage) Setup(r *http.Request, user *auth.User, company *auth.Compa
if err := p.CompanyAddress.FillFromDatabase(r.Context(), conn, user, company); err != nil {
panic(err)
}
if err := p.WeatherForecast.FillFromDatabase(r.Context(), conn); err != nil {
panic(err)
}
}
type LocalizedAlternate struct {
@ -152,3 +159,27 @@ where company_id = $1
&addr.RTCNumber,
)
}
type WeatherForecast struct {
WeatherConditionId string
DayTemperature string
MinTemperature string
ForecastedAt time.Time
}
func (fc *WeatherForecast) FillFromDatabase(ctx context.Context, conn *database.Conn) error {
row := conn.QueryRow(ctx, `
select weather_condition_id
, ceil(day_temperature) || '°'
, ceil(min_temperature) || '° C'
, forecasted_at
from weather_forecast
limit 1
`)
return row.Scan(
&fc.WeatherConditionId,
&fc.DayTemperature,
&fc.MinTemperature,
&fc.ForecastedAt,
)
}

View File

@ -6,6 +6,7 @@
package template
import (
"dev.tandem.ws/tandem/camper/pkg/locale"
"fmt"
"html/template"
"io"
@ -28,6 +29,16 @@ import (
httplib "dev.tandem.ws/tandem/camper/pkg/http"
)
var LongDayNames = []string{
locale.PgettextNoop("Sunday", "day"),
locale.PgettextNoop("Monday", "day"),
locale.PgettextNoop("Tuesday", "day"),
locale.PgettextNoop("Wednesday", "day"),
locale.PgettextNoop("Thursday", "day"),
locale.PgettextNoop("Friday", "day"),
locale.PgettextNoop("Saturday", "day"),
}
func adminTemplateFile(name string) string {
return "web/templates/admin/" + name
}
@ -109,6 +120,9 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ
"formatPrice": func(price string) string {
return FormatPrice(price, user.Locale.Language, user.Locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol)
},
"dayOfWeek": func(time time.Time) template.HTML {
return template.HTML(`<time datetime="` + time.Format(database.ISODateFormat) + `">` + LongDayNames[time.Weekday()] + "</time>")
},
"formatDate": func(time time.Time) template.HTML {
return template.HTML(`<time datetime="` + time.Format(database.ISODateFormat) + `">` + time.Format("02/01/2006") + "</time>")
},

View File

@ -0,0 +1,7 @@
-- Revert camper:weather_forecast from pg
begin;
drop table if exists camper.weather_forecast;
commit;

View File

@ -334,3 +334,4 @@ marshal_payment [roles schema_camper payment payment_customer payment_option pay
unmarshal_booking [roles schema_camper booking booking_option extension_pg_libphonenumber] 2024-04-29T17:20:38Z jordi fita mas <jordi@tandem.blog> # Add function to unmarshal a booking
cancel_booking [roles schema_camper booking booking_campsite] 2024-05-03T14:27:31Z jordi fita mas <jordi@tandem.blog> # Add function to cancel a booking
campsite_type__operating_dates [campsite_type] 2024-07-15T21:27:19Z jordi fita mas <jordi@tandem.blog> # Add operating_dates field to campsite_type
weather_forecast [schema_camper roles extension_uri] 2025-01-28T18:57:33Z jordi fita mas <jordi@tandem.blog> # Add table to keep weather forecast

55
test/weather_forecast.sql Normal file
View File

@ -0,0 +1,55 @@
-- Test weather_forecast
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(31);
set search_path to camper, public;
select has_table('weather_forecast');
select has_pk('weather_forecast');
select table_privs_are('weather_forecast', 'guest', array['SELECT']);
select table_privs_are('weather_forecast', 'employee', array['SELECT']);
select table_privs_are('weather_forecast', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('weather_forecast', 'authenticator', array[]::text[]);
select has_column('weather_forecast', 'station_uri');
select col_is_pk('weather_forecast', 'station_uri');
select col_type_is('weather_forecast', 'station_uri', 'uri');
select col_not_null('weather_forecast', 'station_uri');
select col_hasnt_default('weather_forecast', 'station_uri');
select has_column('weather_forecast', 'forecasted_at');
select col_type_is('weather_forecast', 'forecasted_at', 'timestamp with time zone');
select col_not_null('weather_forecast', 'forecasted_at');
select col_hasnt_default('weather_forecast', 'forecasted_at');
select has_column('weather_forecast', 'weather_condition_id');
select col_type_is('weather_forecast', 'weather_condition_id', 'text');
select col_not_null('weather_forecast', 'weather_condition_id');
select col_hasnt_default('weather_forecast', 'weather_condition_id');
select has_column('weather_forecast', 'day_temperature');
select col_type_is('weather_forecast', 'day_temperature', 'numeric(5,2)');
select col_not_null('weather_forecast', 'day_temperature');
select col_hasnt_default('weather_forecast', 'day_temperature');
select has_column('weather_forecast', 'min_temperature');
select col_type_is('weather_forecast', 'min_temperature', 'numeric(5,2)');
select col_not_null('weather_forecast', 'min_temperature');
select col_hasnt_default('weather_forecast', 'min_temperature');
select has_column('weather_forecast', 'updated_at');
select col_type_is('weather_forecast', 'updated_at', 'timestamp with time zone');
select col_not_null('weather_forecast', 'updated_at');
select col_hasnt_default('weather_forecast', 'updated_at');
select *
from finish();
rollback;

View File

@ -0,0 +1,14 @@
-- Verify camper:weather_forecast on pg
begin;
select station_uri
, weather_condition_id
, day_temperature
, min_temperature
, forecasted_at
, updated_at
from camper.weather_forecast
where false;
rollback;

Binary file not shown.

View File

@ -83,6 +83,14 @@
font-display: swap;
}
@font-face {
font-family: 'weathericons';
src: url('fonts/weathericons.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
*, *::before, *::after {
box-sizing: inherit;
}
@ -198,7 +206,7 @@ body > header {
z-index: 9999;
padding: 1rem 2.5rem;
display: grid;
grid-template-columns: auto 1fr;
grid-template-columns: auto 1fr auto;
align-items: center;
justify-content: space-between;
}
@ -287,6 +295,10 @@ h1 a .name {
left: -9999em;
}
body > header nav:last-of-type {
grid-column: span 2;
}
nav ul, .campsite_type_features ul, .campsite_features ul, .outside_activities > ul {
list-style: none;
padding-left: 0;
@ -435,10 +447,6 @@ nav:last-of-type > ul > li:last-child {
nav:first-of-type {
display: none;
}
body > header > address {
grid-column: 1 / span 2;
}
}
#content {
@ -1872,6 +1880,282 @@ dt {
text-decoration: var(--accent) wavy underline;
}
.weather-forecast {
display: inline-grid;
column-gap: 2ex;
row-gap: 0;
grid-template-columns: auto auto;
justify-content: end;
align-items: center;
}
.weather-forecast span {
font-size: .95em;
}
.weather-forecast time {
font-size: .6em;
text-transform: uppercase;
}
.weather-forecast::before {
display: inline-block;
font-family: "weathericons" !important;
font-size: 1.6em;
font-style: normal;
font-weight: normal;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: '';
grid-row: 1 / 3;
}
.weather-forecast.condition-200:before {
content: "";
}
.weather-forecast.condition-201:before {
content: "";
}
.weather-forecast.condition-202:before {
content: "";
}
.weather-forecast.condition-210:before {
content: "";
}
.weather-forecast.condition-211:before {
content: "";
}
.weather-forecast.condition-212:before {
content: "";
}
.weather-forecast.condition-221:before {
content: "";
}
.weather-forecast.condition-230:before {
content: "";
}
.weather-forecast.condition-231:before {
content: "";
}
.weather-forecast.condition-232:before {
content: "";
}
.weather-forecast.condition-300:before {
content: "";
}
.weather-forecast.condition-301:before {
content: "";
}
.weather-forecast.condition-302:before {
content: "";
}
.weather-forecast.condition-310:before {
content: "";
}
.weather-forecast.condition-311:before {
content: "";
}
.weather-forecast.condition-312:before {
content: "";
}
.weather-forecast.condition-313:before {
content: "";
}
.weather-forecast.condition-314:before {
content: "";
}
.weather-forecast.condition-321:before {
content: "";
}
.weather-forecast.condition-500:before {
content: "";
}
.weather-forecast.condition-501:before {
content: "";
}
.weather-forecast.condition-502:before {
content: "";
}
.weather-forecast.condition-503:before {
content: "";
}
.weather-forecast.condition-504:before {
content: "";
}
.weather-forecast.condition-511:before {
content: "";
}
.weather-forecast.condition-520:before {
content: "";
}
.weather-forecast.condition-521:before {
content: "";
}
.weather-forecast.condition-522:before {
content: "";
}
.weather-forecast.condition-531:before {
content: "";
}
.weather-forecast.condition-600:before {
content: "";
}
.weather-forecast.condition-601:before {
content: "";
}
.weather-forecast.condition-602:before {
content: "";
}
.weather-forecast.condition-611:before {
content: "";
}
.weather-forecast.condition-612:before {
content: "";
}
.weather-forecast.condition-615:before {
content: "";
}
.weather-forecast.condition-616:before {
content: "";
}
.weather-forecast.condition-620:before {
content: "";
}
.weather-forecast.condition-621:before {
content: "";
}
.weather-forecast.condition-622:before {
content: "";
}
.weather-forecast.condition-701:before {
content: "";
}
.weather-forecast.condition-711:before {
content: "";
}
.weather-forecast.condition-721:before {
content: "";
}
.weather-forecast.condition-731:before {
content: "";
}
.weather-forecast.condition-741:before {
content: "";
}
.weather-forecast.condition-761:before {
content: "";
}
.weather-forecast.condition-762:before {
content: "";
}
.weather-forecast.condition-771:before {
content: "";
}
.weather-forecast.condition-781:before {
content: "";
}
.weather-forecast.condition-800:before {
content: "";
}
.weather-forecast.condition-801:before {
content: "";
}
.weather-forecast.condition-802:before {
content: "";
}
.weather-forecast.condition-803:before {
content: "";
}
.weather-forecast.condition-804:before {
content: "";
}
.weather-forecast.condition-900:before {
content: "";
}
.weather-forecast.condition-901:before {
content: "";
}
.weather-forecast.condition-902:before {
content: "";
}
.weather-forecast.condition-903:before {
content: "";
}
.weather-forecast.condition-904:before {
content: "";
}
.weather-forecast.condition-905:before {
content: "";
}
.weather-forecast.condition-906:before {
content: "";
}
.weather-forecast.condition-957:before {
content: "";
}
/*<editor-fold desc="redirect dialog">*/
dialog.redirect:modal {

View File

@ -30,6 +30,9 @@
<a href="mailto:{{ .Email }}">{{ .Email }}</a>
</address>
{{- end }}
{{ with .WeatherForecast -}}
<span class="weather-forecast condition-{{ .WeatherConditionId }}">{{ dayOfWeek .ForecastedAt }} <span>{{ .DayTemperature }} / {{ .MinTemperature }}</span></span>
{{- end }}
{{ if .LocalizedAlternates -}}
<nav>
<ul>
@ -71,7 +74,8 @@
<li class="boto-reserva"><a href="/{{ currentLocale }}/booking">{{( pgettext "Booking" "title" )}}</a></li>
{{ if .LocalizedAlternates -}}
<li class="has-submenu">{{ range .LocalizedAlternates -}}
{{ if eq .Lang currentLocale }}<button type="button">{{ .Endonym }}</button>{{ end }}
{{ if eq .Lang currentLocale }}
<button type="button">{{ .Endonym }}</button>{{ end }}
{{- end }}
<ul>
{{ range .LocalizedAlternates }}{{ if ne .Lang currentLocale -}}
@ -129,7 +133,10 @@
</div>
</section>
</div>
<span>© {{ .CompanyAddress.TradeName }} | 19942023 | <a href="/{{ currentLocale }}/legal/credits">{{( pgettext "Credits" "title" )}}</a> | <a href="/{{ currentLocale }}/legal/notice">{{( pgettext "Terms and Conditions" "title" )}}</a> | <a href="/{{ currentLocale }}/legal/reservation">{{( pgettext "Reservation Conditions" "title" )}}</a></span>
<span>© {{ .CompanyAddress.TradeName }} | 19942023 | <a
href="/{{ currentLocale }}/legal/credits">{{( pgettext "Credits" "title" )}}</a> | <a
href="/{{ currentLocale }}/legal/notice">{{( pgettext "Terms and Conditions" "title" )}}</a> | <a
href="/{{ currentLocale }}/legal/reservation">{{( pgettext "Reservation Conditions" "title" )}}</a></span>
</footer>
</body>