oficina / calendar Public

Polish Calendar views and shared error pages

Paul Campbell pushed by paul@keymail.dev 4e008a0089012ae8ebd6c6394fa7469f21f3ff96 parent 16c5197
7 files changed, +50 −14
  • internal/calendar/app.go +6 −1
  • internal/calendar/handlers.go +4 −1
  • internal/calendar/handlers_events.go +1 −0
  • internal/calendar/render.go +36 −7
  • internal/calendar/templates/errors.html +1 −1
  • internal/calendar/templates/event.html +1 −3
  • internal/calendar/templates/layout.html +1 −1

CI failed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/calendar/cmd/calendar	[no test files]
--- FAIL: TestHomeRouterRetainsExistingCalendarIdentity (0.02s)
    home_router_test.go:148: redirect status 307: 
FAIL
FAIL	amadan.net/oficina/calendar/internal/calendar	4.538s
?   	amadan.net/oficina/calendar/internal/calendar/icons	[no test files]
?   	amadan.net/oficina/calendar/internal/calendar/seal	[no test files]
--- FAIL: TestABookedEventsFormLocksItsTimeButSavesItsTitle (0.02s)
    booking_settings_test.go:150: reschedule: changed=false err=calendar: slot taken
2026/09/24 14:33:11 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/24 14:33:11 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.196s
--- FAIL: TestNoFileExceedsTheLineCap (0.00s)
    guards_test.go:91: internal/calendar/render.go is 306 lines, cap is 300 — split it
--- FAIL: TestOurCSSDoesNotClaimAnotherPrefix (0.00s)
    guards_test.go:226: calendar.css defines a selector for another namespace: "[rst-" — this app's prefix is cal-
