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 -}}