Add the calendar to the public campsite type page

I had to export the Calendar type from Season to use it from
campsite/types, and also renamed them because season.SeasonCalendar is
a bit redundant compared to just season.Calendar.

I still have not added the HTMx code to switch year because i am not
sure whether Oriol will want to show a whole year or just half a year.

The calculation for the text color taking into account the contrast with
the background is from [0].

[0]: https://www.smashingmagazine.com/2020/07/css-techniques-legibility/#foreground-contrast
This commit is contained in:
jordi fita mas 2023-10-14 23:14:23 +02:00
parent e1575c6edd
commit 852acaccc3
7 changed files with 186 additions and 31 deletions

View File

@ -9,6 +9,7 @@ import (
"context" "context"
gotemplate "html/template" gotemplate "html/template"
"net/http" "net/http"
"time"
"golang.org/x/text/language" "golang.org/x/text/language"
@ -17,6 +18,7 @@ import (
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/season"
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
"dev.tandem.ws/tandem/camper/pkg/uuid" "dev.tandem.ws/tandem/camper/pkg/uuid"
) )
@ -35,7 +37,7 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
page, err := newPublicPage(r.Context(), conn, user.Locale, head) page, err := newPublicPage(r.Context(), company, conn, user.Locale, head)
if database.ErrorIsNotFound(err) { if database.ErrorIsNotFound(err) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -55,6 +57,7 @@ type publicPage struct {
Carousel []*carousel.Slide Carousel []*carousel.Slide
Prices []*typePrice Prices []*typePrice
Features []*typeFeature Features []*typeFeature
Calendar *season.Calendar
Spiel gotemplate.HTML Spiel gotemplate.HTML
Info gotemplate.HTML Info gotemplate.HTML
Facilities gotemplate.HTML Facilities gotemplate.HTML
@ -74,7 +77,7 @@ type typeFeature struct {
Name string Name string
} }
func newPublicPage(ctx context.Context, conn *database.Conn, loc *locale.Locale, slug string) (*publicPage, error) { func newPublicPage(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale, slug string) (*publicPage, error) {
prices, err := collectPrices(ctx, conn, loc.Language, slug) prices, err := collectPrices(ctx, conn, loc.Language, slug)
if err != nil { if err != nil {
return nil, err return nil, err
@ -83,9 +86,14 @@ func newPublicPage(ctx context.Context, conn *database.Conn, loc *locale.Locale,
if err != nil { if err != nil {
return nil, err return nil, err
} }
calendar, err := season.CollectSeasonCalendar(ctx, company, conn, time.Now().Year())
if err != nil {
return nil, err
}
page := &publicPage{ page := &publicPage{
PublicPage: template.NewPublicPage(), PublicPage: template.NewPublicPage(),
Carousel: mustCollectSlides(ctx, conn, loc, slug), Carousel: mustCollectSlides(ctx, conn, loc, slug),
Calendar: calendar,
Prices: prices, Prices: prices,
Features: features, Features: features,
} }
@ -129,7 +137,13 @@ func collectPrices(ctx context.Context, conn *database.Conn, language language.T
group by season_id group by season_id
) as option on option.season_id = season.season_id ) as option on option.season_id = season.season_id
where season.active where season.active
`, language, slug) union all
select $3
, to_color($4)::text
, 1
, ''
, false
`, language, slug, locale.PgettextNoop("Closed", "season"), season.UnsetColor)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -171,5 +185,5 @@ func collectFeatures(ctx context.Context, conn *database.Conn, language language
func (p *publicPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func (p *publicPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn) p.Setup(r, user, company, conn)
template.MustRenderPublic(w, r, user, company, "campsite/type.gohtml", p) template.MustRenderPublicFiles(w, r, user, company, p, "campsite/type.gohtml", "campsite/calendar.gohtml")
} }

View File

@ -26,7 +26,7 @@ import (
"dev.tandem.ws/tandem/camper/pkg/uuid" "dev.tandem.ws/tandem/camper/pkg/uuid"
) )
const unsetColor = 0xd1d0df const UnsetColor = 0xd1d0df
type AdminHandler struct { type AdminHandler struct {
locales locale.Locales locales locale.Locales
@ -124,7 +124,7 @@ func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, c
if err != nil { if err != nil {
panic(err) panic(err)
} }
calendar, err := collectSeasonCalendar(r.Context(), company, conn, getCalendarYear(r.URL.Query())) calendar, err := CollectSeasonCalendar(r.Context(), company, conn, getCalendarYear(r.URL.Query()))
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -213,7 +213,7 @@ var longMonthNames = []string{
locale.PgettextNoop("December", "month"), locale.PgettextNoop("December", "month"),
} }
func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *database.Conn, year int) (*seasonCalendar, error) { func CollectSeasonCalendar(ctx context.Context, company *auth.Company, conn *database.Conn, year int) (*Calendar, error) {
firstDay := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) firstDay := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
lastDay := time.Date(year, time.December, 31, 23, 59, 59, 0, time.UTC) lastDay := time.Date(year, time.December, 31, 23, 59, 59, 0, time.UTC)
rows, err := conn.Query(ctx, ` rows, err := conn.Query(ctx, `
@ -222,19 +222,19 @@ func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *dat
from generate_series($2, $3, interval '1 day') as t(day) from generate_series($2, $3, interval '1 day') as t(day)
left join season_calendar on season_range @> t.day::date left join season_calendar on season_range @> t.day::date
left join season on season.season_id = season_calendar.season_id and company_id = $4 left join season on season.season_id = season_calendar.season_id and company_id = $4
`, unsetColor, firstDay, lastDay, company.ID) `, UnsetColor, firstDay, lastDay, company.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var month *seasonMonth var month *Month
var week seasonWeek var week Week
calendar := &seasonCalendar{ calendar := &Calendar{
Year: year, Year: year,
} }
weekday := int(time.Monday) weekday := int(time.Monday)
for rows.Next() { for rows.Next() {
day := &seasonDay{} day := &Day{}
if err = rows.Scan(&day.Date, &day.Color); err != nil { if err = rows.Scan(&day.Date, &day.Color); err != nil {
return nil, err return nil, err
} }
@ -242,32 +242,32 @@ func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *dat
if month == nil || month.Month != dayMonth { if month == nil || month.Month != dayMonth {
if month != nil { if month != nil {
for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 { for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 {
week = append(week, &seasonDay{}) week = append(week, &Day{})
} }
month.Weeks = append(month.Weeks, week) month.Weeks = append(month.Weeks, week)
calendar.Months = append(calendar.Months, month) calendar.Months = append(calendar.Months, month)
} }
month = &seasonMonth{ month = &Month{
Month: dayMonth, Month: dayMonth,
Name: longMonthNames[dayMonth-1], Name: longMonthNames[dayMonth-1],
} }
week = seasonWeek{} week = Week{}
weekday = int(time.Monday) weekday = int(time.Monday)
dayWeekday := int(day.Date.Weekday()) dayWeekday := int(day.Date.Weekday())
for ; weekday != dayWeekday; weekday = (weekday + 1) % 7 { for ; weekday != dayWeekday; weekday = (weekday + 1) % 7 {
week = append(week, &seasonDay{}) week = append(week, &Day{})
} }
} }
week = append(week, day) week = append(week, day)
weekday = (weekday + 1) % 7 weekday = (weekday + 1) % 7
if weekday == int(time.Monday) { if weekday == int(time.Monday) {
month.Weeks = append(month.Weeks, week) month.Weeks = append(month.Weeks, week)
week = seasonWeek{} week = Week{}
} }
} }
if month != nil { if month != nil {
for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 { for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 {
week = append(week, &seasonDay{}) week = append(week, &Day{})
} }
month.Weeks = append(month.Weeks, week) month.Weeks = append(month.Weeks, week)
calendar.Months = append(calendar.Months, month) calendar.Months = append(calendar.Months, month)
@ -276,28 +276,28 @@ func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *dat
return calendar, nil return calendar, nil
} }
type seasonCalendar struct { type Calendar struct {
Year int Year int
Months []*seasonMonth Months []*Month
Form *calendarForm Form *calendarForm
} }
type seasonMonth struct { type Month struct {
Month time.Month Month time.Month
Name string Name string
Weeks []seasonWeek Weeks []Week
} }
type seasonWeek []*seasonDay type Week []*Day
type seasonDay struct { type Day struct {
Date time.Time Date time.Time
Color string Color string
} }
type seasonIndex struct { type seasonIndex struct {
Seasons []*seasonEntry Seasons []*seasonEntry
Calendar *seasonCalendar Calendar *Calendar
} }
func (page *seasonIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (page *seasonIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
@ -457,7 +457,7 @@ func mustCollectCalendarSeasons(ctx context.Context, company *auth.Company, conn
from season from season
where company_id = $3 where company_id = $3
and active and active
order by sort, name`, locale.PgettextNoop("Unset", "action"), unsetColor, company.ID) order by sort, name`, locale.PgettextNoop("Unset", "action"), UnsetColor, company.ID)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -498,7 +498,7 @@ func (f *calendarForm) Valid(l *locale.Locale) bool {
} }
func (f *calendarForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, year int) { func (f *calendarForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, year int) {
calendar, err := collectSeasonCalendar(r.Context(), company, conn, year) calendar, err := CollectSeasonCalendar(r.Context(), company, conn, year)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"strconv"
"strings" "strings"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
@ -94,6 +95,10 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ
"dec": func(i int) int { "dec": func(i int) int {
return i - 1 return i - 1
}, },
"hexToDec": func(s string) int {
num, _ := strconv.ParseInt(s, 16, 0)
return int(num)
},
}) })
templates = append(templates, "form.gohtml") templates = append(templates, "form.gohtml")
files := make([]string, len(templates)) files := make([]string, len(templates))

View File

@ -821,6 +821,94 @@ dt {
} }
} }
.campsite_type_calendar button {
display: flex;
gap: 1em;
border: none;
cursor: pointer;
}
.campsite_type_calendar form button:first-child, .campsite_type_calendar > header button {
min-width: 0;
}
.campsite_type_calendar > header {
display: flex;
gap: 2rem;
justify-content: center;
align-items: center;
}
.campsite_type_calendar > header button:first-of-type {
order: -1;
}
.campsite_type_calendar > header button:first-of-type::before {
content: "←";
}
.campsite_type_calendar > header button:last-of-type::before {
content: "→";
}
.campsite_type_calendar > div {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(27rem, 1fr));
grid-auto-rows: 1fr;
justify-content: center;
align-items: start;
gap: 1em;
}
@media (max-width: 48rem) {
.campsite_type_calendar > div {
display: flex;
flex-direction: column;
}
.campsite_type_calendar table {
width: 100%;
}
}
.campsite_type_calendar table {
border-collapse: collapse;
}
.campsite_type_calendar td {
width: calc(100% / 7);
}
.campsite_type_calendar time {
--aa-brightness: calc(((var(--red) * 299) + (var(--green) * 587) + (var(--blue) * 114)) / 1000);
--aa-color: calc((var(--aa-brightness) - 128) * -1000);
background: rgb(var(--red), var(--green), var(--blue));
color: rgb(var(--aa-color), var(--aa-color), var(--aa-color));
display: flex;
width: 100%;
min-width: 3rem;
aspect-ratio: 1;
justify-content: center;
align-items: center;
}
.campsite_type_calendar [aria-checked] {
border: 2px solid var(--camper--color--black);
position: relative;
}
.campsite_type_calendar [aria-checked]::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
background-color: var(--camper--color--black);
border-radius: 50%;
width: .8rem;
height: .8rem;
}
body > footer { body > footer {
display: flex; display: flex;

View File

@ -1,5 +1,5 @@
<article class="season-calendar" data-hx-target="this" data-hx-swap="outerHTML"> <article class="season-calendar" data-hx-target="this" data-hx-swap="outerHTML">
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.seasonCalendar*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.Calendar*/ -}}
<header> <header>
<h3>{{ .Year }}</h3> <h3>{{ .Year }}</h3>
<button type="button" <button type="button"

