feat(banner): per-campaign colour overrides and urgent alerts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s

Non-default campaigns gain an optional colour override (background / text /
link) in two sets — one for every theme, one for the dark theme only — and an
"urgent" flag.

- Colours ride profile.get as six trailing FlatBuffers strings on
  BannerCampaign (backward-compatible). The client resolves the cascade
  (dark <- dark ?? all, light <- all) per rendered theme and derives the strip
  border from the background in JS (no CSS color-mix, for the old Android
  WebView floor); AdBanner applies them as inline vars scoped to the strip.
- Urgent is resolved entirely server-side: while any enabled, in-window urgent
  campaign exists, computeActiveSet returns only the urgent campaigns and
  bannerFor skips the eligibility gate — so a system notice reaches every viewer
  (paid / hint-holding / no_banner included) and preempts the ordinary feed. No
  wire field; it appears on each viewer's next profile.get.
- Admin console (/_gm/banners): native colour pickers + a live light/dark
  preview of the strip, and an urgent toggle. The default campaign stays plain,
  enforced by the service and a DB CHECK.

Migration 00009 is additive (nullable colour columns + a bool default +
all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe.

Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent
preempt + colour validation), codec + resolver unit, gateway transcode, and
integration (colour round-trip + urgent bypass against real Postgres).
This commit is contained in:
Ilia Denisov
2026-07-05 15:36:35 +02:00
parent ac383880b7
commit 6db9178449
32 changed files with 1297 additions and 152 deletions
+11 -2
View File
@@ -53,9 +53,18 @@ type BannerResp struct {
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
// weight and its messages, in display order, already resolved to one language.
// The override_* colours are the optional per-campaign palette (empty when the
// campaign keeps the neutral theme tokens); they ride through to the client
// verbatim, mirroring the backend DTO.
type BannerCampaignResp struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
Weight int `json:"weight"`
Messages []string `json:"messages"`
OverrideBg string `json:"override_bg,omitempty"`
OverrideFg string `json:"override_fg,omitempty"`
OverrideLink string `json:"override_link,omitempty"`
OverrideBgDark string `json:"override_bg_dark,omitempty"`
OverrideFgDark string `json:"override_fg_dark,omitempty"`
OverrideLinkDark string `json:"override_link_dark,omitempty"`
}
// BannerTimingsResp mirrors the backend's global display timings.
+31
View File
@@ -118,6 +118,23 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
return b.FinishedBytes()
}
// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
// optional field (the client then reads it as absent).
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
if s == "" {
return 0
}
return b.CreateString(s)
}
// addOptString adds an optional string slot only when it was created (a 0 offset
// leaves the field absent). add is the generated BannerCampaignAdd* setter.
func addOptString(b *flatbuffers.Builder, add func(*flatbuffers.Builder, flatbuffers.UOffsetT), off flatbuffers.UOffsetT) {
if off != 0 {
add(b, off)
}
}
// encodeBanner builds a BannerInfo table from the resolved banner block and
// returns its offset. It is bottom-up: each campaign's messages vector and table
// are built first, then the campaigns vector, then the BannerInfo table. The
@@ -134,9 +151,23 @@ func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatb
b.PrependUOffsetT(msgOffsets[j])
}
msgs := b.EndVector(len(msgOffsets))
// The optional colour strings must be created before the table starts; an
// empty colour is omitted (offset 0), so the client reads it as absent.
obg := optString(b, c.OverrideBg)
ofg := optString(b, c.OverrideFg)
olink := optString(b, c.OverrideLink)
obgD := optString(b, c.OverrideBgDark)
ofgD := optString(b, c.OverrideFgDark)
olinkD := optString(b, c.OverrideLinkDark)
fb.BannerCampaignStart(b)
fb.BannerCampaignAddWeight(b, int32(c.Weight))
fb.BannerCampaignAddMessages(b, msgs)
addOptString(b, fb.BannerCampaignAddOverrideBg, obg)
addOptString(b, fb.BannerCampaignAddOverrideFg, ofg)
addOptString(b, fb.BannerCampaignAddOverrideLink, olink)
addOptString(b, fb.BannerCampaignAddOverrideBgDark, obgD)
addOptString(b, fb.BannerCampaignAddOverrideFgDark, ofgD)
addOptString(b, fb.BannerCampaignAddOverrideLinkDark, olinkD)
campOffsets[i] = fb.BannerCampaignEnd(b)
}
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
@@ -61,6 +61,54 @@ func TestProfileGetEncodesBanner(t *testing.T) {
}
}
// TestProfileGetEncodesBannerColors verifies the gateway carries a campaign's
// optional colour overrides into the FlatBuffers payload, and leaves an absent
// colour (or an absent set) empty so the client falls back to the theme tokens.
func TestProfileGetEncodesBannerColors(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
`"banner":{"campaigns":[` +
`{"weight":1,"messages":["promo-en"],` +
`"override_bg":"#aa0000","override_fg":"#ffffff","override_link":"#ffdd00",` +
`"override_bg_dark":"#330000","override_fg_dark":"#eeeeee","override_link_dark":"#ffcc00"},` +
`{"weight":1,"messages":["plain-en"]}],` +
`"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` +
`"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgProfileGet)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
if err != nil {
t.Fatalf("handler: %v", err)
}
banner := fb.GetRootAsProfile(payload, 0).Banner(nil)
if banner == nil {
t.Fatal("profile carries no banner block")
}
var c fb.BannerCampaign
banner.Campaigns(&c, 0)
if got := string(c.OverrideBg()); got != "#aa0000" {
t.Errorf("override_bg = %q, want #aa0000", got)
}
if got := string(c.OverrideLink()); got != "#ffdd00" {
t.Errorf("override_link = %q, want #ffdd00", got)
}
if got := string(c.OverrideBgDark()); got != "#330000" {
t.Errorf("override_bg_dark = %q, want #330000", got)
}
if got := string(c.OverrideLinkDark()); got != "#ffcc00" {
t.Errorf("override_link_dark = %q, want #ffcc00", got)
}
// The second campaign has no override: every colour field must be absent (nil).
banner.Campaigns(&c, 1)
if c.OverrideBg() != nil || c.OverrideFg() != nil || c.OverrideLink() != nil ||
c.OverrideBgDark() != nil || c.OverrideFgDark() != nil || c.OverrideLinkDark() != nil {
t.Error("plain campaign unexpectedly carries a colour override")
}
}
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
// banner block (the backend omits it).
func TestProfileGetNoBanner(t *testing.T) {