Make seasons’ calendar dynamic and allow to set/unset ranges

The CSS is not very good, but for testing purposes it will work.
This commit is contained in:
jordi fita mas 2023-09-27 14:21:27 +02:00
parent ea2fe8848b
commit e584e29f46
6 changed files with 292 additions and 54 deletions

View File

@ -12,6 +12,7 @@ import (
"net/url" "net/url"
"regexp" "regexp"
"strconv" "strconv"
"time"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
@ -83,6 +84,11 @@ func (v *Validator) CheckValidPostalCode(ctx context.Context, conn *database.Con
return v.Check(input, match, message), nil return v.Check(input, match, message), nil
} }
func (v *Validator) CheckValidDate(field *Input, message string) bool {
_, err := time.Parse("2006-01-02", field.Val)
return v.Check(field, err == nil, message)
}
func (v *Validator) CheckPasswordConfirmation(password *Input, confirm *Input, message string) bool { func (v *Validator) CheckPasswordConfirmation(password *Input, confirm *Input, message string) bool {
return v.Check(confirm, password.Val == confirm.Val, message) return v.Check(confirm, password.Val == confirm.Val, message)
} }

View File

@ -7,9 +7,12 @@ package season
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"time" "time"
"github.com/jackc/pgtype"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form" "dev.tandem.ws/tandem/camper/pkg/form"
@ -19,6 +22,8 @@ import (
"dev.tandem.ws/tandem/camper/pkg/uuid" "dev.tandem.ws/tandem/camper/pkg/uuid"
) )
const unsetColor = 13750495
type AdminHandler struct { type AdminHandler struct {
} }
@ -32,14 +37,6 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
head, r.URL.Path = httplib.ShiftPath(r.URL.Path) head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head { switch head {
case "new":
switch r.Method {
case http.MethodGet:
f := newSeasonForm()
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "": case "":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@ -49,6 +46,21 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
} }
case "new":
switch r.Method {
case http.MethodGet:
f := newSeasonForm()
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "range":
switch r.Method {
case http.MethodPut:
updateSeasonCalendar(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default: default:
if !uuid.Valid(head) { if !uuid.Valid(head) {
http.NotFound(w, r) http.NotFound(w, r)
@ -83,6 +95,7 @@ func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, c
if err != nil { if err != nil {
panic(err) panic(err)
} }
calendar.Form = newCalendarForm(r.Context(), company, conn)
page := &seasonIndex{ page := &seasonIndex{
Seasons: seasons, Seasons: seasons,
Calendar: calendar, Calendar: calendar,
@ -138,23 +151,23 @@ 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) (*seasonCalendar, 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, `
select t.day::date select t.day::date
, to_color(coalesce(color, 13750495)) as color , to_color(coalesce(color, $1)) as color
from generate_series($1, $2, 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 = $3 left join season on season.season_id = season_calendar.season_id and company_id = $4
`, 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 *seasonMonth
var week seasonWeek var week seasonWeek
var calendar seasonCalendar calendar := &seasonCalendar{}
weekday := int(time.Monday) weekday := int(time.Monday)
for rows.Next() { for rows.Next() {
day := &seasonDay{} day := &seasonDay{}
@ -168,7 +181,7 @@ func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *dat
week = append(week, &seasonDay{}) week = append(week, &seasonDay{})
} }
month.Weeks = append(month.Weeks, week) month.Weeks = append(month.Weeks, week)
calendar = append(calendar, month) calendar.Months = append(calendar.Months, month)
} }
month = &seasonMonth{ month = &seasonMonth{
Month: dayMonth, Month: dayMonth,
@ -193,13 +206,16 @@ func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *dat
week = append(week, &seasonDay{}) week = append(week, &seasonDay{})
} }
month.Weeks = append(month.Weeks, week) month.Weeks = append(month.Weeks, week)
calendar = append(calendar, month) calendar.Months = append(calendar.Months, month)
} }
return calendar, nil return calendar, nil
} }
type seasonCalendar []*seasonMonth type seasonCalendar struct {
Months []*seasonMonth
Form *calendarForm
}
type seasonMonth struct { type seasonMonth struct {
Month time.Month Month time.Month
@ -216,11 +232,11 @@ type seasonDay struct {
type seasonIndex struct { type seasonIndex struct {
Seasons []*seasonEntry Seasons []*seasonEntry
Calendar seasonCalendar Calendar *seasonCalendar
} }
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) {
template.MustRenderAdmin(w, r, user, company, "season/index.gohtml", page) template.MustRenderAdminFiles(w, r, user, company, page, "season/index.gohtml", "season/calendar.gohtml")
} }
func processSeasonForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *seasonForm, act func()) { func processSeasonForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *seasonForm, act func()) {
@ -310,3 +326,110 @@ func (f *seasonForm) Valid(ctx context.Context, conn *database.Conn, l *locale.L
func (f *seasonForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (f *seasonForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "season/form.gohtml", f) template.MustRenderAdmin(w, r, user, company, "season/form.gohtml", f)
} }
func updateSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newCalendarForm(r.Context(), company, conn)
if ok, err := form.Handle(f, w, r, user); err != nil {
return
} else if ok {
var seasonRange pgtype.Daterange
if err = seasonRange.Set(fmt.Sprintf("[%s,%s]", f.StartDate.Val, f.EndDate.Val)); err != nil {
panic(err)
}
if f.SeasonID.Val == "" {
conn.MustExec(r.Context(), "select unset_season_range($1)", seasonRange)
} else {
conn.MustExec(r.Context(), "select set_season_range($1, $2)", f.SeasonID, seasonRange)
}
f.StartDate.Val = ""
f.EndDate.Val = ""
}
f.MustRender(w, r, user, company, conn)
}
type calendarForm struct {
Seasons []*seasonEntry
StartDate *form.Input
EndDate *form.Input
SeasonID *form.Input
}
func newCalendarForm(ctx context.Context, company *auth.Company, conn *database.Conn) *calendarForm {
return &calendarForm{
Seasons: mustCollectCalendarSeasons(ctx, company, conn),
StartDate: &form.Input{
Name: "start_date",
},
EndDate: &form.Input{
Name: "end_date",
},
SeasonID: &form.Input{
Name: "season_id",
},
}
}
func mustCollectCalendarSeasons(ctx context.Context, company *auth.Company, conn *database.Conn) []*seasonEntry {
rows, err := conn.Query(ctx, `
select '' as slug
, $1 as name
, to_color($2)::text
, true
, 0 as sort
union all
select season_id::text
, name
, to_color(color)::text
, active
, 1 as sort
from season
where company_id = $3
and active
order by sort, name`, locale.PgettextNoop("Unset", "action"), unsetColor, company.ID)
if err != nil {
panic(err)
}
defer rows.Close()
var seasons []*seasonEntry
for rows.Next() {
entry := &seasonEntry{}
var sort int
if err = rows.Scan(&entry.Slug, &entry.Name, &entry.Color, &entry.Active, &sort); err != nil {
panic(err)
}
seasons = append(seasons, entry)
}
return seasons
}
func (f *calendarForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.StartDate.FillValue(r)
f.EndDate.FillValue(r)
f.SeasonID.FillValue(r)
return nil
}
func (f *calendarForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
if v.CheckRequired(f.StartDate, l.GettextNoop("Start date can not be empty.")) {
v.CheckValidDate(f.StartDate, l.GettextNoop("Start date must be a valid date."))
}
if v.CheckRequired(f.EndDate, l.GettextNoop("End date can not be empty.")) {
v.CheckValidDate(f.EndDate, l.GettextNoop("End date must be a valid date."))
}
return v.AllOK
}
func (f *calendarForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
calendar, err := collectSeasonCalendar(r.Context(), company, conn, 2023)
if err != nil {
panic(err)
}
calendar.Form = f
template.MustRenderNoLayout(w, r, user, company, "season/calendar.gohtml", calendar)
}

View File

@ -127,10 +127,61 @@ a.missing-translation {
.season-calendar { .season-calendar {
display: grid; display: grid;
grid-template-columns: repeat(3, auto); grid-template-columns: repeat(3, auto);
grid-auto-rows: 1fr;
justify-content: center; justify-content: center;
align-items: start;
gap: 2em; gap: 2em;
} }
.season-calendar svg { @media (max-width: 48rem) {
max-width: 5rem; .season-calendar {
display: flex;
flex-direction: column;
}
} }
.season-calendar table {
border-collapse: collapse;
}
.season-calendar td {
width: calc(100% / 7);
}
.season-calendar time {
display: block;
width: 100%;
min-width: 3rem;
aspect-ratio: 1;
text-indent: 100%;
white-space: nowrap;
overflow: hidden;
}
.season-calendar [aria-checked] {
border: 2px solid black;
position: relative;
}
.season-calendar [aria-checked]::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
background-color: black;
border-radius: 50%;
width: .8rem;
height: .8rem;
}
.season-calendar form {
position: fixed;
}
.season-calendar button {
display: flex;
gap: 1em;
}

View File

@ -175,6 +175,41 @@ export function setupIconInput(icon) {
updateValue(input.value); updateValue(input.value);
} }
export function setupCalendar(calendar) {
const startDate = calendar.querySelector('input[name="start_date"]');
const endDate = calendar.querySelector('input[name="end_date"]');
const days = Array.from(calendar.querySelectorAll('time'));
const selectDate = function (e) {
e.preventDefault();
const date = e.currentTarget.dateTime;
if (!date) {
return;
}
if (!startDate.value) {
startDate.value = date;
e.currentTarget.setAttribute('aria-checked', true);
return;
} else if (startDate.value > date) {
endDate.value = startDate.value;
startDate.value = date;
} else {
endDate.value = date;
}
for (const day of days) {
if (day.dateTime >= startDate.value && day.dateTime <= endDate.value) {
day.setAttribute('aria-checked', true);
} else {
day.removeAttribute('aria-checked');
}
}
}
for (const day of days) {
day.addEventListener('click', selectDate);
}
}
htmx.onLoad((target) => { htmx.onLoad((target) => {
if (target.tagName === 'DIALOG') { if (target.tagName === 'DIALOG') {
target.showModal(); target.showModal();

View File

@ -0,0 +1,55 @@
<div class="season-calendar" data-hx-target="this" data-hx-swap="outerHTML">
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.seasonCalendar*/ -}}
{{ range .Months -}}
<table>
<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="background-color: {{ .Color }}"
datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2" }}</time>
{{- end -}}
</td>
{{- end }}
</tr>
{{- end }}
</tbody>
</table>
{{- end }}
{{ with .Form }}
<form data-hx-put="/admin/seasons/range">
{{ CSRFInput }}
{{ with .StartDate }}<input type="hidden" name="{{ .Name }}" value="{{ .Val }}">{{ end }}
{{ with .EndDate }}<input type="hidden" name="{{ .Name }}" value="{{ .Val }}">{{ end }}
<footer>
{{ range .Seasons -}}
<button type="submit" name="season_id" value="{{ .Slug }}">
<svg width="20px" height="20px">
<circle cx="50%" cy="50%" r="49%" fill="{{ .Color }}" stroke="#000" stroke-width=".5"/>
</svg>
{{ .Name }}
</button>
{{- end }}
</footer>
</form>
{{ end }}
<script type="module">
import {setupCalendar} from "/static/camper.js";
setupCalendar(document.querySelector('.season-calendar'))
</script>
</div>

View File

@ -38,37 +38,5 @@
{{- end }} {{- end }}
<h2>{{( pgettext "Calendar" "title" )}}</h2> <h2>{{( pgettext "Calendar" "title" )}}</h2>
<div class="season-calendar"> {{ template "calendar.gohtml" .Calendar }}
{{ range .Calendar -}}
<table>
<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 -}}
<svg viewBox="0 0 100 100">
<rect x="0" y="0" width="100" height="100" fill="{{ .Color }}"/>
</svg>
{{- end -}}
</td>
{{- end }}
</tr>
{{- end }}
</tbody>
</table>
{{- end }}
</div>
{{- end }} {{- end }}