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
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:
+21
-12
@@ -4,7 +4,7 @@
|
||||
// unit-testable with fake timers. The campaigns + timings come from the profile's
|
||||
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
|
||||
|
||||
import type { BannerCampaign, BannerTimings } from './model';
|
||||
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
|
||||
|
||||
/** Fallback display timings, matching the seeded ad_settings defaults. */
|
||||
export const defaultBannerTimings: BannerTimings = {
|
||||
@@ -16,9 +16,15 @@ export const defaultBannerTimings: BannerTimings = {
|
||||
fadeInMs: 1_000,
|
||||
};
|
||||
|
||||
/** BannerItem is one scheduled message plus its campaign's colour override (for the strip). */
|
||||
export interface BannerItem {
|
||||
md: string;
|
||||
colors: BannerColors;
|
||||
}
|
||||
|
||||
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
|
||||
export interface Scheduler {
|
||||
next(): string;
|
||||
next(): BannerItem;
|
||||
/** Total messages across all (non-empty, positive-weight) campaigns. */
|
||||
readonly total: number;
|
||||
}
|
||||
@@ -33,15 +39,17 @@ export interface Scheduler {
|
||||
*/
|
||||
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
||||
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
|
||||
const colors: BannerColors[] = cs.map((c) => ({ all: c.overrideAll ?? null, dark: c.overrideDark ?? null }));
|
||||
const current = cs.map(() => 0); // SWRR running weights
|
||||
const cursor = cs.map(() => 0); // per-campaign round-robin position
|
||||
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
|
||||
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
|
||||
const empty: BannerColors = { all: null, dark: null };
|
||||
|
||||
return {
|
||||
total,
|
||||
next(): string {
|
||||
if (cs.length === 0) return '';
|
||||
next(): BannerItem {
|
||||
if (cs.length === 0) return { md: '', colors: empty };
|
||||
let best = 0;
|
||||
for (let i = 0; i < cs.length; i++) {
|
||||
current[i] += cs[i].weight;
|
||||
@@ -51,15 +59,16 @@ export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
||||
const c = cs[best];
|
||||
const md = c.messages[cursor[best] % c.messages.length];
|
||||
cursor[best]++;
|
||||
return md;
|
||||
return { md, colors: colors[best] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
|
||||
export interface BannerHost {
|
||||
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
|
||||
show(md: string): void;
|
||||
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. colors is the
|
||||
* source campaign's colour override, applied to the strip (its neutral tokens when unset). */
|
||||
show(md: string, colors: BannerColors): void;
|
||||
/** Reset the scroll to the start without re-fading (to loop a long message). */
|
||||
resetScroll(): void;
|
||||
/** Fade the current message out over durationMs. */
|
||||
@@ -98,7 +107,7 @@ export function createBannerRotator(
|
||||
const sched = createScheduler(campaigns);
|
||||
let running = false;
|
||||
let cycleStart = 0;
|
||||
let lastShown = ''; // the message currently presented, for restart() (re-measure)
|
||||
let lastShown: BannerItem | null = null; // the item currently presented, for restart() (re-measure)
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
const at = (ms: number, fn: () => void) => {
|
||||
@@ -109,11 +118,11 @@ export function createBannerRotator(
|
||||
timers.length = 0;
|
||||
};
|
||||
|
||||
function present(md: string) {
|
||||
function present(item: BannerItem) {
|
||||
if (!running) return;
|
||||
clear();
|
||||
lastShown = md;
|
||||
host.show(md);
|
||||
lastShown = item;
|
||||
host.show(item.md, item.colors);
|
||||
at(config.fadeInMs, measure);
|
||||
}
|
||||
|
||||
@@ -143,7 +152,7 @@ export function createBannerRotator(
|
||||
// the same fade as a message change, not a hard jump.
|
||||
host.hide(config.fadeOutMs);
|
||||
at(config.fadeOutMs + config.gapMs, () => {
|
||||
host.show(lastShown);
|
||||
if (lastShown) host.show(lastShown.md, lastShown.colors);
|
||||
at(config.fadeInMs, () => scrollCycle(over));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user