View File

@ -0,0 +1,49 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
<article class="campsite_type_calendar">
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.Calendar*/ -}}
<header>
<h3>{{ .Year }}</h3>
<button type="button"
data-hx-get="/{{ currentLocale }}/seasons/calendar?year={{ dec .Year }}"
><span class="sr-only">{{ pgettext "Prev" "navigation" }}</span></button>
<button type="button"
data-hx-get="/{{ currentLocale }}/seasons/calendar?year={{ inc .Year }}"
><span class="sr-only">{{ pgettext "Next" "navigation" }}</span></button>
</header>
<div>
{{ range .Months -}}
<table class="month">
<caption>{{ pgettext .Name "month" }}</caption>
<thead>
<tr>
<th scope="col">{{(pgettext "Mon" "day" )}}</th>
<th scope="col">{{(pgettext "Tue" "day" )}}</th>
<th scope="col">{{(pgettext "Wed" "day" )}}</th>
<th scope="col">{{(pgettext "Thu" "day" )}}</th>
<th scope="col">{{(pgettext "Fri" "day" )}}</th>
<th scope="col">{{(pgettext "Sat" "day" )}}</th>
<th scope="col">{{(pgettext "Sun" "day" )}}</th>
</tr>
</thead>
<tbody>
{{ range .Weeks }}
<tr>
{{- range . }}
<td>
{{- if .Color -}}
<time style="--red: {{ slice .Color 1 3 | hexToDec }}; --green: {{ slice .Color 3 5 | hexToDec }}; --blue: {{ slice .Color 5 7 | hexToDec }}"
datetime="{{ .Date.Format "2006-01-02" }}"
>{{ .Date.Format "2" }}</time>
{{- end -}}
</td>
{{- end }}
</tr>
{{- end }}
</tbody>
</table>
{{- end }}
</div>
</article>

View File

@ -69,7 +69,7 @@
</dt> </dt>
{{ if .HasOptions -}} {{ if .HasOptions -}}
<dd>{{ printf (gettext "Starting from %s €/night") .PricePerNight }}</dd> <dd>{{ printf (gettext "Starting from %s €/night") .PricePerNight }}</dd>
{{- else -}} {{- else if .PricePerNight -}}
<dd>{{ printf (gettext "%s €/night") .PricePerNight }}</dd> <dd>{{ printf (gettext "%s €/night") .PricePerNight }}</dd>
{{- end }} {{- end }}
{{ if gt .MinNights 1 -}} {{ if gt .MinNights 1 -}}
@ -82,8 +82,7 @@
{{- end }} {{- end }}
</div> </div>
<div class="campsite_type_calendar"> {{ template "calendar.gohtml" .Calendar }}
</div>
{{ with .Features -}} {{ with .Features -}}
<article class="campsite_type_features"> <article class="campsite_type_features">