oficina / calendar Public

Fix week-view all-day offset, short chips, and all-day edit fields

B8: an all-day event pushed its day's column down ~42px, so timed
events sat about an hour below their labels. The week grid now renders
one shared All day row, and chips under an hour put time and title on
one line (day view too).
B9: editing an all-day event opened with blank Starts/Ends because a
date went into a datetime-local input. All-day events get date inputs,
and the server reads either shape under All day.
Known follow-up (review nit): ticking All day on a same-day timed event
still refuses with 'ends on the following day'.
Reviewed by Fable 5.1 on discussion #14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from fix/r1-allday-week-and-edit into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 7a1d2f648a38b2429c63f40d416a0427350bea14 parent cd3478c
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 failed — 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.473s
?   	amadan.net/oficina/calendar/internal/calendar/icons	[no test files]
?   	amadan.net/oficina/calendar/internal/calendar/seal	[no test files]
--- FAIL: TestPublicBookingPostRegistersItsHoldAndMail (0.03s)
    schedule_delivery_test.go:83: public booking did not register scheduled work: got 2026-09-23 09:46:43 +0000 UTC, expected 2026-09-23 11:11:43 +0100 IST
2026/09/23 10:41:43 INFO migrate applied="[sessions/0001_init auth/0001_init auth/0002_sessions_backfill calendar/0001_create_people calendar/0002_alter_people calendar/0003_alter_reminders calendar/0004_create_attendees calendar/0005_alter_people calendar/0006_alter_reminder_queues]" skipped=0 adopted=false
2026/09/23 10:41:43 ERROR calendar: scheduled work registration failed stage=schedule.register err="calendar: this platform does not support safe scheduled work; update the platform before starting Calendar"
FAIL
FAIL	amadan.net/oficina/calendar/internal/calendartest	2.915s
ok  	amadan.net/oficina/calendar/internal/guardstest	0.047s
FAIL
make: *** [Makefile:85: test] Error 1

amadan: step test failed: exit status 2

amadan: job failed at step test — its output is above, not at the tail of this log
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');