oficina / calendar Public

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
Paul Campbell pushed by paul@keymail.dev c231314b3e0ed160e762870d97a39dd35649eeb9 parent b99d284parent 7a1d2f6
10 files changed, +457 −33
  • internal/calendar/handlers_event_form.go +21 −2
  • internal/calendar/handlers_views.go +9 −6
  • internal/calendar/static/calendar.css +33 −0
  • internal/calendar/templates/day.html +1 −1
  • internal/calendar/templates/event_form.html +18 −3
  • internal/calendar/templates/week.html +31 −21
  • internal/calendar/viewdata.go +11 −0
  • internal/calendartest/allday_views_test.go +147 −0
  • internal/calendartest/playwright_week_test.go +103 −0
  • test/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>&nbsp;</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>&nbsp;</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');