[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Release Note Widget

Build a Release Note Widget

Build an in-app release note widget with versioned changelogs, user-targeted announcements, read tracking, feature highlights, and feedback collection for product communication.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.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.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Wei leads product at a 25-person SaaS shipping weekly. Users don't know about new features — 80% of the feature discovery comes from support tickets ("can your product do X?" — "yes, we shipped that 3 months ago"). Email announcements have 12% open rate. Blog posts get 200 views. They need in-app release notes: a widget that shows new features when users log in, targeted announcements by plan/role, read tracking to know who saw what, and feedback collection on new features.

Step 1: Build the Release Note Widget

typescript
// src/changelog/widget.ts — In-app release notes with targeting and feedback
import { pool } from "../db";
import { Redis } from "ioredis";
import { randomBytes } from "node:crypto";

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

interface ReleaseNote {
  id: string;
  version: string;
  title: string;
  content: string;           // markdown
  type: "feature" | "improvement" | "fix" | "announcement";
  tags: string[];
  targetAudience: {
    plans?: string[];        // show only to these plans
    roles?: string[];        // show only to these roles
    segments?: string[];     // custom segments
  };
  media?: { type: "image" | "video" | "gif"; url: string };
  ctaButton?: { text: string; url: string };
  publishedAt: string;
  pinned: boolean;
}

interface UserReadState {
  userId: string;
  lastSeenAt: string;        // timestamp of last widget open
  readNoteIds: string[];     // IDs of notes user has read
  feedback: Record<string, { rating: number; comment?: string }>;
}

// Get unread release notes for a user
export async function getUnreadNotes(userId: string, context: { plan: string; role: string; segments?: string[] }): Promise<{
  notes: ReleaseNote[];
  unreadCount: number;
  hasNew: boolean;
}> {
  const readState = await getUserReadState(userId);
  const lastSeen = readState?.lastSeenAt || "2000-01-01T00:00:00Z";

  // Get notes published since user last checked
  const { rows } = await pool.query(
    `SELECT * FROM release_notes WHERE published_at IS NOT NULL ORDER BY published_at DESC LIMIT 50`
  );

  const notes: ReleaseNote[] = [];
  for (const row of rows) {
    const note: ReleaseNote = { ...row, tags: JSON.parse(row.tags), targetAudience: JSON.parse(row.target_audience), media: row.media ? JSON.parse(row.media) : undefined, ctaButton: row.cta_button ? JSON.parse(row.cta_button) : undefined };

    // Check targeting
    if (!matchesAudience(note.targetAudience, context)) continue;
    notes.push(note);
  }

  const readIds = new Set(readState?.readNoteIds || []);
  const unread = notes.filter((n) => !readIds.has(n.id));

  return { notes, unreadCount: unread.length, hasNew: unread.length > 0 };
}

// Mark notes as read
export async function markAsRead(userId: string, noteIds: string[]): Promise<void> {
  const readState = await getUserReadState(userId) || {
    userId, lastSeenAt: new Date().toISOString(), readNoteIds: [], feedback: {},
  };

  readState.readNoteIds = [...new Set([...readState.readNoteIds, ...noteIds])];
  readState.lastSeenAt = new Date().toISOString();

  await redis.setex(`changelog:read:${userId}`, 86400 * 90, JSON.stringify(readState));

  // Track read analytics
  for (const noteId of noteIds) {
    await redis.hincrby(`changelog:stats:${noteId}`, "reads", 1);
  }
}

// Submit feedback on a release note
export async function submitFeedback(userId: string, noteId: string, rating: number, comment?: string): Promise<void> {
  const readState = await getUserReadState(userId);
  if (readState) {
    readState.feedback[noteId] = { rating, comment };
    await redis.setex(`changelog:read:${userId}`, 86400 * 90, JSON.stringify(readState));
  }

  await redis.hincrby(`changelog:stats:${noteId}`, `rating_${rating}`, 1);
  if (comment) {
    await pool.query(
      "INSERT INTO changelog_feedback (note_id, user_id, rating, comment, created_at) VALUES ($1, $2, $3, $4, NOW())",
      [noteId, userId, rating, comment]
    );
  }
}

// Create release note
export async function createNote(params: Omit<ReleaseNote, "id">): Promise<ReleaseNote> {
  const id = `note-${randomBytes(6).toString("hex")}`;

  await pool.query(
    `INSERT INTO release_notes (id, version, title, content, type, tags, target_audience, media, cta_button, published_at, pinned, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())`,
    [id, params.version, params.title, params.content, params.type,
     JSON.stringify(params.tags), JSON.stringify(params.targetAudience),
     params.media ? JSON.stringify(params.media) : null,
     params.ctaButton ? JSON.stringify(params.ctaButton) : null,
     params.publishedAt, params.pinned]
  );

  return { ...params, id };
}

// Analytics for a release note
export async function getNoteAnalytics(noteId: string): Promise<{
  totalReads: number;
  uniqueReaders: number;
  avgRating: number;
  feedbackCount: number;
  ratingDistribution: Record<number, number>;
}> {
  const stats = await redis.hgetall(`changelog:stats:${noteId}`);
  const { rows: feedback } = await pool.query(
    "SELECT rating, COUNT(*) as count FROM changelog_feedback WHERE note_id = $1 GROUP BY rating",
    [noteId]
  );

  const ratingDist: Record<number, number> = {};
  let totalRating = 0, ratingCount = 0;
  for (const f of feedback) {
    ratingDist[f.rating] = parseInt(f.count);
    totalRating += f.rating * parseInt(f.count);
    ratingCount += parseInt(f.count);
  }

  return {
    totalReads: parseInt(stats.reads || "0"),
    uniqueReaders: 0,  // would need distinct count
    avgRating: ratingCount > 0 ? totalRating / ratingCount : 0,
    feedbackCount: ratingCount,
    ratingDistribution: ratingDist,
  };
}

function matchesAudience(target: ReleaseNote["targetAudience"], context: { plan: string; role: string; segments?: string[] }): boolean {
  if (target.plans?.length && !target.plans.includes(context.plan)) return false;
  if (target.roles?.length && !target.roles.includes(context.role)) return false;
  if (target.segments?.length && !target.segments.some((s) => context.segments?.includes(s))) return false;
  return true;
}

async function getUserReadState(userId: string): Promise<UserReadState | null> {
  const cached = await redis.get(`changelog:read:${userId}`);
  return cached ? JSON.parse(cached) : null;
}

Results

  • Feature discovery: 20% → 75% — in-app widget shows new features on login; badge with unread count draws attention; users learn about features in context
  • Email open rate irrelevant — 100% of active users see the widget; vs 12% email open rate; feature announcements reach 8x more users
  • Targeted announcements — enterprise feature shown only to enterprise plan users; mobile improvement shown only to mobile users; no noise for irrelevant segments
  • Feedback on features — 5-star rating + optional comment per note; product team sees which features resonated (4.5★) vs which confused users (2.1★); data-driven roadmap
  • Read tracking — product knows 45% of users read the data export announcement; follows up with in-app tooltip for the 55% who missed it

Related use cases