FAIL
FAIL	amadan.net/oficina/calendar/internal/guardstest	0.044s
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/app.go b/internal/calendar/app.go
index 9a5d607..b006a91 100644
--- a/internal/calendar/app.go
+++ b/internal/calendar/app.go
@@ -154,7 +154,7 @@ func New(cfg Config) (*App, error) {
mailRecorder: recorder,
auth: authPlugin,
}
- h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer, gate: newGate()}
+ h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer, gate: newGate(), viewerFrom: authPlugin.SessionFrom}
a.h = h
if homeSettings != nil {
a.home, err = a.newHome(homeSettings, list)
@@ -163,6 +163,11 @@ func New(cfg Config) (*App, error) {
return nil, err
}
h.homeEnabled = true
+ h.directory = homeSettings.Directory
+ h.viewerFrom = func(r *http.Request) (rauth.Identity, bool) {
+ p, err := a.homePerson(r)
+ return rauth.Identity{Address: p.Address}, err == nil
+ }
}
// Native wiring comes before the scheduler starts, so a native
// failure leaves no timer goroutine behind to leak.
diff --git a/internal/calendar/handlers.go b/internal/calendar/handlers.go
index 8ff2dd6..c08fd34 100644
--- a/internal/calendar/handlers.go
+++ b/internal/calendar/handlers.go
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
+ rauth "github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/mail"
)
@@ -12,6 +13,7 @@ import (
type handlers struct {
scheduled *scheduledWork
homeEnabled bool
+ directory string
store *Store
logger *slog.Logger
@@ -26,7 +28,8 @@ type handlers struct {
// gate is the per-IP token bucket every public booking route
// passes through. One per app; memory only.
- gate *gate
+ gate *gate
+ viewerFrom func(*http.Request) (rauth.Identity, bool)
}
// secure reports whether cookies this app sets should carry Secure. It
diff --git a/internal/calendar/handlers_events.go b/internal/calendar/handlers_events.go
index a924d5b..4699e90 100644
--- a/internal/calendar/handlers_events.go
+++ b/internal/calendar/handlers_events.go
@@ -151,6 +151,7 @@ func (h *handlers) showEvent(w http.ResponseWriter, r *http.Request) {
"Attendees": attendees,
"Zone": p.Zone,
"RuleText": DescribeRule(e.RRule),
+ "When": spanWords(e.StartUTC, e.EndUTC, p.Zone),
}
render(w, "event", h.page(r, e.Title, data))
}
diff --git a/internal/calendar/render.go b/internal/calendar/render.go
index 75895b1..ba2b566 100644
--- a/internal/calendar/render.go
+++ b/internal/calendar/render.go
@@ -14,6 +14,8 @@ import (
"amadan.net/oficina/estilo.git/chrome"
estilomenu "amadan.net/oficina/estilo.git/menu"
"github.com/carlosframework/rastrillo"
+ rauth "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/sessions"
"github.com/carlosframework/rastrillo/ui"
"amadan.net/oficina/calendar/internal/calendar/icons"
@@ -177,18 +179,25 @@ func (h *handlers) page(r *http.Request, title string, data any) PageData {
QuickPane: emptyPane(),
Data: data,
}
- if id, ok := viewer(r); ok {
+ if id, ok := h.viewer(r); ok {
pd.SignedIn = true
pd.Address = id.Address
}
- pd.Chrome = calendarChrome(r, pd.SignedIn, pd.Address, mode, title)
+ pd.Chrome = calendarChrome(r, pd.SignedIn, pd.Address, mode, title, h.directory)
if r.URL.Path == "/signin" || r.URL.Path == "/signin/local" || r.URL.Path == "/signed-out" {
pd.Chrome.Account, pd.Chrome.SignIn = nil, nil
}
return pd
}
-func calendarChrome(r *http.Request, signedIn bool, address string, mode chrome.Mode, title string) chrome.Bar {
+func (h *handlers) viewer(r *http.Request) (rauth.Identity, bool) {
+ if h.viewerFrom != nil {
+ return h.viewerFrom(r)
+ }
+ return viewer(r)
+}
+
+func calendarChrome(r *http.Request, signedIn bool, address string, mode chrome.Mode, title, directory string) chrome.Bar {
current := func(prefix string) bool {
return r.URL.Path == prefix || (prefix != "/" && len(r.URL.Path) > len(prefix) && r.URL.Path[:len(prefix)] == prefix)
}
@@ -207,6 +216,9 @@ func calendarChrome(r *http.Request, signedIn bool, address string, mode chrome.
bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + template.URLQueryEscaper(r.URL.RequestURI()), Label: "Sign in"}
return bar
}
+ if directory != "" {
+ bar.Switcher = &chrome.Switcher{Label: "Oficina apps", AllApps: estilomenu.Item{Href: directory, Label: "All apps"}}
+ }
bar.Account = &chrome.Account{
TriggerLabel: "Account: " + address, Initials: chrome.Initials("", address), Address: address,
Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"},
@@ -243,17 +255,34 @@ 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)
+ signed, address := false, ""
+ if id, ok := viewer(r); ok {
+ signed, address = true, id.Address
+ } else if _, ok := sessions.Current(r); ok {
+ signed = true
+ }
w.WriteHeader(status)
render(w, "errors", PageData{
- Title: "Something went wrong",
+ Title: errPage.Title,
ThemeAttr: chrome.ThemeAttr(mode),
Mode: string(mode),
Path: r.URL.Path,
- Chrome: calendarChrome(r, false, "", mode, "Something went wrong"),
- Data: map[string]any{"Status": status, "Ref": ref},
+ SignedIn: signed,
+ Address: address,
+ Chrome: calendarChrome(r, signed, address, mode, errPage.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."
+ }
+ return chrome.ErrorPage{Status: status, Title: title, Body: body, Home: chrome.Link{Href: "/", Label: "Start page"}, Ref: ref, RefLabel: "Reference:"}
+}
+
// notFound has an HTML page only for navigation. API/feed clients retain the
// ordinary compact 404, which is both the useful representation and prevents
// an HTML error page from being mistaken for a successful feed response.
@@ -263,7 +292,7 @@ func (h *handlers) notFound(w http.ResponseWriter, r *http.Request) {
return
}
w.WriteHeader(http.StatusNotFound)
- render(w, "errors", h.page(r, "Page not found", map[string]any{"Status": http.StatusNotFound}))
+ render(w, "errors", h.page(r, "Page not found", errorData(http.StatusNotFound, "")))
}
func containsHTML(accept string) bool {
diff --git a/internal/calendar/templates/errors.html b/internal/calendar/templates/errors.html
index 7fa0354..b83354b 100644
--- a/internal/calendar/templates/errors.html
+++ b/internal/calendar/templates/errors.html
@@ -7,4 +7,4 @@
override the partial's own catalog defaults; see
ui/partials/error-page.html for what it accepts. */}}
{{define "title"}}{{.Title}}{{end}}
-{{define "content"}}{{template "error-page" dict "Status" .Data.Status "Ref" .Data.Ref}}{{end}}
+{{define "content"}}{{template "estilo-error" .Data}}{{end}}
diff --git a/internal/calendar/templates/event.html b/internal/calendar/templates/event.html
index 00bb875..cfdde13 100644
--- a/internal/calendar/templates/event.html
+++ b/internal/calendar/templates/event.html
@@ -26,9 +26,7 @@
{{end}}
{{$items := list
- (dict "Label" "When" "Value" .Data.Chip.TimeLabel)
- (dict "Label" "Starts" "Value" .Data.Event.StartLocal)
- (dict "Label" "Ends" "Value" .Data.Event.EndLocal)}}
+ (dict "Label" "When" "Value" .Data.When)}}
{{template "detail-list" dict "Items" $items}}
{{/* The authored zone is a whisper, never the headline: the time above
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html
index f30d90b..9dac19d 100644
--- a/internal/calendar/templates/layout.html
+++ b/internal/calendar/templates/layout.html
@@ -1,5 +1,5 @@
{{define "layout"}}<!doctype html>
-<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"{{if .ThemeAttr}} data-theme="{{.ThemeAttr}}"{{end}}>
+<html lang="{{block "lang" .}}en-GB{{end}}" dir="{{block "dir" .}}ltr{{end}}"{{if .ThemeAttr}} data-theme="{{.ThemeAttr}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">