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:
parent
ea2fe8848b
commit
e584e29f46
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
|
@ -38,37 +38,5 @@
|
|||
{{- end }}
|
||||
|
||||
<h2>{{( pgettext "Calendar" "title" )}}</h2>
|
||||
<div class="season-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>
|
||||
{{ template "calendar.gohtml" .Calendar }}
|
||||
{{- end }}
|
||||
|
|
Loading…
Reference in New Issue