diff --git a/cmd/camper-weather/main.go b/cmd/camper-weather/main.go new file mode 100644 index 0000000..42a9e49 --- /dev/null +++ b/cmd/camper-weather/main.go @@ -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) + } + } +} diff --git a/deploy/weather_forecast.sql b/deploy/weather_forecast.sql new file mode 100644 index 0000000..b45433a --- /dev/null +++ b/deploy/weather_forecast.sql @@ -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; diff --git a/pkg/template/page.go b/pkg/template/page.go index ed74b05..a2936dd 100644 --- a/pkg/template/page.go +++ b/pkg/template/page.go @@ -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, + ) +} diff --git a/pkg/template/render.go b/pkg/template/render.go index 39d65bf..8633337 100644 --- a/pkg/template/render.go +++ b/pkg/template/render.go @@ -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(`") + }, "formatDate": func(time time.Time) template.HTML { return template.HTML(`") }, diff --git a/revert/weather_forecast.sql b/revert/weather_forecast.sql new file mode 100644 index 0000000..25a6aa9 --- /dev/null +++ b/revert/weather_forecast.sql @@ -0,0 +1,7 @@ +-- Revert camper:weather_forecast from pg + +begin; + +drop table if exists camper.weather_forecast; + +commit; diff --git a/sqitch.plan b/sqitch.plan index 999bb2e..9112ab4 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -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 # Add function to unmarshal a booking cancel_booking [roles schema_camper booking booking_campsite] 2024-05-03T14:27:31Z jordi fita mas # Add function to cancel a booking campsite_type__operating_dates [campsite_type] 2024-07-15T21:27:19Z jordi fita mas # Add operating_dates field to campsite_type +weather_forecast [schema_camper roles extension_uri] 2025-01-28T18:57:33Z jordi fita mas # Add table to keep weather forecast diff --git a/test/weather_forecast.sql b/test/weather_forecast.sql new file mode 100644 index 0000000..4b0d5e7 --- /dev/null +++ b/test/weather_forecast.sql @@ -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; + diff --git a/verify/weather_forecast.sql b/verify/weather_forecast.sql new file mode 100644 index 0000000..ced15fe --- /dev/null +++ b/verify/weather_forecast.sql @@ -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; diff --git a/web/static/fonts/weathericons.woff2 b/web/static/fonts/weathericons.woff2 new file mode 100644 index 0000000..bb0c19d Binary files /dev/null and b/web/static/fonts/weathericons.woff2 differ diff --git a/web/static/public.css b/web/static/public.css index cd5da8d..0163534 100644 --- a/web/static/public.css +++ b/web/static/public.css @@ -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: ""; +} + + /**/ dialog.redirect:modal { diff --git a/web/templates/public/layout.gohtml b/web/templates/public/layout.gohtml index ee486a8..540ac7d 100644 --- a/web/templates/public/layout.gohtml +++ b/web/templates/public/layout.gohtml @@ -30,6 +30,9 @@ {{ .Email }} {{- end }} + {{ with .WeatherForecast -}} + {{ dayOfWeek .ForecastedAt }} {{ .DayTemperature }} / {{ .MinTemperature }} + {{- end }} {{ if .LocalizedAlternates -}}