oficina / calendar Public

Error pages say what the spec's §3.2 table says

404 "We can’t find that page", 403 "You don’t have access to this page",
500 "Something went wrong", 503 "Calendar is unavailable right now", each
with the table's body and tab title; the way back names the app ("Go to
Calendar"), and a 500 or 503 on a GET offers "Try again" on the same URL.
The branch had "Page not found" as the h1 and rastrillo's "Start page".
A 400 (not in the table) reads as the person's stale link or form.

TestErrorPagesUseTheSuiteCopy fails before this on all five cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 5faf83799e12f835d464a830d5b929fc72e80712 parent f5ca64c
2 files changed, +67 −11
  • internal/calendar/render.go +29 −9
  • internal/calendartest/index_test.go +38 −2

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.759s
?   	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	2.715s
ok  	amadan.net/oficina/calendar/internal/guardstest	0.086s
?   	amadan.net/oficina/calendar/scripts/writeestilo	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/calendar/render.go b/internal/calendar/render.go
index 3d6f7fc..f9b966c 100644
--- a/internal/calendar/render.go
+++ b/internal/calendar/render.go
@@ -188,7 +188,7 @@ func render(w http.ResponseWriter, name string, data any) {
// would.
func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
mode := chrome.Appearance(r)
- errPage := errorData(status, ref)
+ title, errPage := errorData(r, status, ref)
signed, address := false, ""
if id, ok := viewer(r); ok {
signed, address = true, id.Address
@@ -197,23 +197,42 @@ func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
}
w.WriteHeader(status)
render(w, "errors", PageData{
- Title: errPage.Title,
+ Title: title,
ThemeAttr: chrome.ThemeAttr(mode),
Mode: string(mode),
Path: r.URL.Path,
SignedIn: signed,
Address: address,
- Chrome: calendarChrome(r, signed, address, mode, errPage.Title, ""),
+ Chrome: calendarChrome(r, signed, address, mode, title, ""),
Data: errPage,
})
}
-func errorData(status int, ref string) chrome.ErrorPage {
- title, body := "Something went wrong", "Something went wrong on our side. Try again in a moment."
- if status == http.StatusNotFound {
- title, body = "Page not found", "The link may be out of date, or the page may have moved."
+// errorData is the shared-chrome spec's §3.2 table: the tab title, then the
+// page's h1, body and primary way back. A 404 never says whether the thing
+// exists (not shared and deleted read the same). A 500 or 503 on a GET
+// offers the same URL again; any other method goes to Calendar, because
+// re-sending a POST from a link is not possible and not wanted.
+func errorData(r *http.Request, status int, ref string) (string, chrome.ErrorPage) {
+ home := chrome.Link{Href: "/", Label: "Go to Calendar"}
+ again := home
+ if r.Method == http.MethodGet {
+ again = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
}
- return chrome.ErrorPage{Status: status, Title: title, Body: body, Home: chrome.Link{Href: "/", Label: "Start page"}, Ref: ref, RefLabel: "Reference:"}
+ title, h1, body, cta := "Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below.", again
+ switch status {
+ case http.StatusNotFound:
+ title, h1, body, cta = "Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved.", home
+ case http.StatusForbidden:
+ title, h1, body, cta = "No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should.", home
+ case http.StatusServiceUnavailable:
+ title, h1, body, cta = "Calendar is unavailable", "Calendar is unavailable right now", "Try again in a few minutes.", again
+ case http.StatusBadRequest:
+ // Not in the table: a malformed request is the person's link or
+ // form, not our failure, so it reads as the 404's way back.
+ title, h1, body, cta = "Something went wrong", "That request didn’t work", "The link or form may be out of date. Go back and try again.", home
+ }
+ return title, chrome.ErrorPage{Status: status, Title: h1, Body: body, Home: cta, Ref: ref, RefLabel: "Reference:"}
}
// notFound has an HTML page only for navigation. API/feed clients retain the
@@ -225,7 +244,8 @@ func (h *handlers) notFound(w http.ResponseWriter, r *http.Request) {
return
}
w.WriteHeader(http.StatusNotFound)
- render(w, "errors", h.page(r, "Page not found", errorData(http.StatusNotFound, "")))
+ title, page := errorData(r, http.StatusNotFound, "")
+ render(w, "errors", h.page(r, title, page))
}
func containsHTML(accept string) bool {
diff --git a/internal/calendartest/index_test.go b/internal/calendartest/index_test.go
index 9385699..44c822a 100644
--- a/internal/calendartest/index_test.go
+++ b/internal/calendartest/index_test.go
@@ -67,10 +67,46 @@ func TestErrorPageRendersFrameworkPartial(t *testing.T) {
t.Fatalf("ErrorPage: status %d, want %d", rec.Code, http.StatusInternalServerError)
}
body := rec.Body.String()
- if !strings.Contains(body, "Something went wrong on our side") {
- t.Errorf("ErrorPage body missing the 500 title:\n%s", body)
+ if !strings.Contains(body, "If it keeps happening, quote the reference below.") {
+ t.Errorf("ErrorPage body missing the 500 body (spec §3.2):\n%s", body)
}
if !strings.Contains(body, "abc123") {
t.Errorf("ErrorPage body missing the ref:\n%s", body)
}
}
+
+// The error pages say what the shared-chrome spec's §3.2 table says (estilo
+// docs/superpowers/specs/2026-09-23-shared-chrome.md): the tab title, the
+// h1, and a way back that names the app. A 500 or 503 on a GET offers the
+// same URL again; anything else goes to Calendar.
+func TestErrorPagesUseTheSuiteCopy(t *testing.T) {
+ for _, c := range []struct {
+ status int
+ method string
+ title, h1, cta string
+ ctaHref string
+ }{
+ {http.StatusNotFound, http.MethodGet, "Page not found · Calendar", "We can’t find that page", "Go to Calendar", "/"},
+ {http.StatusForbidden, http.MethodGet, "No access · Calendar", "You don’t have access to this page", "Go to Calendar", "/"},
+ {http.StatusInternalServerError, http.MethodGet, "Something went wrong · Calendar", "Something went wrong", "Try again", "/week/2026-09-24?x=1"},
+ {http.StatusInternalServerError, http.MethodPost, "Something went wrong · Calendar", "Something went wrong", "Go to Calendar", "/"},
+ {http.StatusServiceUnavailable, http.MethodGet, "Calendar is unavailable · Calendar", "Calendar is unavailable right now", "Try again", "/week/2026-09-24?x=1"},
+ } {
+ rec := httptest.NewRecorder()
+ calendar.ErrorPage(rec, httptest.NewRequest(c.method, "/week/2026-09-24?x=1", nil), c.status, "")
+ body := rec.Body.String()
+ if rec.Code != c.status {
+ t.Errorf("%d %s: status %d", c.status, c.method, rec.Code)
+ }
+ if !strings.Contains(body, "<title>"+c.title+"</title>") {
+ t.Errorf("%d %s: no <title>%s</title>", c.status, c.method, c.title)
+ }
+ if !regexp.MustCompile(`<h1 rst-error-title>\s*` + regexp.QuoteMeta(c.h1) + `\s*</h1>`).MatchString(body) {
+ t.Errorf("%d %s: no h1 %q", c.status, c.method, c.h1)
+ }
+ cta := regexp.MustCompile(`<a rst-btn="primary" href="([^"]*)">([^<]*)</a>`).FindStringSubmatch(body)
+ if cta == nil || cta[2] != c.cta || strings.ReplaceAll(cta[1], "&amp;", "&") != c.ctaHref {
+ t.Errorf("%d %s: primary way back = %q, want %q → %s", c.status, c.method, cta, c.cta, c.ctaHref)
+ }
+ }
+}