Reference
App and Config
What the generated file builds and what you can adjust in setup.go.
Config#
type Config struct {
Addr string // ":3000"; PORT/ADDR in the environment
Env Env // Dev | Prod; TRILHA_ENV
MaxBodyBytes int64 // 1 MiB
MaxFormMemory int64 // 32 MiB kept in memory by multipart parsing
Logger *slog.Logger // slog.Default()
Public fs.FS // static files; nil turns them off
Mounts map[string]fs.FS // static trees by URL prefix, before Public
Upstreams map[string]Upstream // URL prefix → an API that already exists
CSRFForAPI bool // require CSRF in route.go too
CSRF CSRF // cookie, field and header names of the token
BasePath string // URL prefix; TRILHA_BASE_PATH
Security Security // headers (see Security)
TrustedProxies []string // CIDRs; TRILHA_TRUSTED_PROXIES
RateLimit RateLimit // global per-client limit
Secret, PreviousSecret []byte // TRILHA_SECRET, TRILHA_SECRET_PREVIOUS (or their _FILE)
Timeouts Timeouts // http.Server limits (trilha.NoTimeout disables one)
StaticCacheControl string // Cache-Control of static files in prod ("public, max-age=3600")
Compression Compression // gzip of text responses (zero value = on)
StaticHeaders func(name string, hdr http.Header) // headers per static file
LogRequest func(c *Ctx, status int, dur time.Duration) bool // nil logs every request
OnSecurityEvent func(SecurityEvent)
OnRequest RequestHook // wraps every request that reached a route (tracing)
DevReload string // trilha.Off disables the reload script in dev; TRILHA_DEV_RELOAD=off
Observability Observability // health probes and the metrics endpoint
CORS CORS // origins allowed to call the app (zero value = off)
Locale string // the one language of the process: "en" (zero value) or "pt-BR"
Locales []string // the languages of the app, the first being the default
LocaleOf func(c *Ctx) string // the preference the app stored (auth: the session)
Catalog *Catalog // the app's own messages, read by c.T
Idempotency IdempotencyStore // keys of the submissions already handled (nil = in the process)
}trilha.ConfigFromEnv() reads the variables; trilha.PublicFS(embedded, "public") picks between the embedded copy (prod) and the folder on disk (dev).
Where to configure#
The generated file does cfg := trilha.ConfigFromEnv(), calls app.Config(&cfg) if app/setup.go exports func Config(cfg *trilha.Config), and then trilha.New(cfg) and app.Setup(a). Config may also be written as func Config(cfg *trilha.Config) error, and then the generated file stops the boot with your message — reading the app's own configuration is what fails on startup, and it should fail where it happens. You may change any field in either one; the only difference is when the value is read:
| Fields | Read at | Config | Setup (via a.Config()) |
|---|---|---|---|
Security, Public, MaxBodyBytes, MaxFormMemory, CSRFForAPI, BasePath, OnSecurityEvent, StaticCacheControl, StaticHeaders, Compression, OnRequest | every request | ✓ | ✓ |
Logger, Secret/PreviousSecret, RateLimit, TrustedProxies, CORS, Upstreams | derived in New and reapplied when serving starts (ListenAndServe, Handler, Export) | ✓ | ✓ |
Addr, Timeouts | ListenAndServe | ✓ | ✓ |
Env | New (ephemeral key in dev) and per request | ✓ | partial |
Use Config when you want to build the configuration from your own package (file, Vault, flags) instead of the environment.
Many languages#
Locale is one language for the whole process. Locales is a list, and with a list the language becomes a property of the request:
func Setup(a *trilha.App) error {
cfg := a.Config()
cfg.Locales = []string{"pt-BR", "ht", "fr"} // the first one is the default
cfg.LocaleOf = sessao.Flow.LocaleOf // what the person picked, from the session
return nil
}c.Locale() then negotiates once per request, and the first supported answer wins:
| Order | Source | Where it comes from |
|---|---|---|
| 1 | cfg.LocaleOf(c) | the preference the app stored — auth.User.Locale through Auth.LocaleOf |
| 2 | ?lang=ht | a link on the page; the choice is written to the trilha_lang cookie |
| 3 | trilha_lang | the cookie that ?lang left, so the next click stays in the language |
| 4 | Accept-Language | by q-value and by base language: pt matches pt-BR, fr-CA matches fr |
| 5 | cfg.Locales[0] | the default |
A locale that is not on the list is not an answer; the match ignores case and falls back to the base language, and what comes back is always the spelling you configured. c.SetLocale forces it for the rest of the request. Everything that reads the locale — ui.Date, ui.Relative, ui.Number, the kit's labels, c.CSV, c.T — follows the request, and the layout writes h.Lang(c.Locale()).
The catalog of messages#
Catalog is where the application's own sentences live: one flat JSON file per locale, embedded so the build is still a single binary. embed only reaches downwards, so the folder sits next to the package that embeds it — app/i18n/ for the app/setup.go below.
//go:embed i18n
var messages embed.FS
func Setup(a *trilha.App) error {
cat, err := trilha.LoadCatalog(messages, "i18n") // i18n/pt-BR.json, i18n/ht.json…
if err != nil {
return err
}
cat.Fallback = map[string]string{"ht": "fr"} // Creole reads French before the default
a.Config().Catalog = cat
return nil
}{
"protocolo.recebido": "Protocolo %s recebido",
"protocolo.pendentes": {"one": "%d protocolo pendente", "other": "%d protocolos pendentes"}
}| Symbol | What it is |
|---|---|
LoadCatalog(fsys fs.FS, dir string) (*Catalog, error) | reads every <locale>.json under dir; a file that is not a JSON object fails the load naming it |
Catalog.Fallback map[string]string | the chain: ht → fr → the default → the key itself |
cat.Locales() []string | the locales the catalog carries, sorted |
cat.Missing(locale string, keys []string) []string | of those keys, the ones this locale does not define — what trilha i18n missing prints |
c.T("protocolo.recebido", numero) says it in the language of the request; %s/%d go through fmt.Sprintf, and a first argument that is a number picks one for 1 and other otherwise. A key no locale defines comes back as the key and is logged once — trilha check fails on it before anybody sees it on a screen.
CSRF names#
The token travels under three names, and every one of them is a default, not a rule:
| Field | Default |
|---|---|
CSRF.Cookie | trilha_csrf |
CSRF.Field | _csrf |
CSRF.Header | X-CSRF-Token |
The three defaults are the constants trilha.CSRFCookie, trilha.CSRFField and trilha.CSRFHeader, so a test or a fetch that has to send the token reads the name from the framework instead of retyping the string.
cfg.CSRF = trilha.CSRF{Cookie: "billing_csrf", Field: "_billing_csrf", Header: "X-Billing-CSRF"}Rename them when the app is not alone on the page: mounted inside a server that already writes _csrf, two hidden fields with the same name reach the handler and the browser sends whichever cookie it likes. An empty field keeps its default, so renaming one is one line. The name given here is the one CSRFInput, CSRFToken, the check, the CORS allow-list and the test client all use — there is no second place to keep in step.
CORS#
CORS is off while Origins is empty: no header is added, and OPTIONS keeps reaching the router.
| Field | Meaning |
|---|---|
Origins []string | exact origins (https://app.example.com), or the single entry "*" |
Methods []string | default GET, HEAD, POST, PUT, PATCH, DELETE |
Headers []string | what the client may send; default Content-Type, Authorization, X-CSRF-Token, Trilha-Fragment |
Expose []string | response headers the other origin's script may read |
Credentials bool | allows cookies and Authorization; incompatible with "*" |
MaxAge time.Duration | how long the browser caches the preflight; zero omits the header |
An unsafe or malformed policy panics in New ("*" with Credentials, "*" mixed with other origins, an origin with a path, a trailing slash or no scheme). See Security for why.
Timeouts#
Timeouts.Shutdown (5 s) is how long ListenAndServe waits for in-flight requests after SIGINT/SIGTERM. Zero means "default"; trilha.NoTimeout disables the limit (large uploads on a slow network, long polling). Write applies to the whole response: instead of disabling it globally, a streaming route should use c.Stream() (SSE) or c.NoWriteDeadline().
func Config(cfg *trilha.Config) {
cfg.Timeouts.Read = trilha.NoTimeout // 32 MB uploads from a phone
}Static files#
StaticCacheControl replaces the production Cache-Control of a file asked for without its current version (dev always sends no-cache); the URL c.Asset gives out is already immutable, so keep this one short. Text files go gzip-compressed to clients that accept it. StaticHeaders(name, headers) runs afterwards, per file, and may change any header:
cfg.StaticCacheControl = "public, max-age=86400" // files without ?v=; the right ?v= is immutable already
cfg.StaticHeaders = func(name string, h http.Header) {
if name == "robots.txt" { h.Set("Cache-Control", "no-store") }
h.Set("Cross-Origin-Resource-Policy", "same-origin")
}Compression#
Text responses go out gzipped to a client that sends Accept-Encoding: gzip: the SSR page, fragments, JSON, CSS, JavaScript and SVG. A versioned static file (the kit's CSS and JS, anything served through c.Asset) is compressed once per version and answered from memory; the rest is compressed on the way.
Never compressed: text/event-stream (the gzip buffer would hold the events back), a response that already carries Content-Encoding, images other than SVG, PDF, archives and media, HEAD, 1xx/204/206/304, a request with Range, and a body under Compression.MinSize (1 KiB). A compressed answer adds Vary: Accept-Encoding and turns a strong ETag weak, so the 304 keeps working.
cfg.Compression = trilha.Compression{Disabled: true} // behind a proxy that already compressesThe CSRF token in the page is masked per response because of it — see Security.
Static trees outside public/#
Public serves one tree at the root, which requires the folders on disk to be shaped like the URLs. When they are not — an icon generator that writes elsewhere, a folder shared with another build — Mounts maps prefix to tree:
cfg.Mounts = map[string]fs.FS{
"/icons/": sub(embedded, "static/public/icons"),
"/js/": sub(embedded, "static/js"),
}Mounts are tried before Public, longest prefix first; a prefix that matches without the file falls through to the next one and then to Public, so nothing has to be exhaustive. Both answer before a route with a wildcard in it and after a route spelled out in full: an app with /{lang} at the root serves /ui.css from the file, not from the page, and Asset warns in the log when the address it hands out belongs to a literal route (see conventions). StaticCacheControl, StaticHeaders and Asset treat a mounted file like any other, and the name given to StaticHeaders is the one from the URL (icons/icon-192.png), which is what tells one mount from another.
The request log#
Every request matched by a route is logged. An app that serves its own static files sees most of that volume say "a file was served with 200" — and a log nobody reads protects nobody. LogRequest decides, with the response already written:
cfg.LogRequest = func(c *trilha.Ctx, status int, _ time.Duration) bool {
return status >= 400 || c.Pattern() != ""
}It also covers "do not log the health check" and "sample 1% of the traffic". Files served from Public or Mounts never went through this log.
The record carries both addresses: path is the concrete one (/v/cmtk…/budget), for whoever is looking into a single case, and route is the template (/v/{tripId}/budget), for whoever is counting. An app with an id in the URL has one path per record and one route per screen, and rebuilding the second from the first with a regular expression outside the app is the cardinality problem this field exists to avoid. c.Pattern() is the same value inside the handler, and it is empty for what the fallback answered — which is what the example above uses to keep static files out of the log.
Version in the address (Asset)#
func (a *App) Asset(path string) string
func (c *Ctx) Asset(path string) string // same thing; it is what the layout usesc.Asset("/site.css") returns /site.css?v=8f3a1c92, where v is the FNV-1a hash of the file's content in Config.Mounts or Config.Public (prefixed with BasePath, like c.Base()). Since the address changes when the file changes, a deploy never leaves anyone with new HTML and old CSS — the browser asks for a URL it has never seen.
A request whose v matches gets public, max-age=31536000, immutable, whatever the StaticCacheControl; a wrong or missing v falls under the normal rule, and in dev nothing is immutable. The file is read once in production; in dev a Stat decides whether to re-read it, so editing the CSS and refreshing the page is enough.
A path that exists in neither Mounts nor Public comes back unversioned, with a warning in the log: a typo in the layout does not take the page down. ui.Head and the examples already use Asset.
App#
| Method | Description |
|---|---|
New(cfg) *App | creates the application |
Register(Route) | registers a route (called by the generated file) |
SetRootLayout(fn), SetNotFound(fn), SetErrorPage(fn) | wire the root files; the handlers are a PageFunc and an ErrorPageFunc, which is what the generated file passes |
trilha.Provide[T](a, v) | files a dependency under its type (see "Dependencies") |
trilha.Use[T](b) T | reads it back from a trilha.Bag — the interface a *Ctx and an *App implement, and only those two, so a handler and a Setup read the same dependency the same way |
trilha.Lookup[T](b) (T, bool) | Use for an optional dependency: false when nothing was provided, instead of a panic |
Values() map[string]any | global values set in Setup, by name and untyped |
Logger() *slog.Logger | the logger |
Env() Env | environment |
Handler() http.Handler | the root mux, for tests and for embedding in another server |
ListenAndServe() error | serves with graceful shutdown on SIGINT/SIGTERM; then runs the OnShutdown hooks |
OnShutdown(func(*App) error) | registers what to close on exit (pool, queue, flush); setup.go may export Shutdown, which the generated file registers |
Routes() map[string][]string | registered patterns and their methods |
OfflineRoutes() []string | the patterns of the pages that declared var Offline = true, sorted: what a service worker is allowed to keep — see Offline |
Route(pattern) (Route, bool) | one registered route, as a copy: its Kind, methods and chain |
Probe(*http.Request) bool | runs the route's chain without the handler; true when the caller would get through (see "Probing a route") |
AddExportPath(paths...) | extra paths for Export; a last segment with a dot exports as that file, not as index.html |
ExportPaths() []string | what Export will render |
Export(dir) error | writes the static site |
BasePath() string | URL prefix |
Security() *Security | headers, adjustable in Setup |
Config() *Config | the whole configuration, adjustable in Setup (see "Where to configure") |
trilha.Run(a) is what the generated main calls: it exports if TRILHA_EXPORT is set, otherwise it serves. trilha.Fatal(err) logs and exits, ignoring http.ErrServerClosed.
RunShutdown() runs those hooks now, newest first, and answers whatever they answered. ListenAndServe calls it when the server stops, which is the normal path and the one nobody has to think about; it is exported for the two cases where nothing stops a server. A test that has to prove the pool was closed or that the last audit line was flushed calls it at the end — a flush nobody can trigger is a flush nobody can check, and it is the one that turns out to be broken in production. A main of your own, serving through http.Server or inside another binary, calls it after its own Shutdown, because the hooks are where the application put the work that has to happen after the last request and not after the last byte.
Probing a route#
Probe answers would this request be allowed? without answering the request: the middleware chain runs — session, API key, policy, rate limit — and the handler does not. It is how mcp.FromRoutes lists only the tools a caller may use, and what a menu can ask before drawing a link.
req := httptest.NewRequest("POST", "/api/posts", nil)
req.Header.Set("Authorization", c.Request().Header.Get("Authorization"))
if a.Probe(req) { /* the caller may create posts */ }Inside the chain c.Probing() says so. The framework's own guards already respect it — a probe takes no rate-limit token from Config.RateLimit or trilha.Limit, records no API-key usage, raises no security event and writes no request log — and a middleware of yours that counts, charges or sends something should ask the same question.
trilha.WithVia(req, "mcp") marks a request as arriving through a bridge, so the actor of the audit record says Via: "mcp" instead of session — the audit screen filters on it.
Dependencies#
A page needs the store, the pool, the mailer. Keeping them in package variables works right up to the day there are two apps in one process — a host that mounts two of them, or a test that builds a second one — and then both read the same globals and the second test to run sees the first one's data.
func Setup(a *trilha.App) error {
store := posts.New()
trilha.Provide(a, store)
return nil
}func Page(c *trilha.Ctx) (h.Node, error) {
store := trilha.Use[*posts.Store](c)
...
}Provide files the value under its type; Use[T] reads it back, and takes either the *Ctx of a handler or the *App itself — which is what Setup and a test have in hand. A type nobody provided panics at the call, naming the type, instead of turning up later as a nil somewhere else.
Optional? Lookup. A service provided only when its configuration is present is asked about, not assumed — the provision stays the one answer to "is it on?":
if t, ok := trilha.Lookup[*trial.Service](c); ok {
return t.Start(c)
}
return c.Redirect("/contact")The type is the key, so a seam is declared by writing it out: trilha.Provide[Mailer](a, SMTPMailer{...}) files an interface, and the handler asking Use[Mailer](c) never learns which implementation it got. Without the type argument the key would be SMTPMailer, and the handler would be asking for something else.
Values() is still there for glue by name, and c.Get/c.Set are the per-request values a middleware leaves behind — a different question, answered in Middleware.
Your own main#
If any file in the project's main package already declares func main(), the generator omits its own and writes only newApp(). You keep control of the lifecycle:
func main() {
a := newApp()
if err := migrate(a); err != nil { // between Setup and the server
trilha.Fatal(err)
}
trilha.Run(a)
}public/ is optional: the //go:embed is only generated when the folder has files.
An app inside another binary#
When the folder declares a package other than main, the generated file follows it and exports the constructor:
// internal/crm/trilha_gen.go → package crm, func NewApp() *trilha.App
mux := http.NewServeMux()
mux.HandleFunc("/legacy", legacy.Handler)
mux.Handle("/", crm.NewApp().Handler())
http.ListenAndServe(":8080", mux)Handler() returns the http.Handler of the whole app — routing, static files, middlewares and error pages — so the host mounts it like any other handler. trilha gen needs nothing beyond the package the folder already declares; see CLI.
Testing an app#
The generated file defines newApp(), and package trilha ships the test client, so a test in the project's main package goes through the real app with no plumbing of its own:
func TestHome(t *testing.T) {
trilha.TestRequest(t, newApp(), "GET", "/").WantStatus(200).WantContains("<h1>")
}| Symbol | Role |
|---|---|
TestingT | Helper() and Fatalf(...): what the helpers use from *testing.T, so the package never imports testing |
TestRequest(t, a *App, method, target string, opts ...TestOption) *TestResponse | one request against the whole app |
TestRoute(t, r Route, method, target string, opts ...TestOption) *TestResponse | one route.go, with its middlewares |
TestPage(t, r Route, target string, opts ...TestOption) *TestResponse | one page, with its layouts; Node comes filled in |
NewTestClient(t, a *App) *TestClient | the client with a cookie jar |
(*TestClient) Request, Get(path), PostForm(path, values), PostJSON(path, v) | the requests; PostForm carries the CSRF token of the jar and PostJSON marshals the value and sends it as application/json |
TestOption | WithApp, WithHeader, WithCookie, WithSigned, WithForm, WithJSON, WithBody, WithoutCSRF |
TestResponse | Node, WantStatus, WantContains, WantHeader, JSON(&v), Cookie(name); embeds *httptest.ResponseRecorder |
Every request carries the CSRF cookie and, on a method with a body, the matching X-CSRF-Token header: cookie and token come from the same client, which is exactly what double submit asks a browser for. WithoutCSRF() is how a test proves the refusal.
No Want assertion returns an error — in a test, the value of an error is stopping with the right message, so a failure prints the target, the status and the body. Anything the ready-made assertions do not cover is an if over the embedded recorder. See Testing for the whole trail.
The served HTML (PageSnapshot)#
A snapshot is the response as the browser gets it, with what changes on every request taken out: the nonce of the Content-Security-Policy, the CSRF token, the request id, cookie values, timestamps and the durations of Server-Timing become {{NONCE}}, {{CSRF}}, {{RID}}, {{COOKIE}}, {{DATE}} and {{DUR}}. The values are the ones the response itself reveals, replaced literally — nothing is guessed from the shape of the HTML. Two runs give the same text, so a page can be held to a golden file:
var update = flag.Bool("update", false, "rewrite the snapshots")
func TestLoginPage(t *testing.T) {
snap := trilha.CapturePage(t, newApp(), "GET", "/login")
for _, err := range []error{
snap.MatchGolden("testdata/snapshots/login.txt", *update),
snap.HasCSRFToken(), snap.HasCSPNonce(), snap.HasSafeCookies(),
} {
if err != nil {
t.Error(err)
}
}
}| Symbol | Role |
|---|---|
CapturePage(t, a *App, method, target string, opts ...TestOption) PageSnapshot | one request, its snapshot |
(*TestResponse) Snapshot() PageSnapshot | the snapshot of a response — behind a login, from a TestClient that signed in |
PageSnapshot | Status, Header, Body, already normalized; String() is the golden format |
(PageSnapshot) MatchGolden(path string, update bool) error | compares with the file, or writes it when update; the error names the line and the column |
(PageSnapshot) HasCSRFToken() error | every form that is not GET carries the hidden CSRF field |
(PageSnapshot) HasCSPNonce() error | every inline <script>/<style> carries the nonce the policy announces (E_CSP_NONCE) |
(PageSnapshot) HasSafeCookies() error | every cookie set is HttpOnly and has SameSite |
(PageSnapshot) HasNoSecret(values ...string) error | none of the values is in the body or in a header |
(PageSnapshot) HasAria(sel, attr string) error | every element matching sel has attr; nothing matching is an error too |
(PageSnapshot) FocusedOnError(sel string) error | after a 422, the first aria-invalid="true" — where the kit puts the focus — is sel |
These return an error instead of stopping, because a page is checked for several things at once and every failure is worth reading. Each message says what was looked for, what was found and what to change. Selectors are compound only: tag, #id, .class, [attr] and [attr=value] together, as in input[name=email]. What only a browser sees — the focus actually moving, a fragment swapped, an island mounted — is the uitest module's job; see Testing.
Settings#
The configuration an administrator changes without a deploy is a pattern every application writes by hand: a settings table, a GET that answers JSON, a PUT, a screen with one form per section — and validation in none of them. trilha.Settings[T] is the struct being the configuration, and the screen coming from it.
type Assistant struct {
Model string `json:"model" form:"model" validate:"required,max=60" label:"Model" help:"the name the provider knows"`
Temperature float64 `json:"temperature" form:"temperature" validate:"min=0,max=2" label:"Temperature"`
Tools bool `json:"tools" form:"tools" label:"Let it use the tools"`
}
var Cfg = trilha.NewSettings("assistant", Assistant{Temperature: 0.7, Tools: true})
func Setup(a *trilha.App) error { return Cfg.Bind(a, store) } // nil store = memory
cfg := Cfg.Get() // a copy, safe from any goroutine
func POST(c *trilha.Ctx) error { return Cfg.Update(c) }| Symbol | Role |
|---|---|
NewSettings(key, defaults) | declares a section; the defaults answer before anybody saves |
Bind(app, store) | reads what was saved, in Setup; a nil store keeps it in memory and says so once |
Get() T | the current configuration, copied |
Set(T) error | saves a section the application built itself |
Update(c) error | the whole POST: bind, validate, save, audit, redirect |
Schema() / Values() | the form of the section and what it shows |
SettingsStore | Load(key) / Save(key, data) over whatever the app already runs |
trilha.SchemaOf[T]() | the form of any struct, from its own tags |
ui.SettingsForm(c, section, errs) | the screen: one field per field, of the type the tags asked for |
The tags carry three jobs at once: json says how it is stored, form names the input — the same name Bind reads — and validate is the rule, the same rule on the screen and on anything else that saves. label and help are what a person reads. oneof becomes a select, a bool becomes a checkbox, a number carries its range.
A 422 saves nothing. That is the half every hand-written settings page gets wrong: it validates on the screen and saves anyway.
A section written by an older version of the struct does not bring the app down. A field that was renamed keeps its default, and a value that cannot be read at all falls back to the defaults with a line in the log — an empty configuration in production is worse than an outdated one.
The audit line names the fields that changed and never their values. A settings page is where a token lives, and a trail that copies it is a second place to leak it from.
SchemaOf panics on a field no form can hold — a map, a slice, a nested struct — because the alternative is a screen that silently cannot edit part of its own configuration.
trilha add settings writes the section and this screen.
var Cfg = trilha.NewSettings("assistant", Assistant{Temperature: 0.7, Tools: true})
func Page(c *trilha.Ctx) (h.Node, error) { return ui.SettingsForm(c, Cfg, nil), nil }
func POST(c *trilha.Ctx) error { return Cfg.Update(c) }Versioned[T]#
A numbered history of one kind of thing, with one version marked as published — the pattern two screens of the same application always end up writing twice.
var Modelos = trilha.NewVersioned[Modelo]("modelos", trilha.VersionedOpts{})
n, _ := Modelos.Draft(c, id) // n+1, a copy of the current one
n, _ = Modelos.Save(c, id, m, "adjusted") // writes into the open draft
_ = Modelos.Publish(c, id, n) // freezes n and makes it current
m, v, _ := Modelos.Current(ctx, id) // published, or the draft before the first publish
hist, _ := Modelos.History(ctx, id)
n, _ = Modelos.Restore(c, id, 3) // a new version with the content of 3A published version does not change. Save on one answers ErrVersionFrozen, carrying the Hint that says what to do — open a draft. That is the rule an application writes by hand, once per screen, slightly differently each time. Restore creates: going back is a thing that happened, and a history that can lose an entry is a history nobody can answer questions with.
Every Save, Publish and Restore writes a line to the audit trail: who published what is the question that arrives a week later. The value is stored as JSON, so a version written before a field existed still reads back — with that field at its zero value instead of an error.
The store is trilha.VersionMemory() by default; a trilha.VersionStore — a table behind Save, List and Get, over trilha.VersionRecord values, which hold the bytes and not the struct, so one table serves every kind in the application — is the next step, and no screen changes. An id or a number with no version behind it answers trilha.ErrNoVersion, and the Hint on ErrVersionFrozen carries the code trilha.ErrFrozen (E_VERSION_FROZEN), which is what a screen matches on when it wants to offer the "open a draft" button instead of the message. The screen, with a demo, is ui.VersionList. No recipe writes it: there is no trilha add for this one, it is the runtime primitive used straight from an app's own page.
Deadlines#
Every application with a due date ends up writing the same arithmetic: how many are late, how many fall in the next thirty days, which one is next, and what colour each band gets.
resumo := trilha.Deadlines(itens, trilha.DeadlineOpts{
Horizons: []int{7, 30, 90},
Now: time.Now().In(c.Location()),
})
resumo.Overdue // []Deadline, oldest first
resumo.Within[30] // everything due in the next thirty days
resumo.Next // the nearest one that has not run out yet, or nil
resumo.ByKind // map[string]DeadlineKind: per Kind, how many are open and how many lateA Deadline is a Title, a Kind, a Due, a URL, an Owner and a Done flag.
A deadline is a date, not an instant. Something due today is not late until today is over. Comparing two time.Time values directly is the bug this exists to remove: it marks the morning of the due date as late, and nobody notices until somebody is called about a certificate that was still valid. Which day it is depends on where the reader is, so the zone is DeadlineOpts.In and not the machine's — an empty one is the zone Now already carries, which is what time.Now().In(c.Location()) gives.
Done items are out of every bucket, including Total and ByKind: a resolved deadline is not a deadline, it is history.
The bands are cumulative — Within[30] includes what is in Within[7], because that is what "within thirty days" says. What falls past the last band is Later. A screen that wants the ring between two bands subtracts.
The screen, with a demo, is ui.DeadlineCards. No recipe writes it: it is the runtime primitive, used straight from an app's own page.
Working days#
cal := trilha.BusinessDays(feriados...) // a *BusinessCalendar; the dates are the app's
prazo := cal.Add(time.Now(), 5) // five working days from today
cal.IsBusinessDay(d) // weekends and those dates are not
resumo := trilha.Deadlines(itens, trilha.DeadlineOpts{Business: cal})With a calendar, the horizons are counted in working days: thirty working days reach further into the month than thirty calendar days. The holidays are yours, and there is no HolidaysBR in the framework: that table changes by country and by year, and a calendar the framework guessed would be an arithmetic error nobody thinks to check.
The screens are ui.DeadlineCards, ui.DeadlineList and ui.DeadlineBadge.