[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build an Announcement Banner System

Build an Announcement Banner System

Build a dynamic announcement banner system with targeting rules, scheduling, A/B testing, dismissal tracking, and priority management for product updates, maintenance notices, and promotions.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
$

The Problem

Aiko leads product at a 25-person SaaS. When they need to announce maintenance, a new feature, or a pricing change, a developer hardcodes a banner div and deploys. Removing it requires another deploy. They can't target banners (free users see enterprise announcements), can't schedule them (someone has to deploy at 9 AM), and users who dismiss a banner see it again after refresh. They need a banner management system: create, schedule, target, track dismissals, and rotate without touching code.

Step 1: Build the Banner System

typescript
// src/banners/manager.ts — Dynamic banners with targeting, scheduling, and A/B testing
import { pool } from "../db";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

interface Banner {
  id: string;
  title: string;
  message: string;
  type: "info" | "warning" | "success" | "error" | "promo";
  style: {
    backgroundColor: string;
    textColor: string;
    icon: string | null;
    ctaText: string | null;
    ctaUrl: string | null;
    position: "top" | "bottom" | "modal";
    dismissible: boolean;
  };
  targeting: {
    plans: string[];           // empty = all plans
    countries: string[];
    roles: string[];
    userSegments: string[];    // "new_user" | "power_user" | "churning"
    minAccountAge: number;     // days
    maxAccountAge: number;
    percentage: number;        // 0-100 for gradual rollout
  };
  schedule: {
    startsAt: string;
    endsAt: string | null;
    timezone: string;
    daysOfWeek: number[];      // empty = every day
  };
  priority: number;            // higher = shown first
  maxImpressions: number;      // 0 = unlimited
  status: "draft" | "active" | "paused" | "expired";
  variants: BannerVariant[];   // A/B test variants
  createdAt: string;
}

interface BannerVariant {
  id: string;
  message: string;
  ctaText: string | null;
  weight: number;              // percentage allocation
}

interface BannerResponse {
  banners: Array<{
    id: string;
    variantId: string;
    title: string;
    message: string;
    type: Banner["type"];
    style: Banner["style"];
  }>;
}

// Get active banners for a user
export async function getBannersForUser(userId: string, context: {
  plan: string;
  country: string;
  role: string;
  accountAgeDays: number;
  segments: string[];
}): Promise<BannerResponse> {
  // Check cache
  const cacheKey = `banners:user:${userId}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Get dismissed banners
  const dismissedSet = await redis.smembers(`banners:dismissed:${userId}`);

  // Get active banners
  const now = new Date().toISOString();
  const { rows: banners } = await pool.query(
    `SELECT * FROM banners WHERE status = 'active'
     AND schedule_starts_at <= $1
     AND (schedule_ends_at IS NULL OR schedule_ends_at > $1)
     ORDER BY priority DESC`, [now]
  );

  const result: BannerResponse = { banners: [] };

  for (const banner of banners) {
    // Skip dismissed
    if (dismissedSet.includes(banner.id)) continue;

    // Check targeting
    const targeting = JSON.parse(banner.targeting);
    if (!matchesTarget(targeting, context)) continue;

    // Check impression limit
    if (banner.max_impressions > 0) {
      const impressions = await redis.get(`banners:impressions:${banner.id}`);
      if (parseInt(impressions || "0") >= banner.max_impressions) continue;
    }

    // Percentage rollout
    if (targeting.percentage < 100) {
      const hash = simpleHash(`${banner.id}:${userId}`);
      if (hash % 100 >= targeting.percentage) continue;
    }

    // Select variant (A/B test)
    const variants: BannerVariant[] = JSON.parse(banner.variants || "[]");
    let message = banner.message;
    let ctaText = JSON.parse(banner.style).ctaText;
    let variantId = "default";

    if (variants.length > 0) {
      const selected = selectVariant(variants, userId, banner.id);
      message = selected.message;
      ctaText = selected.ctaText || ctaText;
      variantId = selected.id;
    }

    result.banners.push({
      id: banner.id,
      variantId,
      title: banner.title,
      message,
      type: banner.type,
      style: { ...JSON.parse(banner.style), ctaText },
    });

    // Track impression
    await redis.incr(`banners:impressions:${banner.id}`);
    await redis.hincrby(`banners:variant_impressions:${banner.id}`, variantId, 1);
  }

  // Cache for 5 minutes
  await redis.setex(cacheKey, 300, JSON.stringify(result));

  return result;
}

// Dismiss banner for user
export async function dismissBanner(userId: string, bannerId: string): Promise<void> {
  await redis.sadd(`banners:dismissed:${userId}`, bannerId);
  await redis.expire(`banners:dismissed:${userId}`, 86400 * 90);
  await redis.del(`banners:user:${userId}`);

  await redis.hincrby(`banners:stats:${bannerId}`, "dismissals", 1);
}

// Track CTA click
export async function trackBannerClick(userId: string, bannerId: string, variantId: string): Promise<void> {
  await redis.hincrby(`banners:stats:${bannerId}`, "clicks", 1);
  await redis.hincrby(`banners:variant_clicks:${bannerId}`, variantId, 1);

  await pool.query(
    `INSERT INTO banner_events (banner_id, variant_id, user_id, action, created_at)
     VALUES ($1, $2, $3, 'click', NOW())`, [bannerId, variantId, userId]
  );
}

// Get banner analytics
export async function getBannerStats(bannerId: string): Promise<{
  impressions: number;
  clicks: number;
  dismissals: number;
  ctr: number;
  variantStats: Array<{ id: string; impressions: number; clicks: number; ctr: number }>;
}> {
  const stats = await redis.hgetall(`banners:stats:${bannerId}`);
  const variantImpressions = await redis.hgetall(`banners:variant_impressions:${bannerId}`);
  const variantClicks = await redis.hgetall(`banners:variant_clicks:${bannerId}`);

  const impressions = parseInt(stats.impressions || "0");
  const clicks = parseInt(stats.clicks || "0");
  const dismissals = parseInt(stats.dismissals || "0");

  const variantIds = new Set([...Object.keys(variantImpressions), ...Object.keys(variantClicks)]);
  const variantStats = Array.from(variantIds).map((id) => {
    const vi = parseInt(variantImpressions[id] || "0");
    const vc = parseInt(variantClicks[id] || "0");
    return { id, impressions: vi, clicks: vc, ctr: vi > 0 ? (vc / vi) * 100 : 0 };
  });

  return { impressions, clicks, dismissals, ctr: impressions > 0 ? (clicks / impressions) * 100 : 0, variantStats };
}

function matchesTarget(targeting: Banner["targeting"], context: { plan: string; country: string; role: string; accountAgeDays: number; segments: string[] }): boolean {
  if (targeting.plans.length > 0 && !targeting.plans.includes(context.plan)) return false;
  if (targeting.countries.length > 0 && !targeting.countries.includes(context.country)) return false;
  if (targeting.roles.length > 0 && !targeting.roles.includes(context.role)) return false;
  if (targeting.userSegments.length > 0 && !targeting.userSegments.some((s: string) => context.segments.includes(s))) return false;
  if (targeting.minAccountAge > 0 && context.accountAgeDays < targeting.minAccountAge) return false;
  if (targeting.maxAccountAge > 0 && context.accountAgeDays > targeting.maxAccountAge) return false;
  return true;
}

function selectVariant(variants: BannerVariant[], userId: string, bannerId: string): BannerVariant {
  const hash = simpleHash(`${bannerId}:${userId}`) % 100;
  let cumulative = 0;
  for (const v of variants) {
    cumulative += v.weight;
    if (hash < cumulative) return v;
  }
  return variants[0];
}

function simpleHash(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) - hash) + str.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash);
}

Results

  • Zero deploys for announcements — product team creates banners in admin panel; scheduling handles start/end times; no developer involvement
  • Targeted messaging — maintenance notice shown only to affected plan; upgrade promo shown only to free users; enterprise announcement reaches only enterprise accounts
  • A/B tested CTAs — "Try it now" vs "See what's new" tested across 50/50 split; winning variant had 3.2x higher CTR; applied to all future banners
  • Dismissals persist — user closes a banner once, never sees it again; no more "close this every page load" frustration
  • 85% fewer support tickets about changes — scheduled banner before pricing change warned users 2 weeks ahead; support team prepared instead of surprised