abono: status dates the app can format; failing without a date
Summary
The status partial takes an optional DateText, the date as the app wrote it, and wraps the date in a time element either way. A failing status with no known date now has its own key, abono.status.failing_nodate, with no placeholder.
Agent notes
Status.Sentence builds the pill's markup from the catalog text with escaping, so the time element can sit inside the sentence. Core's locale-aware date helper is requested in rastrillo discussion 112; the template comment says so. The agreed key set is now 23.
5 files changed,
+95
−31
describe.go+37 −10describe_test.go+1 −1templates.go+1 −1templates/abono-status.html+17 −12templates_test.go+39 −7
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/abono 0.682s ok amadan.net/rastrillo/abono/abonotest 0.057s ok amadan.net/rastrillo/abono/internal/fakestripe 0.021s ok amadan.net/rastrillo/abono/internal/stripeapi 0.007s === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/abono 6.979s ok amadan.net/rastrillo/abono/abonotest 1.645s ok amadan.net/rastrillo/abono/internal/fakestripe 1.130s ok amadan.net/rastrillo/abono/internal/stripeapi 1.071s === browser === browser: no browser test file yet, nothing to run
Intent
diff --git a/describe.go b/describe.go| index 4cefb1d..67354c4 100644 |
| --- a/describe.go |
| +++ b/describe.go |
| @@ -1,6 +1,10 @@ |
| package abono |
| -import "time" |
| +import ( |
| + "html/template" |
| + "strings" |
| + "time" |
| +) |
| // The keys of a Status, for the app to translate. Tones are status-pill's. |
| const ( |
| @@ -8,9 +12,12 @@ const ( |
| StatusRenews = "abono.status.renews" |
| StatusCancelling = "abono.status.cancelling" |
| StatusFailing = "abono.status.failing" |
| - StatusEnded = "abono.status.ended" |
| - StatusComp = "abono.status.comp" |
| - StatusCompUntil = "abono.status.comp_until" |
| + // StatusFailingNoDate is failing when the day access stops is unknown; |
| + // its sentence has no {date}. |
| + StatusFailingNoDate = "abono.status.failing_nodate" |
| + StatusEnded = "abono.status.ended" |
| + StatusComp = "abono.status.comp" |
| + StatusCompUntil = "abono.status.comp_until" |
| ) |
| // Status is the one line a holding reads as: a message key, a tone, and |
| @@ -29,7 +36,8 @@ type Status struct { |
| // renews active or trialing, with a period end, not cancelling: Date is the period end |
| // cancelling cancel_at_period_end and granting: Date is the period end |
| // failing past_due and granting: Date is the day access stops, 30 days after the |
| -// unpaid period started (no date when the start is unknown) |
| +// unpaid period started |
| +// failing_nodate the same, when the start is unknown so there is no date |
| // ended does not grant |
| // comp a comp with no end |
| // comp_until a comp with an end: Date is ends_at |
| @@ -50,12 +58,11 @@ func Describe(h Holding, now time.Time) Status { |
| } |
| switch { |
| case h.Status == "past_due": |
| - var stops *time.Time |
| - if h.PeriodStart != nil { |
| - t := h.PeriodStart.Add(backstopWindow) |
| - stops = &t |
| + if h.PeriodStart == nil { |
| + return Status{Key: StatusFailingNoDate, Tone: "warning"} |
| } |
| - return Status{Key: StatusFailing, Tone: "warning", Date: stops} |
| + stops := h.PeriodStart.Add(backstopWindow) |
| + return Status{Key: StatusFailing, Tone: "warning", Date: &stops} |
| case h.CancelAtPeriodEnd: |
| return Status{Key: StatusCancelling, Tone: "warning", Date: copyTime(h.Until)} |
| case h.Until != nil: |
| @@ -75,3 +82,23 @@ func copyTime(t *time.Time) *time.Time { |
| // Describe is Describe(h, now), as a method so a template can call it: |
| // {{.Holding.Describe .Now}}. |
| func (h Holding) Describe(now time.Time) Status { return Describe(h, now) } |
| + |
| +// Sentence is the status's words as markup, for a template: text is the |
| +// key's catalog text with its {date} still in it, and dateText is what to |
| +// show for the date (empty shows the date as year-month-day). The date |
| +// goes in a <time datetime> element, so it stays machine-readable |
| +// whatever words the app gave it. Both strings are escaped; a status |
| +// with no date, or text with no {date}, is just the text. |
| +func (s Status) Sentence(text, dateText string) template.HTML { |
| + before, after, found := strings.Cut(text, "{date}") |
| + if s.Date == nil || !found { |
| + return template.HTML(template.HTMLEscapeString(text)) |
| + } |
| + iso := s.Date.Format("2006-01-02") |
| + if dateText == "" { |
| + dateText = iso |
| + } |
| + return template.HTML(template.HTMLEscapeString(before) + |
| + `<time datetime="` + iso + `">` + template.HTMLEscapeString(dateText) + `</time>` + |
| + template.HTMLEscapeString(after)) |
| +} |
diff --git a/describe_test.go b/describe_test.go| index 21e6077..18e0afd 100644 |
| --- a/describe_test.go |
| +++ b/describe_test.go |
| @@ -45,7 +45,7 @@ func TestDescribe(t *testing.T) { |
| "abono.status.failing", "warning", ptr(past.Add(30 * day))}, |
| {"failing with no period start has no date", |
| abono.Holding{Source: abono.SourceSubscription, Status: "past_due", Until: &end}, |
| - "abono.status.failing", "warning", nil}, |
| + "abono.status.failing_nodate", "warning", nil}, |
| {"failing one second before start+30d", |
| abono.Holding{Source: abono.SourceSubscription, Status: "past_due", PeriodStart: ptr(now.Add(-30*day + time.Second)), Until: ptr(now.AddDate(1, 0, 0))}, |
| "abono.status.failing", "warning", ptr(now.Add(time.Second))}, |
diff --git a/templates.go b/templates.go| index 39d89eb..705d495 100644 |
| --- a/templates.go |
| +++ b/templates.go |
| @@ -17,7 +17,7 @@ var templatesFS embed.FS |
| // |
| // The partials: |
| // |
| -// abono-status {Holding, Now}: the holding as one status pill |
| +// abono-status {Holding, Now, DateText}: the holding as one status pill |
| // abono-plan-card {Offer, Interval, Action, Code}: one plan at one interval |
| // abono-plan-picker {Offers, Action}: the plans on sale, by interval |
| // abono-paywall {Offers, SignedIn, SigninURL, Action, PlansURL} |
diff --git a/templates/abono-status.html b/templates/abono-status.html| index f9ffc3b..544b6b7 100644 |
| --- a/templates/abono-status.html |
| +++ b/templates/abono-status.html |
| @@ -1,18 +1,23 @@ |
| {{/* abono-status — a holding as one line: a status pill, never colour alone. |
| The words come from Describe: its message key is translated, and when |
| - it has a date the key's {date} is filled with it. The tone is |
| - Describe's, so the pill's colour and its words always agree. |
| + it has a date the key's {date} is filled with it, inside a |
| + <time datetime="2026-11-03"> so the date stays machine-readable. The |
| + tone is Describe's, so the pill's colour and its words always agree. |
| + A failing status with no known date has its own key, with no {date}. |
| - The date is written year-month-day (2026-11-03), which reads the same |
| - in every language. A date in the reader's own words needs a date |
| - helper in core, which has none yet. |
| + The date reads year-month-day (2026-11-03) unless the app passes |
| + DateText, the date already written for this reader ("3 November |
| + 2026"), as core's own partials take dates. A helper in core that |
| + formats a date in the reader's locale is requested (rastrillo |
| + discussion 112); until it lands, the app formats it. |
| Keys: |
| - Holding abono.Holding, required — what to describe |
| - Now time.Time, required — the moment to describe it at; the |
| - same Now the access check uses, so the line cannot say |
| - "active" of access that has ended |
| - L *ui.Localizer — the request's, found by {{localizer $}}; |
| - add it with withL when calling from another partial */}} |
| -{{define "abono-status"}}{{$L := localizer $}}{{$s := .Holding.Describe .Now}}{{$label := ""}}{{if $s.Date}}{{$label = Tf $L $s.Key "date" ($s.Date.Format "2006-01-02")}}{{else}}{{$label = T $L $s.Key}}{{end}}{{template "status-pill" dict "Tone" $s.Tone "Label" $label}}{{end}} |
| + Holding abono.Holding, required — what to describe |
| + Now time.Time, required — the moment to describe it at; the |
| + same Now the access check uses, so the line cannot say |
| + "active" of access that has ended |
| + DateText string, optional — the date as shown; empty shows year-month-day |
| + L *ui.Localizer — the request's, found by {{localizer $}}; |
| + add it with withL when calling from another partial */}} |
| +{{define "abono-status"}}{{$L := localizer $}}{{$s := .Holding.Describe .Now}}{{template "status-pill" dict "Tone" $s.Tone "Label" ($s.Sentence (T $L $s.Key) (or (opt . "DateText") ""))}}{{end}} |
diff --git a/templates_test.go b/templates_test.go| index 69ea082..5d8f0f0 100644 |
| --- a/templates_test.go |
| +++ b/templates_test.go |
| @@ -25,6 +25,7 @@ var testCatalog = rastrillo.Catalog{ |
| "abono.status.renews": "T-RENEWS {date}", |
| "abono.status.cancelling": "T-CANCELLING {date}", |
| "abono.status.failing": "T-FAILING {date}", |
| + "abono.status.failing_nodate": "T-FAILING-NODATE", |
| "abono.status.ended": "T-ENDED", |
| "abono.status.comp": "T-COMP", |
| "abono.status.comp_until": "T-COMPUNTIL {date}", |
| @@ -182,12 +183,14 @@ func holdingCases() []holdingCase { |
| failing := sub("past_due", false) |
| failing.PeriodStart = &started |
| active := abono.Holding{Plan: "supporter", Source: abono.SourceSubscription, Status: "active", Grants: true} |
| + noStart := sub("past_due", false) |
| compUntil := abono.Holding{Plan: "supporter", Source: abono.SourceComp, Status: "active", Until: &soon, Grants: true} |
| return []holdingCase{ |
| {"active", active, abono.StatusActive, "positive", "T-ACTIVE", false}, |
| {"renews", sub("active", false), abono.StatusRenews, "positive", "T-RENEWS", true}, |
| {"cancelling", sub("active", true), abono.StatusCancelling, "warning", "T-CANCELLING", true}, |
| {"failing", failing, abono.StatusFailing, "warning", "T-FAILING", true}, |
| + {"failing_nodate", noStart, abono.StatusFailingNoDate, "warning", "T-FAILING-NODATE", false}, |
| {"ended", sub("canceled", false), abono.StatusEnded, "neutral", "T-ENDED", false}, |
| {"comp", abono.Holding{Plan: "supporter", Source: abono.SourceComp, Status: "active", Grants: true}, abono.StatusComp, "positive", "T-COMP", false}, |
| {"comp_until", compUntil, abono.StatusCompUntil, "positive", "T-COMPUNTIL", true}, |
| @@ -217,9 +220,9 @@ func TestStatusRendersEveryKey(t *testing.T) { |
| t.Fatalf("case says hasDate=%v, Describe gave %v", c.hasDate, s.Date) |
| } |
| if c.hasDate { |
| - want := s.Date.Format("2006-01-02") |
| + want := `<time datetime="` + s.Date.Format("2006-01-02") + `">` + s.Date.Format("2006-01-02") + `</time>` |
| if !strings.Contains(got, want) { |
| - t.Errorf("status lacks the date %q:\n%s", want, got) |
| + t.Errorf("status lacks %s:\n%s", want, got) |
| } |
| if strings.Contains(got, "{date}") { |
| t.Errorf("status left the {date} placeholder:\n%s", got) |
| @@ -227,14 +230,43 @@ func TestStatusRendersEveryKey(t *testing.T) { |
| } |
| }) |
| } |
| - for _, key := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} { |
| + for _, key := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusFailingNoDate, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} { |
| if !seen[key] { |
| t.Errorf("no case renders %s", key) |
| } |
| } |
| } |
| -var pillText = regexp.MustCompile(`^<span rst-status rst-tone="([a-z]+)">([^<]+)</span>$`) |
| +var ( |
| + pillText = regexp.MustCompile(`^<span rst-status rst-tone="([a-z]+)">(.+)</span>$`) |
| + anyTag = regexp.MustCompile(`<[^>]*>`) |
| +) |
| + |
| +// An app that formats dates itself passes DateText, and it is what the |
| +// reader sees; the machine-readable date stays in the time element. The |
| +// catalog's own text and the app's are escaped, never trusted as markup. |
| +func TestStatusShowsTheAppsDateText(t *testing.T) { |
| + cat := rastrillo.Catalog{} |
| + for k, v := range testCatalog { |
| + cat[k] = v |
| + } |
| + cat["abono.status.renews"] = "Back & <i>soon</i>: {date}!" |
| + l := testLocalizer(t, cat) |
| + h := holdingCases()[1].h |
| + got := render(t, "abono-status", map[string]any{"L": l, "Holding": h, "Now": nowAt, "DateText": "3 <November>"}) |
| + want := `Back & <i>soon</i>: <time datetime="2026-11-03">3 <November></time>!` |
| + if !strings.Contains(got, want) { |
| + t.Errorf("want %s in:\n%s", want, got) |
| + } |
| + if strings.Contains(got, ">2026-11-03<") { |
| + t.Errorf("ISO date shown beside the app's text:\n%s", got) |
| + } |
| + // Without DateText, the ISO date is the text. |
| + got = render(t, "abono-status", map[string]any{"L": l, "Holding": h, "Now": nowAt}) |
| + if want := `<time datetime="2026-11-03">2026-11-03</time>!`; !strings.Contains(got, want) { |
| + t.Errorf("want %s in:\n%s", want, got) |
| + } |
| +} |
| // A status is a word first. Every tone's pill carries visible text, so the |
| // colour is never the only thing that says it. |
| @@ -252,7 +284,7 @@ func TestStatusIsNeverColourAlone(t *testing.T) { |
| if m[1] != c.tone { |
| t.Errorf("%s: tone %q, want %q", c.name, m[1], c.tone) |
| } |
| - if strings.TrimSpace(m[2]) == "" { |
| + if strings.TrimSpace(anyTag.ReplaceAllString(m[2], "")) == "" { |
| t.Errorf("%s: pill has no label", c.name) |
| } |
| } |
| @@ -441,7 +473,7 @@ func partialKeys(t *testing.T) map[string]bool { |
| // The keys of the agreed copy. A key outside this list is copy nobody |
| // reviewed, so a partial that reaches for one fails here. |
| var agreedKeys = []string{ |
| - "abono.status.active", "abono.status.renews", "abono.status.cancelling", "abono.status.failing", |
| + "abono.status.active", "abono.status.renews", "abono.status.cancelling", "abono.status.failing", "abono.status.failing_nodate", |
| "abono.status.ended", "abono.status.comp", "abono.status.comp_until", |
| "abono.price.per_month", "abono.price.per_year", "abono.price.monthly", "abono.price.yearly", "abono.price.switch_label", |
| "abono.plan.interval_unavailable", "abono.plan.code_label", "abono.plan.code_hint", |
| @@ -477,7 +509,7 @@ func TestCatalogKeysAreUsedAndDefined(t *testing.T) { |
| t.Skip("Task 8b: approved copy pending") |
| } |
| used := partialKeys(t) |
| - for _, k := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} { |
| + for _, k := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusFailingNoDate, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} { |
| used[k] = true |
| } |
| re := regexp.MustCompile(`"(abono\.[a-z_.]+)"`) |