diff --git a/pkg/form/validator.go b/pkg/form/validator.go
index 0fe1f41..cd04d47 100644
--- a/pkg/form/validator.go
+++ b/pkg/form/validator.go
@@ -12,6 +12,7 @@ import (
"net/url"
"regexp"
"strconv"
+ "time"
"dev.tandem.ws/tandem/camper/pkg/database"
"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
}
+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 {
return v.Check(confirm, password.Val == confirm.Val, message)
}
diff --git a/pkg/season/admin.go b/pkg/season/admin.go
index 45bb253..c6e183f 100644
--- a/pkg/season/admin.go
+++ b/pkg/season/admin.go
@@ -7,9 +7,12 @@ package season
import (
"context"
+ "fmt"
"net/http"
"time"
+ "github.com/jackc/pgtype"
+
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
@@ -19,6 +22,8 @@ import (
"dev.tandem.ws/tandem/camper/pkg/uuid"
)
+const unsetColor = 13750495
+
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)
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 "":
switch r.Method {
case http.MethodGet:
@@ -49,6 +46,21 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
default:
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:
if !uuid.Valid(head) {
http.NotFound(w, r)
@@ -83,6 +95,7 @@ func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, c
if err != nil {
panic(err)
}
+ calendar.Form = newCalendarForm(r.Context(), company, conn)
page := &seasonIndex{
Seasons: seasons,
Calendar: calendar,
@@ -138,23 +151,23 @@ var longMonthNames = []string{
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)
lastDay := time.Date(year, time.December, 31, 23, 59, 59, 0, time.UTC)
rows, err := conn.Query(ctx, `
select t.day::date
- , to_color(coalesce(color, 13750495)) as color
- from generate_series($1, $2, interval '1 day') as t(day)
+ , to_color(coalesce(color, $1)) as color
+ from generate_series($2, $3, interval '1 day') as t(day)
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
- `, firstDay, lastDay, company.ID)
+ left join season on season.season_id = season_calendar.season_id and company_id = $4
+ `, unsetColor, firstDay, lastDay, company.ID)
if err != nil {
return nil, err
}
var month *seasonMonth
var week seasonWeek
- var calendar seasonCalendar
+ calendar := &seasonCalendar{}
weekday := int(time.Monday)
for rows.Next() {
day := &seasonDay{}
@@ -168,7 +181,7 @@ func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *dat
week = append(week, &seasonDay{})
}
month.Weeks = append(month.Weeks, week)
- calendar = append(calendar, month)
+ calendar.Months = append(calendar.Months, month)
}
month = &seasonMonth{
Month: dayMonth,
@@ -193,13 +206,16 @@ func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *dat
week = append(week, &seasonDay{})
}
month.Weeks = append(month.Weeks, week)
- calendar = append(calendar, month)
+ calendar.Months = append(calendar.Months, month)
}
return calendar, nil
}
-type seasonCalendar []*seasonMonth
+type seasonCalendar struct {
+ Months []*seasonMonth
+ Form *calendarForm
+}
type seasonMonth struct {
Month time.Month
@@ -216,11 +232,11 @@ type seasonDay struct {
type seasonIndex struct {
Seasons []*seasonEntry
- Calendar seasonCalendar
+ Calendar *seasonCalendar
}
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()) {
@@ -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) {
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)
+}
diff --git a/web/static/camper.css b/web/static/camper.css
index 3481790..0d52e8d 100644
--- a/web/static/camper.css
+++ b/web/static/camper.css
@@ -127,10 +127,61 @@ a.missing-translation {
.season-calendar {
display: grid;
grid-template-columns: repeat(3, auto);
+ grid-auto-rows: 1fr;
justify-content: center;
+ align-items: start;
gap: 2em;
}
-.season-calendar svg {
- max-width: 5rem;
+@media (max-width: 48rem) {
+ .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;
+}
+
diff --git a/web/static/camper.js b/web/static/camper.js
index 5bc36cd..7764471 100644
--- a/web/static/camper.js
+++ b/web/static/camper.js
@@ -175,6 +175,41 @@ export function setupIconInput(icon) {
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) => {
if (target.tagName === 'DIALOG') {
target.showModal();
diff --git a/web/templates/admin/season/calendar.gohtml b/web/templates/admin/season/calendar.gohtml
new file mode 100644
index 0000000..e2f7a34
--- /dev/null
+++ b/web/templates/admin/season/calendar.gohtml
@@ -0,0 +1,55 @@
+
+ {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.seasonCalendar*/ -}}
+ {{ range .Months -}}
+
+ {{ pgettext .Name "month" }}
+
+
+ {{(pgettext "Mon" "day" )}} |
+ {{(pgettext "Tue" "day" )}} |
+ {{(pgettext "Wed" "day" )}} |
+ {{(pgettext "Thu" "day" )}} |
+ {{(pgettext "Fri" "day" )}} |
+ {{(pgettext "Sat" "day" )}} |
+ {{(pgettext "Sun" "day" )}} |
+
+
+
+ {{ range .Weeks }}
+
+ {{- range . }}
+
+ {{- if .Color -}}
+
+ {{- end -}}
+ |
+ {{- end }}
+
+ {{- end }}
+
+
+ {{- end }}
+ {{ with .Form }}
+
+ {{ end }}
+
+
diff --git a/web/templates/admin/season/index.gohtml b/web/templates/admin/season/index.gohtml
index b9cc836..1d87e75 100644
--- a/web/templates/admin/season/index.gohtml
+++ b/web/templates/admin/season/index.gohtml
@@ -38,37 +38,5 @@
{{- end }}
{{( pgettext "Calendar" "title" )}}
-
- {{ range .Calendar -}}
-
- {{ pgettext .Name "month" }}
-
-
- {{(pgettext "Mon" "day" )}} |
- {{(pgettext "Tue" "day" )}} |
- {{(pgettext "Wed" "day" )}} |
- {{(pgettext "Thu" "day" )}} |
- {{(pgettext "Fri" "day" )}} |
- {{(pgettext "Sat" "day" )}} |
- {{(pgettext "Sun" "day" )}} |
-
-
-
- {{ range .Weeks }}
-
- {{ range . }}
-
- {{- if .Color -}}
-
- {{- end -}}
- |
- {{- end }}
-
- {{- end }}
-
-
- {{- end }}
-
+ {{ template "calendar.gohtml" .Calendar }}
{{- end }}