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 -}} + + + + + + + + + + + + + + + {{ range .Weeks }} + + {{- range . }} + + {{- end }} + + {{- end }} + +
{{ pgettext .Name "month" }}
{{(pgettext "Mon" "day" )}}{{(pgettext "Tue" "day" )}}{{(pgettext "Wed" "day" )}}{{(pgettext "Thu" "day" )}}{{(pgettext "Fri" "day" )}}{{(pgettext "Sat" "day" )}}{{(pgettext "Sun" "day" )}}
+ {{- if .Color -}} + + {{- end -}} +
+ {{- end }} + {{ with .Form }} +
+ {{ CSRFInput }} + {{ with .StartDate }}{{ end }} + {{ with .EndDate }}{{ end }} +
+ {{ range .Seasons -}} + + {{- end }} +
+
+ {{ 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 -}} - - - - - - - - - - - - - - - {{ range .Weeks }} - - {{ range . }} - - {{- end }} - - {{- end }} - -
{{ pgettext .Name "month" }}
{{(pgettext "Mon" "day" )}}{{(pgettext "Tue" "day" )}}{{(pgettext "Wed" "day" )}}{{(pgettext "Thu" "day" )}}{{(pgettext "Fri" "day" )}}{{(pgettext "Sat" "day" )}}{{(pgettext "Sun" "day" )}}
- {{- if .Color -}} - - - - {{- end -}} -
- {{- end }} -
+ {{ template "calendar.gohtml" .Calendar }} {{- end }}