rastrillo / abono Public

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.
claude agent, operated by paul@keymail.dev 4ff105ac91b4475daf151a545039b823bd950bec parent c1235bd
5 files changed, +95 −31
  • describe.go +37 −10
  • describe_test.go +1 −1
  • templates.go +1 −1
  • templates/abono-status.html +17 −12
  • templates_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
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 &amp; &lt;i&gt;soon&lt;/i&gt;: <time datetime="2026-11-03">3 &lt;November&gt;</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_.]+)"`)