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:
parent
7c6bac1986
commit
d64e899e0f
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>")
|
||||
},
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert camper:weather_forecast from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop table if exists camper.weather_forecast;
|
||||
|
||||
commit;
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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.
|
@ -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 {
|
||||
|
|
|
@ -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 }} | 1994–2023 | <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 }} | 1994–2023 | <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>
|
||||
|
|
Loading…
Reference in New Issue