Merge remote-tracking branch 'origin/main' into fix/r1-native-keep-events-on-error
# Conflicts: # native/apple/Sources/CalendarKit/CalendarMenuView.swift # native/apple/Sources/CalendarKit/CalendarModel.swift # native/apple/Sources/CalendarKit/CalendarWorkspace.swift
10 files changed,
+457
−33
internal/calendar/handlers_event_form.go+21 −2internal/calendar/handlers_views.go+9 −6internal/calendar/static/calendar.css+33 −0internal/calendar/templates/day.html+1 −1internal/calendar/templates/event_form.html+18 −3internal/calendar/templates/week.html+31 −21internal/calendar/viewdata.go+11 −0internal/calendartest/allday_views_test.go+147 −0internal/calendartest/playwright_week_test.go+103 −0test/browser/week.mjs+83 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/calendar/cmd/calendar [no test files] ok amadan.net/oficina/calendar/internal/calendar 4.450s ? amadan.net/oficina/calendar/internal/calendar/icons [no test files] ? amadan.net/oficina/calendar/internal/calendar/seal [no test files] ok amadan.net/oficina/calendar/internal/calendartest 1.951s ok amadan.net/oficina/calendar/internal/guardstest 0.042s === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/internal/calendar/handlers_event_form.go b/internal/calendar/handlers_event_form.go| index 171dbde..5912106 100644 |
| --- a/internal/calendar/handlers_event_form.go |
| +++ b/internal/calendar/handlers_event_form.go |
| @@ -58,7 +58,8 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo |
| _ = r.ParseForm() |
| calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64) |
| allDay := r.FormValue("all_day") == "1" |
| - start := strings.TrimSpace(r.FormValue("start")) |
| + start := formTime(r.FormValue("start"), allDay) |
| + end := formTime(r.FormValue("end"), allDay) |
| // The zone is the person's, except on a booked event's form, which |
| // carries the EVENT's own zone as a hidden field: after the host |
| // changes their profile zone, a title-only save must still match |
| @@ -75,7 +76,7 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo |
| Notes: r.FormValue("notes"), |
| AllDay: allDay, |
| StartLocal: start, |
| - EndLocal: strings.TrimSpace(r.FormValue("end")), |
| + EndLocal: end, |
| TZ: tz, |
| Kind: r.FormValue("kind"), |
| Status: r.FormValue("status"), |
| @@ -108,6 +109,24 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo |
| return in, f, true |
| } |
| +// formTime reads a Starts/Ends value in the shape the All day box |
| +// asks for. The form renders date inputs for an all-day event and |
| +// datetime inputs otherwise, but without script ticking or unticking |
| +// All day cannot change the input under it — so an all-day submission |
| +// may arrive as "2026-09-23T00:00" and a timed one as "2026-09-23". |
| +// Both mean something unambiguous: the date part, or midnight on it. |
| +// Anything else is passed through for validation to refuse. |
| +func formTime(raw string, allDay bool) string { |
| + v := strings.TrimSpace(raw) |
| + switch { |
| + case allDay && len(v) > len(LayoutDate) && v[len(LayoutDate)] == 'T': |
| + return v[:len(LayoutDate)] |
| + case !allDay && len(v) == len(LayoutDate): |
| + return v + "T00:00" |
| + } |
| + return v |
| +} |
| + |
| // writable is the calendars somebody may actually create an event on. |
| // A dropdown offering a calendar the server would refuse is a dropdown |
| // that teaches people the app is broken. |
diff --git a/internal/calendar/handlers_views.go b/internal/calendar/handlers_views.go| index 39076bc..7268fb8 100644 |
| --- a/internal/calendar/handlers_views.go |
| +++ b/internal/calendar/handlers_views.go |
| @@ -95,6 +95,7 @@ func (h *handlers) week(w http.ResponseWriter, r *http.Request) { |
| now := time.Now().Unix() |
| var days []DayPage |
| + anyAllDay := false |
| for i := 0; i < 7; i++ { |
| d := start.AddDate(0, 0, i) |
| ds, de, err := DayBounds(d, p.Zone) |
| @@ -117,17 +118,19 @@ func (h *handlers) week(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| days = append(days, page) |
| + anyAllDay = anyAllDay || len(page.AllDay) > 0 |
| } |
| loc, _ := Zone(p.Zone) |
| title := fmt.Sprintf("Week of %s", start.Format("2 January 2006")) |
| data := map[string]any{ |
| - "Days": days, |
| - "StartISO": start.Format(LayoutDate), |
| - "PrevISO": start.AddDate(0, 0, -7).Format(LayoutDate), |
| - "NextISO": start.AddDate(0, 0, 7).Format(LayoutDate), |
| - "TodayISO": time.Now().In(loc).Format(LayoutDate), |
| - "Zone": p.Zone, |
| + "Days": days, |
| + "AnyAllDay": anyAllDay, |
| + "StartISO": start.Format(LayoutDate), |
| + "PrevISO": start.AddDate(0, 0, -7).Format(LayoutDate), |
| + "NextISO": start.AddDate(0, 0, 7).Format(LayoutDate), |
| + "TodayISO": time.Now().In(loc).Format(LayoutDate), |
| + "Zone": p.Zone, |
| } |
| render(w, "week", h.page(r, title, data)) |
| } |
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css| index ff4beb9..ce9efcb 100644 |
| --- a/internal/calendar/static/calendar.css |
| +++ b/internal/calendar/static/calendar.css |
| @@ -265,6 +265,18 @@ |
| white-space: normal; |
| } |
| +/* Under an hour there is room for one line, so time and title share |
| + * it. Stacked, the title is the second line and the first thing the |
| + * chip's own overflow cuts off. */ |
| +[cal-placed~="short"] [cal-chip] { |
| + align-items: baseline; |
| + flex-direction: row; |
| + gap: var(--rst-sp-1); |
| + line-height: 1.15; |
| + padding-block: 0; |
| + white-space: nowrap; |
| +} |
| + |
| /* The all-day strip above the timed grid. All-day events are not |
| * packed into the column: giving a birthday a height would make it |
| * look like a 24-hour meeting. */ |
| @@ -276,6 +288,27 @@ |
| padding: var(--rst-sp-2); |
| } |
| +/* In the week the all-day strip is one ROW of the grid, a cell per |
| + * day, so every day's cell is as tall as the busiest one and the |
| + * timed columns below all start on the same line. */ |
| +[cal-allday="week"] { |
| + align-content: flex-start; |
| + border-inline-end: 1px solid var(--rst-line); |
| + flex-direction: column; |
| + flex-wrap: nowrap; |
| + min-inline-size: 0; |
| + padding: var(--rst-sp-1); |
| +} |
| + |
| +[cal-allday-label] { |
| + border-block-end: 1px solid var(--rst-line); |
| + border-inline-end: 1px solid var(--rst-line); |
| + color: var(--rst-text-faint); |
| + font-size: var(--rst-fs-xs); |
| + padding: var(--rst-sp-1) var(--rst-sp-2) 0 0; |
| + text-align: end; |
| +} |
| + |
| /* ── The whisper ───────────────────────────────────────────────── */ |
| /* An event authored in another zone. The primary time is always the |
diff --git a/internal/calendar/templates/day.html b/internal/calendar/templates/day.html| index e2b0277..9510748 100644 |
| --- a/internal/calendar/templates/day.html |
| +++ b/internal/calendar/templates/day.html |
| @@ -52,7 +52,7 @@ |
| {{end}} |
| {{range .Data.Day.Placed}} |
| - <div cal-placed style="top:{{.TopPct}}%;height:{{.HeightPct}}%;left:{{.LeftPct}}%;width:{{.WidthPct}}%"> |
| + <div cal-placed="{{if .Short}}short{{end}}" style="top:{{.TopPct}}%;height:{{.HeightPct}}%;left:{{.LeftPct}}%;width:{{.WidthPct}}%"> |
| {{template "chip" .Chip}} |
| </div> |
| {{end}} |
diff --git a/internal/calendar/templates/event_form.html b/internal/calendar/templates/event_form.html| index 6996775..298835b 100644 |
| --- a/internal/calendar/templates/event_form.html |
| +++ b/internal/calendar/templates/event_form.html |
| @@ -87,9 +87,23 @@ |
| {{template "field-check" dict |
| "Name" "all_day" "Value" "1" "Label" "All day" "Checked" .Data.AllDay}} |
| - {{/* datetime.js enhances these two into a picker with |
| - natural-language parsing. Without it they are ordinary |
| - datetime-local inputs, which is a working floor. */}} |
| + {{/* An all-day event's times are DATES, and a datetime-local input |
| + refuses a bare date: it renders blank, and the form then cannot |
| + be saved without retyping both. So an all-day event gets date |
| + inputs. Ticking All day without script cannot swap the input, |
| + which is why readEventForm takes either shape. |
| + datetime.js enhances both into a picker with natural-language |
| + parsing. Without it they are ordinary inputs, which is a |
| + working floor. */}} |
| + {{if .Data.AllDay}} |
| + {{template "field-date" dict |
| + "Name" "start" "Label" "Starts" "Value" .Data.StartLocal "Required" true |
| + "Error" (if_eq .Data.ErrorField "start" .Data.Error)}} |
| + {{template "field-date" dict |
| + "Name" "end" "Label" "Ends" "Value" .Data.EndLocal "Required" true |
| + "Hint" "The end is exclusive: an all-day event on the 2nd ends on the 3rd." |
| + "Error" (if_eq .Data.ErrorField "end" .Data.Error)}} |
| + {{else}} |
| {{template "field-datetime" dict |
| "Name" "start" "Label" "Starts" "Value" .Data.StartLocal "Required" true |
| "Error" (if_eq .Data.ErrorField "start" .Data.Error)}} |
| @@ -97,6 +111,7 @@ |
| "Name" "end" "Label" "Ends" "Value" .Data.EndLocal "Required" true |
| "Hint" "The end is exclusive: an all-day event on the 2nd ends on the 3rd." |
| "Error" (if_eq .Data.ErrorField "end" .Data.Error)}} |
| + {{end}} |
| {{template "field-select" dict |
| "ID" "repeat" "Name" "repeat" "Label" "Repeats" |
diff --git a/internal/calendar/templates/week.html b/internal/calendar/templates/week.html| index fada49d..179d806 100644 |
| --- a/internal/calendar/templates/week.html |
| +++ b/internal/calendar/templates/week.html |
| @@ -29,34 +29,44 @@ |
| Flattening it to a list would make it the agenda, which already |
| exists and is better at being a list. */}} |
| <div cal-week-scroll> |
| + {{/* Row by row, not column by column: the headers, then ONE all-day |
| + row across the whole week, then the timed grid. When each day |
| + carried its own all-day strip, a day with a birthday pushed its |
| + hour column down and its 10:00 meeting drew beside the 11:00 |
| + label. As rows of one grid, every timed column starts at the |
| + same height whatever sits above it. */}} |
| <div cal-week-grid> |
| + <div cal-column-head> </div> |
| + {{range .Data.Days}} |
| + <div cal-column-head="{{if eq .ISO $.Data.TodayISO}}today{{end}}"> |
| + <a href="/day/{{.ISO}}">{{.Date.Format "Mon 2"}}</a> |
| + {{if eq .ISO $.Data.TodayISO}}<span class="rst-sr-only">(today)</span>{{end}} |
| + </div> |
| + {{end}} |
| + |
| + {{if .Data.AnyAllDay}} |
| + <div cal-allday-label>All day</div> |
| + {{range .Data.Days}} |
| + <div cal-allday="week">{{range .AllDay}}{{template "chip" .}}{{end}}</div> |
| + {{end}} |
| + {{end}} |
| + |
| <div cal-hours> |
| - <div cal-column-head> </div> |
| {{range (index .Data.Days 0).Hours}}<div cal-hour>{{.}}</div>{{end}} |
| </div> |
| - |
| {{range .Data.Days}} |
| - <div> |
| - <div cal-column-head="{{if eq .ISO $.Data.TodayISO}}today{{end}}"> |
| - <a href="/day/{{.ISO}}">{{.Date.Format "Mon 2"}}</a> |
| - {{if eq .ISO $.Data.TodayISO}}<span class="rst-sr-only">(today)</span>{{end}} |
| - </div> |
| - {{if .AllDay}} |
| - <div cal-allday>{{range .AllDay}}{{template "chip" .}}{{end}}</div> |
| + <div cal-column> |
| + <div cal-column-rules aria-hidden="true"></div> |
| + {{range .Slots}} |
| + <a cal-slot href="{{.Href}}" style="top:{{.TopPct}}%;height:{{.HeightPct}}%"> |
| + <span class="rst-sr-only">{{.Label}}</span> |
| + </a> |
| {{end}} |
| - <div cal-column> |
| - <div cal-column-rules aria-hidden="true"></div> |
| - {{range .Slots}} |
| - <a cal-slot href="{{.Href}}" style="top:{{.TopPct}}%;height:{{.HeightPct}}%"> |
| - <span class="rst-sr-only">{{.Label}}</span> |
| - </a> |
| - {{end}} |
| - {{range .Placed}} |
| - <div cal-placed style="top:{{.TopPct}}%;height:{{.HeightPct}}%;left:{{.LeftPct}}%;width:{{.WidthPct}}%"> |
| - {{template "chip" .Chip}} |
| - </div> |
| - {{end}} |
| + {{range .Placed}} |
| + <div cal-placed="{{if .Short}}short{{end}}" style="top:{{.TopPct}}%;height:{{.HeightPct}}%;left:{{.LeftPct}}%;width:{{.WidthPct}}%"> |
| + {{template "chip" .Chip}} |
| </div> |
| + {{end}} |
| </div> |
| {{end}} |
| </div> |
diff --git a/internal/calendar/viewdata.go b/internal/calendar/viewdata.go| index ac0151e..03ca7f3 100644 |
| --- a/internal/calendar/viewdata.go |
| +++ b/internal/calendar/viewdata.go |
| @@ -141,8 +141,18 @@ type PlacedChip struct { |
| HeightPct float64 |
| LeftPct float64 |
| WidthPct float64 |
| + |
| + // Short is a chip under an hour tall. The grid gives an hour 3rem, |
| + // which holds two lines; anything shorter lays its time and title |
| + // on ONE line, or the title — the line that says what the event |
| + // is — is the one clipped away. |
| + Short bool |
| } |
| +// shortChipSeconds is the height below which a placed chip has room |
| +// for one line of text, not two. |
| +const shortChipSeconds = 3600 |
| + |
| // ClickSlot is one empty half-hour, as a link. |
| type ClickSlot struct { |
| Href string |
| @@ -192,6 +202,7 @@ func BuildDay(day time.Time, events []EventView, zone string, now int64, calenda |
| HeightPct: pl.HeightPct, |
| LeftPct: pl.LeftPct, |
| WidthPct: pl.WidthPct, |
| + Short: pl.HeightPct/100*float64(dayEnd-dayStart) < shortChipSeconds, |
| }) |
| } |
diff --git a/internal/calendartest/allday_views_test.go b/internal/calendartest/allday_views_test.go| new file mode 100644 |
| index 0000000..85f21a0 |
| --- /dev/null |
| +++ b/internal/calendartest/allday_views_test.go |
| @@ -0,0 +1,147 @@ |
| +package calendartest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// B8, first half. Each week day used to carry its own all-day strip |
| +// above its hour column, so a day with a birthday started its timed |
| +// grid ~42px lower than the others and its 10:00 meeting drew beside |
| +// the 11:00 label. The all-day strip is now one row of the grid: every |
| +// all-day cell comes before every timed column in the markup, and |
| +// there is one cell per day whether that day has anything or not. |
| +func TestWeekAllDayIsOneRowAboveEveryTimedColumn(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + createEvent(t, h, session, url.Values{ |
| + "title": {"Birthday"}, "all_day": {"1"}, "start": {"2026-10-07"}, "end": {"2026-10-08"}, |
| + "kind": {"note"}, "status": {"confirmed"}, "repeat": {"none"}, |
| + }) |
| + createEvent(t, h, session, url.Values{ |
| + "title": {"Focus time"}, "start": {"2026-10-07T10:00"}, "end": {"2026-10-07T11:00"}, |
| + "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"none"}, |
| + }) |
| + |
| + body := getAs(t, h, session, "/week/2026-10-07").Body.String() |
| + cells := strings.Count(body, `cal-allday="week"`) |
| + if cells != 7 { |
| + t.Fatalf("want one all-day cell per day (7), got %d", cells) |
| + } |
| + lastCell := strings.LastIndex(body, `cal-allday="week"`) |
| + firstColumn := strings.Index(body, "<div cal-column>") |
| + if firstColumn < 0 || lastCell > firstColumn { |
| + t.Fatalf("an all-day cell sits inside the timed grid (last cell at %d, first column at %d)", lastCell, firstColumn) |
| + } |
| + if !strings.Contains(body[:firstColumn], "Birthday") { |
| + t.Error("the all-day event is not in the all-day row") |
| + } |
| + if !strings.Contains(body[firstColumn:], "Focus time") { |
| + t.Error("the timed event is not in the timed grid") |
| + } |
| + |
| + // A week with nothing all-day draws no all-day row at all. |
| + empty := getAs(t, h, session, "/week/2026-11-04").Body.String() |
| + if strings.Contains(empty, `cal-allday`) { |
| + t.Error("a week with no all-day events drew an all-day row") |
| + } |
| +} |
| + |
| +// B8, second half. Under an hour the column has room for one line, |
| +// and a stacked chip put the title on the second — the line the chip's |
| +// overflow cut off. A short chip is marked so time and title share one |
| +// line; an hour or more keeps the stacked layout. |
| +func TestShortEventsKeepTheirTitleLine(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + createEvent(t, h, session, url.Values{ |
| + "title": {"Quick sync"}, "start": {"2026-10-07T14:00"}, "end": {"2026-10-07T14:30"}, |
| + "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, |
| + }) |
| + createEvent(t, h, session, url.Values{ |
| + "title": {"Long review"}, "start": {"2026-10-07T09:00"}, "end": {"2026-10-07T10:00"}, |
| + "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, |
| + }) |
| + placed := regexp.MustCompile(`(?s)<div cal-placed="([a-z]*)"[^>]*>.*?<span cal-chip-title>([^<]*)</span>`) |
| + for _, path := range []string{"/week/2026-10-07", "/day/2026-10-07"} { |
| + body := getAs(t, h, session, path).Body.String() |
| + got := map[string]string{} |
| + for _, m := range placed.FindAllStringSubmatch(body, -1) { |
| + got[strings.TrimSpace(m[2])] = m[1] |
| + } |
| + if v, ok := got["Quick sync"]; !ok || v != "short" { |
| + t.Errorf("%s: a 30-minute chip is not marked short: %q (found %v)", path, v, got) |
| + } |
| + if v, ok := got["Long review"]; !ok || v != "" { |
| + t.Errorf("%s: an hour-long chip is marked %q, want stacked", path, v) |
| + } |
| + } |
| +} |
| + |
| +// B9. An all-day event stores bare dates, and a datetime-local input |
| +// refuses a bare date — the edit form opened with Starts and Ends |
| +// blank and could not be saved without retyping both. The form now |
| +// offers date inputs for an all-day event. |
| +func TestEditingAnAllDayEventShowsItsDates(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + loc := createEvent(t, h, session, url.Values{ |
| + "title": {"Conference"}, "all_day": {"1"}, "start": {"2026-10-07"}, "end": {"2026-10-09"}, |
| + "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"none"}, |
| + }) |
| + body := getAs(t, h, session, loc+"/edit").Body.String() |
| + for name, want := range map[string]string{"start": "2026-10-07", "end": "2026-10-09"} { |
| + input := regexp.MustCompile(`<input[^>]*\bname="` + name + `"[^>]*>`).FindString(body) |
| + if !strings.Contains(input, `type="date"`) || !strings.Contains(input, `value="`+want+`"`) { |
| + t.Errorf("%s input on an all-day edit form: %s\nwant type=date value=%s", name, input, want) |
| + } |
| + } |
| + |
| + // A timed event keeps its datetime inputs. |
| + timed := createEvent(t, h, session, url.Values{ |
| + "title": {"Standup"}, "start": {"2026-10-07T09:30"}, "end": {"2026-10-07T09:45"}, |
| + "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, |
| + }) |
| + body = getAs(t, h, session, timed+"/edit").Body.String() |
| + input := regexp.MustCompile(`<input[^>]*\bname="start"[^>]*>`).FindString(body) |
| + if !strings.Contains(input, `type="datetime-local"`) || !strings.Contains(input, `value="2026-10-07T09:30"`) { |
| + t.Errorf("timed edit form start input: %s", input) |
| + } |
| +} |
| + |
| +// Without script, ticking or unticking All day cannot swap the input |
| +// under it, so each shape of form must accept the other's values: a |
| +// datetime under All day means its date, and a bare date without it |
| +// means midnight. |
| +func TestAllDayToggleWithoutScriptStillSaves(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + |
| + loc := createEvent(t, h, session, url.Values{ |
| + "title": {"Offsite"}, "all_day": {"1"}, "start": {"2026-10-07T09:00"}, "end": {"2026-10-08T00:00"}, |
| + "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"none"}, |
| + }) |
| + body := getAs(t, h, session, loc+"/edit").Body.String() |
| + if in := regexp.MustCompile(`<input[^>]*\bname="start"[^>]*>`).FindString(body); !strings.Contains(in, `value="2026-10-07"`) { |
| + t.Errorf("an all-day event submitted with a datetime did not store its date: %s", in) |
| + } |
| + |
| + cal := regexp.MustCompile(`<option value="(\d+)"[^>]*\bselected\b`).FindStringSubmatch(body) |
| + if cal == nil { |
| + t.Fatal("no calendar selected on the edit form") |
| + } |
| + rec := postAs(t, h, session, loc, url.Values{ |
| + "calendar_id": {cal[1]}, "title": {"Offsite"}, "start": {"2026-10-07"}, "end": {"2026-10-08"}, |
| + "kind": {"commitment"}, "status": {"confirmed"}, "repeat": {"none"}, |
| + }) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("unticking All day on a date form: status %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + body = getAs(t, h, session, loc+"/edit").Body.String() |
| + if in := regexp.MustCompile(`<input[^>]*\bname="start"[^>]*>`).FindString(body); !strings.Contains(in, `value="2026-10-07T00:00"`) { |
| + t.Errorf("a timed save of a bare date did not mean midnight: %s", in) |
| + } |
| +} |
diff --git a/internal/calendartest/playwright_week_test.go b/internal/calendartest/playwright_week_test.go| new file mode 100644 |
| index 0000000..bbaaf04 |
| --- /dev/null |
| +++ b/internal/calendartest/playwright_week_test.go |
| @@ -0,0 +1,103 @@ |
| +//go:build browser |
| + |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "log/slog" |
| + "net" |
| + "net/http" |
| + "net/url" |
| + "os/exec" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// TestWeekAndAllDayFormInARealBrowser is the painted-output half of |
| +// B8 and B9: the Go tests check the markup, this measures what a |
| +// browser draws from it. Column tops, where a 10:00 chip lands against |
| +// the 10:00 rule, whether a 30-minute chip's title is visible, and |
| +// what an all-day edit form's inputs actually hold — a datetime-local |
| +// input given a bare date reports "" to the page, which no markup |
| +// assertion can see. |
| +func TestWeekAndAllDayFormInARealBrowser(t *testing.T) { |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatalf("listen: %v", err) |
| + } |
| + origin := "http://" + ln.Addr().String() |
| + app, err := calendar.New(calendar.Config{ |
| + DBPath: filepath.Join(t.TempDir(), "calendar.db"), |
| + Env: "development", |
| + Origin: origin, |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + }) |
| + if err != nil { |
| + t.Fatalf("calendar.New: %v", err) |
| + } |
| + t.Cleanup(func() { app.Close() }) |
| + srv := &http.Server{Handler: app.Handler()} |
| + go func() { _ = srv.Serve(ln) }() |
| + t.Cleanup(func() { _ = srv.Close() }) |
| + |
| + session := signInOverHTTP(t, app, origin, "someone@example.com") |
| + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + do := func(method, path string, form url.Values) *http.Response { |
| + t.Helper() |
| + var body io.Reader |
| + if form != nil { |
| + body = strings.NewReader(form.Encode()) |
| + } |
| + req, _ := http.NewRequest(method, origin+path, body) |
| + if form != nil { |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", origin) |
| + } |
| + req.AddCookie(session) |
| + res, err := client.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return res |
| + } |
| + res := do("GET", "/events/new", nil) |
| + page, _ := io.ReadAll(res.Body) |
| + res.Body.Close() |
| + m := regexp.MustCompile(`<option value="(\d+)"[^>]*\bselected\b`).FindSubmatch(page) |
| + if m == nil { |
| + t.Fatal("no default calendar on the create form") |
| + } |
| + create := func(v url.Values) string { |
| + t.Helper() |
| + v.Set("calendar_id", string(m[1])) |
| + v.Set("status", "confirmed") |
| + v.Set("repeat", "none") |
| + res := do("POST", "/events", v) |
| + res.Body.Close() |
| + if res.StatusCode != http.StatusSeeOther { |
| + t.Fatalf("create %q: status %d", v.Get("title"), res.StatusCode) |
| + } |
| + return res.Header.Get("Location") |
| + } |
| + create(url.Values{"title": {"Birthday"}, "all_day": {"1"}, "start": {"2026-10-07"}, "end": {"2026-10-08"}, "kind": {"note"}}) |
| + create(url.Values{"title": {"Focus time"}, "start": {"2026-10-07T10:00"}, "end": {"2026-10-07T11:00"}, "kind": {"commitment"}}) |
| + create(url.Values{"title": {"Quick sync"}, "start": {"2026-10-07T14:00"}, "end": {"2026-10-07T14:30"}, "kind": {"meeting"}}) |
| + allDay := create(url.Values{"title": {"Conference"}, "all_day": {"1"}, "start": {"2026-10-12"}, "end": {"2026-10-14"}, "kind": {"commitment"}}) |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, "node", "test/browser/week.mjs", |
| + origin, session.Name, session.Value, allDay+"/edit") |
| + cmd.Dir = repoRootForBrowser(t) |
| + out, err := cmd.CombinedOutput() |
| + t.Logf("node:\n%s", out) |
| + if err != nil { |
| + t.Fatalf("the browser checks failed: %v", err) |
| + } |
| +} |
diff --git a/test/browser/week.mjs b/test/browser/week.mjs| new file mode 100644 |
| index 0000000..35dfef3 |
| --- /dev/null |
| +++ b/test/browser/week.mjs |
| @@ -0,0 +1,83 @@ |
| +// week.mjs — B8 and B9 measured in a real browser. |
| +// |
| +// Driven by TestWeekAndAllDayFormInARealBrowser, which seeds the week |
| +// of 5 October 2026 with an all-day note and two timed events on the |
| +// Wednesday, plus an all-day event whose edit URL it passes in. |
| +// |
| +// 1. Every timed column starts at the same height, whatever sits in |
| +// the all-day row above it. |
| +// 2. The 10:00 chip is drawn at the 10:00 line, not an hour down. |
| +// 3. A 30-minute chip's title is painted inside the chip. |
| +// 4. An all-day event's edit form holds its dates — checked through |
| +// input.value, which is what a submit sends. |
| +import { chromium } from 'playwright'; |
| + |
| +const [origin, cookieName, cookieValue, editPath] = process.argv.slice(2); |
| +if (!origin || !cookieName || !cookieValue || !editPath) { |
| + console.error('usage: week.mjs <origin> <cookieName> <cookieValue> <editPath>'); |
| + process.exit(2); |
| +} |
| + |
| +const failures = []; |
| +function check(ok, what) { |
| + if (ok) { console.log('ok: ' + what); return; } |
| + failures.push(what); |
| + console.error('FAIL: ' + what); |
| +} |
| + |
| +const browser = await chromium.launch(); |
| +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); |
| +await ctx.addCookies([{ name: cookieName, value: cookieValue, url: origin, httpOnly: true }]); |
| +const page = await ctx.newPage(); |
| + |
| +// ── 1–3. The week ───────────────────────────────────────────────────── |
| +await page.goto(origin + '/week/2026-10-07', { waitUntil: 'networkidle' }); |
| +const geo = await page.evaluate(() => { |
| + const cols = [...document.querySelectorAll('[cal-column]')].map((c) => c.getBoundingClientRect().top); |
| + const hourPx = document.querySelector('[cal-hour]').getBoundingClientRect().height; |
| + const chip = (title) => { |
| + const t = [...document.querySelectorAll('[cal-placed] [cal-chip-title]')].find((s) => s.textContent.includes(title)); |
| + if (!t) return null; |
| + const chipBox = t.closest('[cal-chip]').getBoundingClientRect(); |
| + const titleBox = t.getBoundingClientRect(); |
| + const colTop = t.closest('[cal-column]').getBoundingClientRect().top; |
| + return { chipTop: chipBox.top, chipBottom: chipBox.bottom, titleTop: titleBox.top, titleBottom: titleBox.bottom, titleWidth: titleBox.width, colTop }; |
| + }; |
| + // Where the 10:00 LABEL draws its line: the label box minus the |
| + // translate that lifts it onto the rule it names. |
| + const label = document.querySelectorAll('[cal-hour]')[10]; |
| + const lift = new DOMMatrix(getComputedStyle(label).transform).m42; |
| + const tenLine = label.getBoundingClientRect().top - lift; |
| + return { cols, hourPx, tenLine, focus: chip('Focus time'), quick: chip('Quick sync') }; |
| +}); |
| +console.log(JSON.stringify(geo)); |
| +check(geo.cols.length === 7, `seven timed columns (got ${geo.cols.length})`); |
| +check(geo.cols.every((top) => Math.abs(top - geo.cols[0]) < 0.5), `every timed column starts at the same height (${geo.cols.join(', ')})`); |
| +check(geo.focus && Math.abs(geo.focus.chipTop - geo.tenLine) < 2, |
| + `the 10:00 chip is beside the 10:00 label (chip ${geo.focus?.chipTop}, label line ${geo.tenLine})`); |
| +check(geo.quick && geo.quick.titleWidth > 8 && geo.quick.titleBottom - geo.quick.titleTop >= 12 && |
| + geo.quick.titleTop >= geo.quick.chipTop - 0.5 && geo.quick.titleBottom <= geo.quick.chipBottom + 0.5, |
| + `the 30-minute chip paints its title inside itself (${JSON.stringify(geo.quick)})`); |
| +await page.screenshot({ path: '/tmp/calendar-week-allday.png', fullPage: false }); |
| + |
| +// ── 4. The all-day edit form, with and without script ───────────────── |
| +for (const js of [true, false]) { |
| + const c = await browser.newContext({ javaScriptEnabled: js }); |
| + await c.addCookies([{ name: cookieName, value: cookieValue, url: origin, httpOnly: true }]); |
| + const p = await c.newPage(); |
| + await p.goto(origin + editPath, { waitUntil: 'networkidle' }); |
| + const values = await p.evaluate(() => ({ |
| + start: document.querySelector('input[name="start"]').value, |
| + end: document.querySelector('input[name="end"]').value, |
| + })); |
| + check(values.start === '2026-10-12' && values.end === '2026-10-14', |
| + `all-day edit form holds its dates, js=${js} (${JSON.stringify(values)})`); |
| + await c.close(); |
| +} |
| + |
| +await browser.close(); |
| +if (failures.length) { |
| + console.error(`${failures.length} check(s) failed`); |
| + process.exit(1); |
| +} |
| +console.log('all week/all-day checks passed'); |