diff --git a/app/api/routes-f/push-token/__tests__/route.test.ts b/app/api/routes-f/push-token/__tests__/route.test.ts new file mode 100644 index 00000000..eb577b99 --- /dev/null +++ b/app/api/routes-f/push-token/__tests__/route.test.ts @@ -0,0 +1,217 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, DELETE } from "../route"; +import { getToken, resetStore } from "../store"; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/routes-f/push-token", { + method: "POST", + body: JSON.stringify(body), + }); +} + +function deleteReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/routes-f/push-token", { + method: "DELETE", + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + resetStore(); +}); + +describe("POST /api/routes-f/push-token (register)", () => { + describe("Validation", () => { + it("rejects invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/push-token", { + method: "POST", + body: "{not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("requires viewer_id", async () => { + const res = await POST(postReq({ platform: "ios", token: "abc" })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("viewer_id"); + }); + + it("requires a valid platform", async () => { + const res = await POST( + postReq({ viewer_id: "v1", platform: "blackberry", token: "abc" }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("platform"); + }); + + it("requires token", async () => { + const res = await POST(postReq({ viewer_id: "v1", platform: "ios" })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("token"); + }); + + it("accepts each valid platform", async () => { + for (const platform of ["ios", "android", "web"]) { + const res = await POST( + postReq({ viewer_id: "v1", platform, token: `tok-${platform}` }) + ); + expect(res.status).toBe(201); + } + }); + }); + + describe("Register", () => { + it("returns a token_id and 201 on first registration", async () => { + const res = await POST( + postReq({ viewer_id: "v1", platform: "ios", token: "fcm-abc" }) + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(typeof body.token_id).toBe("string"); + expect(body.token_id.length).toBeGreaterThan(0); + }); + + it("stores the token for the viewer+platform", async () => { + await POST(postReq({ viewer_id: "v1", platform: "web", token: "w-1" })); + const stored = getToken("v1", "web"); + expect(stored?.token).toBe("w-1"); + }); + + it("keeps separate tokens per platform for the same viewer", async () => { + const ios = await ( + await POST(postReq({ viewer_id: "v1", platform: "ios", token: "i-1" })) + ).json(); + const web = await ( + await POST(postReq({ viewer_id: "v1", platform: "web", token: "w-1" })) + ).json(); + expect(ios.token_id).not.toBe(web.token_id); + expect(getToken("v1", "ios")?.token).toBe("i-1"); + expect(getToken("v1", "web")?.token).toBe("w-1"); + }); + + it("is idempotent for the same token (no duplicate slot)", async () => { + const first = await ( + await POST(postReq({ viewer_id: "v1", platform: "ios", token: "same" })) + ).json(); + const second = await POST( + postReq({ viewer_id: "v1", platform: "ios", token: "same" }) + ); + const secondBody = await second.json(); + expect(second.status).toBe(200); + expect(secondBody.token_id).toBe(first.token_id); + }); + }); + + describe("Replace (dedup per viewer+platform)", () => { + it("replaces the token value but reuses the token_id", async () => { + const first = await ( + await POST(postReq({ viewer_id: "v1", platform: "ios", token: "old" })) + ).json(); + + const replaceRes = await POST( + postReq({ viewer_id: "v1", platform: "ios", token: "new" }) + ); + const replaceBody = await replaceRes.json(); + + expect(replaceRes.status).toBe(200); + expect(replaceBody.token_id).toBe(first.token_id); + expect(getToken("v1", "ios")?.token).toBe("new"); + }); + + it("does not create a second slot on replace", async () => { + await POST(postReq({ viewer_id: "v1", platform: "ios", token: "old" })); + await POST(postReq({ viewer_id: "v1", platform: "ios", token: "new" })); + // Only the latest token survives for this viewer+platform. + expect(getToken("v1", "ios")?.token).toBe("new"); + }); + }); +}); + +describe("DELETE /api/routes-f/push-token (remove)", () => { + it("removes a token by token_id", async () => { + const reg = await ( + await POST(postReq({ viewer_id: "v1", platform: "ios", token: "x" })) + ).json(); + + const res = await DELETE(deleteReq({ token_id: reg.token_id })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.removed).toBe(true); + expect(getToken("v1", "ios")).toBeUndefined(); + }); + + it("removes a token by viewer_id + platform", async () => { + await POST(postReq({ viewer_id: "v2", platform: "android", token: "y" })); + + const res = await DELETE( + deleteReq({ viewer_id: "v2", platform: "android" }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.removed).toBe(true); + expect(getToken("v2", "android")).toBeUndefined(); + }); + + it("returns removed:false when nothing matches", async () => { + const res = await DELETE(deleteReq({ token_id: "tok_does_not_exist" })); + const body = await res.json(); + expect(body.removed).toBe(false); + }); + + it("only removes the targeted viewer+platform slot", async () => { + await POST(postReq({ viewer_id: "v3", platform: "ios", token: "a" })); + await POST(postReq({ viewer_id: "v3", platform: "web", token: "b" })); + + await DELETE(deleteReq({ viewer_id: "v3", platform: "ios" })); + expect(getToken("v3", "ios")).toBeUndefined(); + expect(getToken("v3", "web")?.token).toBe("b"); + }); + + it("requires identifying fields", async () => { + const res = await DELETE(deleteReq({})); + expect(res.status).toBe(400); + }); + + it("rejects an invalid platform when deleting by viewer+platform", async () => { + const res = await DELETE( + deleteReq({ viewer_id: "v1", platform: "symbian" }) + ); + expect(res.status).toBe(400); + }); + + it("rejects invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routes-f/push-token", { + method: "DELETE", + body: "{bad", + }); + const res = await DELETE(req); + expect(res.status).toBe(400); + }); +}); + +describe("Full lifecycle: register -> replace -> remove", () => { + it("walks through the complete flow", async () => { + const reg = await ( + await POST(postReq({ viewer_id: "v9", platform: "ios", token: "t1" })) + ).json(); + + const replace = await ( + await POST(postReq({ viewer_id: "v9", platform: "ios", token: "t2" })) + ).json(); + expect(replace.token_id).toBe(reg.token_id); + expect(getToken("v9", "ios")?.token).toBe("t2"); + + const del = await ( + await DELETE(deleteReq({ token_id: reg.token_id })) + ).json(); + expect(del.removed).toBe(true); + expect(getToken("v9", "ios")).toBeUndefined(); + }); +}); diff --git a/app/api/routes-f/push-token/route.ts b/app/api/routes-f/push-token/route.ts new file mode 100644 index 00000000..a0b65905 --- /dev/null +++ b/app/api/routes-f/push-token/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + PUSH_PLATFORMS, + type DeleteTokenBody, + type PushPlatform, + type RegisterTokenBody, + type RegisterTokenResponse, +} from "./types"; +import { registerToken, removeByTokenId, removeByViewerPlatform } from "./store"; + +function isValidPlatform(value: unknown): value is PushPlatform { + return ( + typeof value === "string" && + PUSH_PLATFORMS.includes(value as PushPlatform) + ); +} + +/** + * POST /api/routes-f/push-token + * Body: { viewer_id, platform: ios|android|web, token } -> { token_id } + * + * Registers a viewer's push token. Tokens are deduplicated per viewer+platform: + * re-registering for the same pair replaces the token value but reuses the + * existing token_id. + */ +export async function POST(req: NextRequest): Promise { + let body: RegisterTokenBody; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); + } + + const { viewer_id, platform, token } = body; + + if (!viewer_id || typeof viewer_id !== "string") { + return NextResponse.json( + { error: "viewer_id is required" }, + { status: 400 } + ); + } + if (!isValidPlatform(platform)) { + return NextResponse.json( + { error: "platform must be one of: ios, android, web" }, + { status: 400 } + ); + } + if (!token || typeof token !== "string") { + return NextResponse.json({ error: "token is required" }, { status: 400 }); + } + + const { record, replaced } = registerToken(viewer_id, platform, token); + return NextResponse.json( + { token_id: record.token_id } as RegisterTokenResponse, + { status: replaced ? 200 : 201 } + ); +} + +/** + * DELETE /api/routes-f/push-token + * Body: { token_id } OR { viewer_id, platform } -> { removed: boolean } + * + * Removes a registered token either by its token_id or by viewer+platform. + */ +export async function DELETE(req: NextRequest): Promise { + let body: DeleteTokenBody; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); + } + + const { token_id, viewer_id, platform } = body; + + if (token_id) { + if (typeof token_id !== "string") { + return NextResponse.json( + { error: "token_id must be a string" }, + { status: 400 } + ); + } + return NextResponse.json({ removed: removeByTokenId(token_id) }); + } + + if (viewer_id || platform) { + if (!viewer_id || typeof viewer_id !== "string") { + return NextResponse.json( + { error: "viewer_id is required" }, + { status: 400 } + ); + } + if (!isValidPlatform(platform)) { + return NextResponse.json( + { error: "platform must be one of: ios, android, web" }, + { status: 400 } + ); + } + return NextResponse.json({ + removed: removeByViewerPlatform(viewer_id, platform), + }); + } + + return NextResponse.json( + { error: "provide token_id, or viewer_id and platform" }, + { status: 400 } + ); +} diff --git a/app/api/routes-f/push-token/store.ts b/app/api/routes-f/push-token/store.ts new file mode 100644 index 00000000..d1d4fd94 --- /dev/null +++ b/app/api/routes-f/push-token/store.ts @@ -0,0 +1,83 @@ +import type { PushPlatform, PushToken } from "./types"; + +/** + * Tokens are deduplicated per (viewer_id, platform): a viewer holds at most one + * push token per platform. Registering again for the same pair replaces the + * stored token value but keeps the same token_id (a stable registration slot). + */ +const tokens = new Map(); +let counter = 0; + +function key(viewerId: string, platform: PushPlatform): string { + return `${viewerId}:${platform}`; +} + +export interface RegisterResult { + record: PushToken; + /** True when an existing token for this viewer+platform was overwritten. */ + replaced: boolean; +} + +export function registerToken( + viewerId: string, + platform: PushPlatform, + token: string, + now: number = Date.now() +): RegisterResult { + const k = key(viewerId, platform); + const existing = tokens.get(k); + + if (existing) { + // Idempotent: re-registering the exact same token is a no-op. + if (existing.token === token) { + return { record: existing, replaced: false }; + } + // Replace the token value in place, preserving the registration slot id. + const updated: PushToken = { + ...existing, + token, + registered_at: new Date(now).toISOString(), + }; + tokens.set(k, updated); + return { record: updated, replaced: true }; + } + + const record: PushToken = { + token_id: `tok_${++counter}`, + viewer_id: viewerId, + platform, + token, + registered_at: new Date(now).toISOString(), + }; + tokens.set(k, record); + return { record, replaced: false }; +} + +export function removeByTokenId(tokenId: string): boolean { + for (const [k, record] of tokens) { + if (record.token_id === tokenId) { + tokens.delete(k); + return true; + } + } + return false; +} + +export function removeByViewerPlatform( + viewerId: string, + platform: PushPlatform +): boolean { + return tokens.delete(key(viewerId, platform)); +} + +export function getToken( + viewerId: string, + platform: PushPlatform +): PushToken | undefined { + return tokens.get(key(viewerId, platform)); +} + +export function resetStore(): void { + tokens.clear(); + counter = 0; +} diff --git a/app/api/routes-f/push-token/types.ts b/app/api/routes-f/push-token/types.ts new file mode 100644 index 00000000..db989e97 --- /dev/null +++ b/app/api/routes-f/push-token/types.ts @@ -0,0 +1,27 @@ +export type PushPlatform = "ios" | "android" | "web"; + +export const PUSH_PLATFORMS: PushPlatform[] = ["ios", "android", "web"]; + +export interface PushToken { + token_id: string; + viewer_id: string; + platform: PushPlatform; + token: string; + registered_at: string; +} + +export interface RegisterTokenBody { + viewer_id: string; + platform: PushPlatform; + token: string; +} + +export interface DeleteTokenBody { + token_id?: string; + viewer_id?: string; + platform?: PushPlatform; +} + +export interface RegisterTokenResponse { + token_id: string; +} diff --git a/app/api/routes-f/similar-creators/__tests__/route.test.ts b/app/api/routes-f/similar-creators/__tests__/route.test.ts new file mode 100644 index 00000000..2e889d94 --- /dev/null +++ b/app/api/routes-f/similar-creators/__tests__/route.test.ts @@ -0,0 +1,189 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../route"; +import { jaccard, rankSimilarCreators } from "../similarity"; +import { creatorGraph, getCreator } from "../seed"; + +function makeReq(query = ""): NextRequest { + return new NextRequest( + `http://localhost/api/routes-f/similar-creators${query}` + ); +} + +describe("jaccard", () => { + it("returns 1 for identical sets", () => { + expect(jaccard(["a", "b"], ["b", "a"])).toBe(1); + }); + + it("returns 0 for disjoint sets", () => { + expect(jaccard(["a"], ["b"])).toBe(0); + }); + + it("returns 0 for two empty sets", () => { + expect(jaccard([], [])).toBe(0); + }); + + it("computes partial overlap correctly", () => { + // {a,b,c} vs {b,c,d}: intersection 2, union 4 => 0.5 + expect(jaccard(["a", "b", "c"], ["b", "c", "d"])).toBe(0.5); + }); + + it("is order-independent and de-duplicates", () => { + expect(jaccard(["a", "a", "b"], ["b", "a"])).toBe(1); + }); +}); + +describe("rankSimilarCreators", () => { + const target = getCreator("creator_a")!; + + it("never includes the target creator itself", () => { + const ranked = rankSimilarCreators(target, creatorGraph, 10); + expect(ranked.find(r => r.creator.creator_id === "creator_a")).toBeUndefined(); + }); + + it("drops creators with zero similarity", () => { + const ranked = rankSimilarCreators(target, creatorGraph, 10); + // creator_e (art/irl, followers v20/v21) shares nothing with creator_a. + expect(ranked.find(r => r.creator.creator_id === "creator_e")).toBeUndefined(); + }); + + it("sorts by similarity_score descending", () => { + const ranked = rankSimilarCreators(target, creatorGraph, 10); + for (let i = 1; i < ranked.length; i++) { + expect(ranked[i - 1].similarity_score).toBeGreaterThanOrEqual( + ranked[i].similarity_score + ); + } + }); + + it("scores equal the sum of category and follower jaccard", () => { + const ranked = rankSimilarCreators(target, creatorGraph, 10); + const b = ranked.find(r => r.creator.creator_id === "creator_b")!; + // categories: {gaming,esports} vs {gaming,esports,irl} => 2/3 + // followers: {v1..v6} vs {v1,v2,v3,v7,v8} => 3/8 + const expected = Number((2 / 3 + 3 / 8).toFixed(4)); + expect(b.similarity_score).toBe(expected); + }); + + it("respects the limit", () => { + const ranked = rankSimilarCreators(target, creatorGraph, 2); + expect(ranked.length).toBeLessThanOrEqual(2); + }); +}); + +describe("GET /api/routes-f/similar-creators", () => { + describe("Validation", () => { + it("requires creator_id", async () => { + const res = await GET(makeReq()); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("creator_id"); + }); + + it("404s for unknown creator", async () => { + const res = await GET(makeReq("?creator_id=nope")); + expect(res.status).toBe(404); + }); + + it("rejects a non-integer limit", async () => { + const res = await GET(makeReq("?creator_id=creator_a&limit=2.5")); + expect(res.status).toBe(400); + }); + + it("rejects limit below 1", async () => { + const res = await GET(makeReq("?creator_id=creator_a&limit=0")); + expect(res.status).toBe(400); + }); + + it("rejects limit above the maximum", async () => { + const res = await GET(makeReq("?creator_id=creator_a&limit=999")); + expect(res.status).toBe(400); + }); + }); + + describe("Overlap shapes", () => { + it("returns creators ranked by similarity", async () => { + const res = await GET(makeReq("?creator_id=creator_a")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.creators)).toBe(true); + expect(body.creators.length).toBeGreaterThan(0); + }); + + it("each result has creator, similarity_score and reason", async () => { + const res = await GET(makeReq("?creator_id=creator_a")); + const body = await res.json(); + body.creators.forEach( + (c: { + creator: { creator_id: string; name: string; categories: string[] }; + similarity_score: number; + reason: string; + }) => { + expect(c.creator).toHaveProperty("creator_id"); + expect(c.creator).toHaveProperty("name"); + expect(c.creator).toHaveProperty("categories"); + expect(typeof c.similarity_score).toBe("number"); + expect(typeof c.reason).toBe("string"); + } + ); + }); + + it("does not leak follower sets in the response", async () => { + const res = await GET(makeReq("?creator_id=creator_a")); + const body = await res.json(); + body.creators.forEach((c: { creator: Record }) => { + expect(c.creator).not.toHaveProperty("followers"); + }); + }); + + it("the reason names the shared categories and mutual followers", async () => { + const res = await GET(makeReq("?creator_id=creator_a")); + const body = await res.json(); + const b = body.creators.find( + (c: { creator: { creator_id: string } }) => + c.creator.creator_id === "creator_b" + ); + expect(b.reason).toContain("categories"); + expect(b.reason).toContain("gaming"); + expect(b.reason).toContain("mutual"); + }); + + it("category-only overlap still yields a positive score", async () => { + // creator_g shares categories with creator_d (education) only. + const res = await GET(makeReq("?creator_id=creator_d")); + const body = await res.json(); + const g = body.creators.find( + (c: { creator: { creator_id: string } }) => + c.creator.creator_id === "creator_g" + ); + expect(g).toBeDefined(); + expect(g.similarity_score).toBeGreaterThan(0); + }); + + it("follower-only overlap still yields a positive score", async () => { + // creator_f and creator_e share follower v21 and category irl. + const res = await GET(makeReq("?creator_id=creator_e")); + const body = await res.json(); + const f = body.creators.find( + (c: { creator: { creator_id: string } }) => + c.creator.creator_id === "creator_f" + ); + expect(f).toBeDefined(); + expect(f.similarity_score).toBeGreaterThan(0); + }); + + it("respects the limit query param", async () => { + const res = await GET(makeReq("?creator_id=creator_a&limit=1")); + const body = await res.json(); + expect(body.creators.length).toBeLessThanOrEqual(1); + }); + + it("returns deterministic results across calls", async () => { + const a = await (await GET(makeReq("?creator_id=creator_a"))).json(); + const b = await (await GET(makeReq("?creator_id=creator_a"))).json(); + expect(a.creators).toEqual(b.creators); + }); + }); +}); diff --git a/app/api/routes-f/similar-creators/route.ts b/app/api/routes-f/similar-creators/route.ts new file mode 100644 index 00000000..20345c43 --- /dev/null +++ b/app/api/routes-f/similar-creators/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { SimilarCreatorsResponse } from "./types"; +import { creatorGraph, getCreator } from "./seed"; +import { rankSimilarCreators } from "./similarity"; + +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 50; + +/** + * GET /api/routes-f/similar-creators?creator_id=creator_a&limit=10 + * + * Returns creators similar to the given creator, scored by the sum of the + * Jaccard index over their categories and over their follower sets. + */ +export async function GET(req: NextRequest): Promise { + const params = req.nextUrl.searchParams; + + const creatorId = params.get("creator_id"); + if (!creatorId) { + return NextResponse.json( + { error: "creator_id is required" }, + { status: 400 } + ); + } + + let limit = DEFAULT_LIMIT; + const limitRaw = params.get("limit"); + if (limitRaw !== null) { + const parsed = Number(limitRaw); + if (!Number.isInteger(parsed)) { + return NextResponse.json( + { error: "limit must be an integer" }, + { status: 400 } + ); + } + if (parsed < 1) { + return NextResponse.json( + { error: "limit must be at least 1" }, + { status: 400 } + ); + } + if (parsed > MAX_LIMIT) { + return NextResponse.json( + { error: `limit must be at most ${MAX_LIMIT}` }, + { status: 400 } + ); + } + limit = parsed; + } + + const target = getCreator(creatorId); + if (!target) { + return NextResponse.json( + { error: `unknown creator_id: ${creatorId}` }, + { status: 404 } + ); + } + + const creators = rankSimilarCreators(target, creatorGraph, limit); + return NextResponse.json({ creators } as SimilarCreatorsResponse); +} diff --git a/app/api/routes-f/similar-creators/seed.ts b/app/api/routes-f/similar-creators/seed.ts new file mode 100644 index 00000000..335ad1a5 --- /dev/null +++ b/app/api/routes-f/similar-creators/seed.ts @@ -0,0 +1,57 @@ +import type { CreatorNode } from "./types"; + +/** + * Seed graph + category data for the similarity recommender. + * + * Follower sets are intentionally overlapping so the Jaccard computation has + * meaningful structure: e.g. creator_a and creator_b share several followers + * and a category, while creator_e is a near-isolated outlier. + */ +export const creatorGraph: CreatorNode[] = [ + { + creator_id: "creator_a", + name: "PixelQueen", + categories: ["gaming", "esports"], + followers: ["v1", "v2", "v3", "v4", "v5", "v6"], + }, + { + creator_id: "creator_b", + name: "ClutchKing", + categories: ["gaming", "esports", "irl"], + followers: ["v1", "v2", "v3", "v7", "v8"], + }, + { + creator_id: "creator_c", + name: "SpeedRunSam", + categories: ["gaming", "speedrun"], + followers: ["v2", "v4", "v9", "v10"], + }, + { + creator_id: "creator_d", + name: "ChainTalk", + categories: ["crypto", "education"], + followers: ["v5", "v11", "v12", "v13"], + }, + { + creator_id: "creator_e", + name: "ArtByMona", + categories: ["art", "irl"], + followers: ["v20", "v21"], + }, + { + creator_id: "creator_f", + name: "LoFiBeats", + categories: ["music", "irl"], + followers: ["v8", "v21", "v22", "v23"], + }, + { + creator_id: "creator_g", + name: "TacticalTina", + categories: ["gaming", "esports", "education"], + followers: ["v1", "v3", "v9", "v14", "v15"], + }, +]; + +export function getCreator(creatorId: string): CreatorNode | undefined { + return creatorGraph.find(c => c.creator_id === creatorId); +} diff --git a/app/api/routes-f/similar-creators/similarity.ts b/app/api/routes-f/similar-creators/similarity.ts new file mode 100644 index 00000000..3e2ca5ff --- /dev/null +++ b/app/api/routes-f/similar-creators/similarity.ts @@ -0,0 +1,95 @@ +import type { CreatorNode, SimilarCreator } from "./types"; + +/** + * Jaccard index of two sets: |A ∩ B| / |A ∪ B|. + * Two empty sets are defined as 0 (no evidence of similarity). + */ +export function jaccard(a: string[], b: string[]): number { + const setA = new Set(a); + const setB = new Set(b); + if (setA.size === 0 && setB.size === 0) return 0; + + let intersection = 0; + for (const item of setA) { + if (setB.has(item)) intersection++; + } + const union = setA.size + setB.size - intersection; + return union === 0 ? 0 : intersection / union; +} + +function intersection(a: string[], b: string[]): string[] { + const setB = new Set(b); + return a.filter(item => setB.has(item)); +} + +function buildReason( + sharedCategories: string[], + mutualFollowerCount: number +): string { + const parts: string[] = []; + if (sharedCategories.length > 0) { + parts.push( + `shares ${sharedCategories.length} ${ + sharedCategories.length === 1 ? "category" : "categories" + } (${sharedCategories.join(", ")})` + ); + } + if (mutualFollowerCount > 0) { + parts.push( + `${mutualFollowerCount} mutual ${ + mutualFollowerCount === 1 ? "follower" : "followers" + }` + ); + } + if (parts.length === 0) return "no shared categories or followers"; + return parts.join(" and "); +} + +/** + * Rank every creator other than `target` by combined similarity. + * + * similarity_score = jaccard(categories) + jaccard(followers), range 0..2. + * Results are sorted by score descending, then by creator_id for stable order, + * and zero-score creators are dropped. + */ +export function rankSimilarCreators( + target: CreatorNode, + candidates: CreatorNode[], + limit: number +): SimilarCreator[] { + const ranked: SimilarCreator[] = []; + + for (const candidate of candidates) { + if (candidate.creator_id === target.creator_id) continue; + + const categoryScore = jaccard(target.categories, candidate.categories); + const followerScore = jaccard(target.followers, candidate.followers); + const score = categoryScore + followerScore; + if (score === 0) continue; + + const sharedCategories = intersection( + target.categories, + candidate.categories + ); + const mutualFollowers = intersection(target.followers, candidate.followers); + + ranked.push({ + creator: { + creator_id: candidate.creator_id, + name: candidate.name, + categories: candidate.categories, + }, + similarity_score: Number(score.toFixed(4)), + reason: buildReason(sharedCategories, mutualFollowers.length), + }); + } + + ranked.sort((a, b) => { + if (b.similarity_score !== a.similarity_score) { + return b.similarity_score - a.similarity_score; + } + return a.creator.creator_id.localeCompare(b.creator.creator_id); + }); + + return ranked.slice(0, limit); +} diff --git a/app/api/routes-f/similar-creators/types.ts b/app/api/routes-f/similar-creators/types.ts new file mode 100644 index 00000000..4286f3b1 --- /dev/null +++ b/app/api/routes-f/similar-creators/types.ts @@ -0,0 +1,26 @@ +export interface CreatorNode { + creator_id: string; + name: string; + /** Categories the creator streams in. */ + categories: string[]; + /** Viewer ids that follow this creator. */ + followers: string[]; +} + +/** Public-facing creator summary (followers omitted for privacy). */ +export interface CreatorSummary { + creator_id: string; + name: string; + categories: string[]; +} + +export interface SimilarCreator { + creator: CreatorSummary; + /** Sum of the category Jaccard and follower Jaccard, range 0..2. */ + similarity_score: number; + reason: string; +} + +export interface SimilarCreatorsResponse { + creators: SimilarCreator[]; +} diff --git a/app/api/routes-f/stream-analytics/__tests__/route.test.ts b/app/api/routes-f/stream-analytics/__tests__/route.test.ts new file mode 100644 index 00000000..b1a8c2ea --- /dev/null +++ b/app/api/routes-f/stream-analytics/__tests__/route.test.ts @@ -0,0 +1,122 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../route"; +import { summarizeSession } from "../summarize"; +import { getSession } from "../seed"; + +function makeReq(query = ""): NextRequest { + return new NextRequest( + `http://localhost/api/routes-f/stream-analytics${query}` + ); +} + +describe("summarizeSession", () => { + it("computes a completed stream's fixed duration", () => { + const session = getSession("stream_completed_1")!; + const summary = summarizeSession(session); + expect(summary.duration_minutes).toBe(120); + }); + + it("computes peak and average viewers from samples", () => { + const session = getSession("stream_completed_1")!; + const summary = summarizeSession(session); + // samples: [120,340,510,480,620,590,410,250] + expect(summary.peak_viewers).toBe(620); + expect(summary.average_viewers).toBe(403); // mean 3220/8 = 402.5 -> 403 + }); + + it("counts unique viewers and messages", () => { + const session = getSession("stream_completed_1")!; + const summary = summarizeSession(session); + expect(summary.unique_viewers).toBe(12); + expect(summary.total_messages).toBe(1843); + }); + + it("sums tips in USDC", () => { + const session = getSession("stream_completed_1")!; + const summary = summarizeSession(session); + // 5+10+25+2.5+100+50+7.5+15 = 215 + expect(summary.total_tips_usdc).toBe(215); + }); + + it("handles a completed stream with no tips", () => { + const session = getSession("stream_completed_2")!; + const summary = summarizeSession(session); + expect(summary.duration_minutes).toBe(45); + expect(summary.total_tips_usdc).toBe(0); + expect(summary.peak_viewers).toBe(42); + expect(summary.average_viewers).toBe(42); + }); + + it("computes a live stream's duration relative to now", () => { + const now = Date.parse("2026-06-26T10:00:00.000Z"); + const session = getSession("stream_live_1", now)!; + const summary = summarizeSession(session, now); + // seed starts the live stream 35 minutes before `now`. + expect(summary.duration_minutes).toBe(35); + }); + + it("a live stream's duration grows as now advances", () => { + const base = Date.parse("2026-06-26T10:00:00.000Z"); + const session = getSession("stream_live_1", base)!; + const early = summarizeSession(session, base); + const later = summarizeSession(session, base + 10 * 60 * 1000); + expect(later.duration_minutes).toBeGreaterThan(early.duration_minutes); + }); +}); + +describe("GET /api/routes-f/stream-analytics", () => { + it("requires stream_id", async () => { + const res = await GET(makeReq()); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("stream_id"); + }); + + it("404s for an unknown stream", async () => { + const res = await GET(makeReq("?stream_id=nope")); + expect(res.status).toBe(404); + }); + + it("returns a summary for a completed stream", async () => { + const res = await GET(makeReq("?stream_id=stream_completed_1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + duration_minutes: 120, + peak_viewers: 620, + average_viewers: 403, + unique_viewers: 12, + total_messages: 1843, + total_tips_usdc: 215, + }); + }); + + it("returns a summary for a live (in-progress) stream", async () => { + const res = await GET(makeReq("?stream_id=stream_live_1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.peak_viewers).toBe(410); + expect(body.unique_viewers).toBe(7); + expect(body.total_messages).toBe(512); + expect(body.total_tips_usdc).toBe(63.5); + // Live duration should be roughly the seed's 35-minute offset. + expect(body.duration_minutes).toBeGreaterThanOrEqual(34); + expect(body.duration_minutes).toBeLessThanOrEqual(36); + }); + + it("returns all required fields", async () => { + const res = await GET(makeReq("?stream_id=stream_completed_1")); + const body = await res.json(); + [ + "duration_minutes", + "peak_viewers", + "average_viewers", + "unique_viewers", + "total_messages", + "total_tips_usdc", + ].forEach(key => expect(body).toHaveProperty(key)); + }); +}); diff --git a/app/api/routes-f/stream-analytics/route.ts b/app/api/routes-f/stream-analytics/route.ts new file mode 100644 index 00000000..53d95198 --- /dev/null +++ b/app/api/routes-f/stream-analytics/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "./seed"; +import { summarizeSession } from "./summarize"; + +/** + * GET /api/routes-f/stream-analytics?stream_id=stream_live_1 + * + * Computes an analytics summary for a single live or past stream: + * duration, peak/average/unique viewers, total messages and total tips (USDC). + */ +export async function GET(req: NextRequest): Promise { + const streamId = req.nextUrl.searchParams.get("stream_id"); + if (!streamId) { + return NextResponse.json( + { error: "stream_id is required" }, + { status: 400 } + ); + } + + const now = Date.now(); + const session = getSession(streamId, now); + if (!session) { + return NextResponse.json( + { error: `unknown stream_id: ${streamId}` }, + { status: 404 } + ); + } + + return NextResponse.json(summarizeSession(session, now)); +} diff --git a/app/api/routes-f/stream-analytics/seed.ts b/app/api/routes-f/stream-analytics/seed.ts new file mode 100644 index 00000000..24575df3 --- /dev/null +++ b/app/api/routes-f/stream-analytics/seed.ts @@ -0,0 +1,59 @@ +import type { StreamSession } from "./types"; + +const MINUTE = 60 * 1000; + +/** + * Seed session data. The live session's `started_at` is expressed relative to + * "now" so its computed duration stays current; completed sessions use fixed + * start/end timestamps. + */ +export function getSessions(now: number = Date.now()): StreamSession[] { + return [ + { + // Completed stream: ran for exactly 120 minutes. + stream_id: "stream_completed_1", + creator_id: "creator_a", + status: "completed", + started_at: new Date("2026-06-20T18:00:00.000Z").toISOString(), + ended_at: new Date("2026-06-20T20:00:00.000Z").toISOString(), + viewer_samples: [120, 340, 510, 480, 620, 590, 410, 250], + unique_viewer_ids: [ + "v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", + "v9", "v10", "v11", "v12", + ], + messages: 1843, + tips_usdc: [5, 10, 25, 2.5, 100, 50, 7.5, 15], + }, + { + // Another completed stream with no tips and a single viewer sample. + stream_id: "stream_completed_2", + creator_id: "creator_b", + status: "completed", + started_at: new Date("2026-06-21T12:00:00.000Z").toISOString(), + ended_at: new Date("2026-06-21T12:45:00.000Z").toISOString(), + viewer_samples: [42], + unique_viewer_ids: ["v1", "v2", "v3"], + messages: 96, + tips_usdc: [], + }, + { + // Live stream: started 35 minutes ago, still in progress. + stream_id: "stream_live_1", + creator_id: "creator_c", + status: "live", + started_at: new Date(now - 35 * MINUTE).toISOString(), + ended_at: null, + viewer_samples: [80, 150, 220, 300, 410], + unique_viewer_ids: ["v1", "v2", "v3", "v4", "v5", "v6", "v7"], + messages: 512, + tips_usdc: [3, 8, 12.5, 40], + }, + ]; +} + +export function getSession( + streamId: string, + now: number = Date.now() +): StreamSession | undefined { + return getSessions(now).find(s => s.stream_id === streamId); +} diff --git a/app/api/routes-f/stream-analytics/summarize.ts b/app/api/routes-f/stream-analytics/summarize.ts new file mode 100644 index 00000000..0e7ff564 --- /dev/null +++ b/app/api/routes-f/stream-analytics/summarize.ts @@ -0,0 +1,49 @@ +import type { StreamSession, StreamAnalyticsSummary } from "./types"; + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Compute an analytics summary for a single stream session. + * + * For completed streams the duration is `ended_at - started_at`; for live + * streams it is `now - started_at`, so an in-progress stream's duration grows + * over time. + */ +export function summarizeSession( + session: StreamSession, + now: number = Date.now() +): StreamAnalyticsSummary { + const start = new Date(session.started_at).getTime(); + const end = + session.status === "completed" && session.ended_at + ? new Date(session.ended_at).getTime() + : now; + + const durationMinutes = Math.max(0, Math.round((end - start) / 60000)); + + const peak = + session.viewer_samples.length > 0 + ? Math.max(...session.viewer_samples) + : 0; + + const average = + session.viewer_samples.length > 0 + ? Math.round( + session.viewer_samples.reduce((sum, v) => sum + v, 0) / + session.viewer_samples.length + ) + : 0; + + const totalTips = session.tips_usdc.reduce((sum, t) => sum + t, 0); + + return { + duration_minutes: durationMinutes, + peak_viewers: peak, + average_viewers: average, + unique_viewers: new Set(session.unique_viewer_ids).size, + total_messages: session.messages, + total_tips_usdc: round2(totalTips), + }; +} diff --git a/app/api/routes-f/stream-analytics/types.ts b/app/api/routes-f/stream-analytics/types.ts new file mode 100644 index 00000000..d27602e5 --- /dev/null +++ b/app/api/routes-f/stream-analytics/types.ts @@ -0,0 +1,28 @@ +export type SessionStatus = "live" | "completed"; + +export interface StreamSession { + stream_id: string; + creator_id: string; + status: SessionStatus; + /** ISO-8601 start time. */ + started_at: string; + /** ISO-8601 end time, or null while the stream is still live. */ + ended_at: string | null; + /** Concurrent viewer counts sampled at regular intervals during the stream. */ + viewer_samples: number[]; + /** Distinct viewer ids seen across the session. */ + unique_viewer_ids: string[]; + /** Total chat messages sent during the session. */ + messages: number; + /** Individual tip amounts in USDC. */ + tips_usdc: number[]; +} + +export interface StreamAnalyticsSummary { + duration_minutes: number; + peak_viewers: number; + average_viewers: number; + unique_viewers: number; + total_messages: number; + total_tips_usdc: number; +} diff --git a/app/api/routes-f/upcoming-streams/__tests__/route.test.ts b/app/api/routes-f/upcoming-streams/__tests__/route.test.ts new file mode 100644 index 00000000..9316e008 --- /dev/null +++ b/app/api/routes-f/upcoming-streams/__tests__/route.test.ts @@ -0,0 +1,168 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../route"; +import { getScheduledStreams } from "../seed"; + +function makeReq(query = ""): NextRequest { + const url = `http://localhost/api/routes-f/upcoming-streams${query}`; + return new NextRequest(url); +} + +describe("GET /api/routes-f/upcoming-streams", () => { + describe("Validation", () => { + it("rejects non-numeric within_hours", async () => { + const res = await GET(makeReq("?within_hours=soon")); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("within_hours"); + }); + + it("rejects zero within_hours", async () => { + const res = await GET(makeReq("?within_hours=0")); + expect(res.status).toBe(400); + }); + + it("rejects negative within_hours", async () => { + const res = await GET(makeReq("?within_hours=-5")); + expect(res.status).toBe(400); + }); + + it("rejects within_hours over the maximum", async () => { + const res = await GET(makeReq("?within_hours=100000")); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("at most"); + }); + }); + + describe("Default time window", () => { + it("defaults to a 48-hour window", async () => { + const res = await GET(makeReq()); + expect(res.status).toBe(200); + const body = await res.json(); + + const now = Date.now(); + const windowEnd = now + 48 * 60 * 60 * 1000; + body.scheduled.forEach((s: { starts_at: string }) => { + const t = new Date(s.starts_at).getTime(); + expect(t).toBeGreaterThan(now); + expect(t).toBeLessThanOrEqual(windowEnd + 1000); + }); + }); + + it("excludes streams that already started (stale schedule)", async () => { + const res = await GET(makeReq()); + const body = await res.json(); + const ids = body.scheduled.map((s: { stream_id: string }) => s.stream_id); + expect(ids).not.toContain("sched_009"); + }); + }); + + describe("Time window filtering", () => { + it("a narrow window returns fewer results than a wide one", async () => { + const narrow = await (await GET(makeReq("?within_hours=3"))).json(); + const wide = await (await GET(makeReq("?within_hours=168"))).json(); + expect(wide.scheduled.length).toBeGreaterThan(narrow.scheduled.length); + }); + + it("a 3-hour window only includes the soonest stream", async () => { + const res = await GET(makeReq("?within_hours=3")); + const body = await res.json(); + const ids = body.scheduled.map((s: { stream_id: string }) => s.stream_id); + expect(ids).toEqual(["sched_001"]); + }); + + it("widening the window includes streams further out", async () => { + const res = await GET(makeReq("?within_hours=48")); + const body = await res.json(); + const ids = body.scheduled.map((s: { stream_id: string }) => s.stream_id); + expect(ids).toContain("sched_006"); // 47h out + expect(ids).not.toContain("sched_007"); // 72h out + }); + }); + + describe("Category filtering", () => { + it("filters to a single category", async () => { + const res = await GET(makeReq("?within_hours=168&category=gaming")); + const body = await res.json(); + expect(body.scheduled.length).toBeGreaterThan(0); + body.scheduled.forEach((s: { category: string }) => { + expect(s.category).toBe("gaming"); + }); + }); + + it("category matching is case-insensitive", async () => { + const lower = await ( + await GET(makeReq("?within_hours=168&category=crypto")) + ).json(); + const upper = await ( + await GET(makeReq("?within_hours=168&category=CRYPTO")) + ).json(); + expect(upper.scheduled.map((s: { stream_id: string }) => s.stream_id)).toEqual( + lower.scheduled.map((s: { stream_id: string }) => s.stream_id) + ); + }); + + it("returns empty for an unknown category", async () => { + const res = await GET(makeReq("?within_hours=168&category=underwater-basket")); + const body = await res.json(); + expect(body.scheduled).toEqual([]); + }); + + it("combines category and time window", async () => { + const res = await GET(makeReq("?within_hours=48&category=music")); + const body = await res.json(); + const ids = body.scheduled.map((s: { stream_id: string }) => s.stream_id); + expect(ids).toContain("sched_003"); // music, 12h + expect(ids).not.toContain("sched_008"); // music, 96h (outside window) + }); + }); + + describe("Sorting", () => { + it("sorts by starts_at ascending", async () => { + const res = await GET(makeReq("?within_hours=336")); + const body = await res.json(); + for (let i = 1; i < body.scheduled.length; i++) { + const prev = new Date(body.scheduled[i - 1].starts_at).getTime(); + const cur = new Date(body.scheduled[i].starts_at).getTime(); + expect(prev).toBeLessThanOrEqual(cur); + } + }); + }); + + describe("Response shape", () => { + it("returns scheduled streams with the expected fields", async () => { + const res = await GET(makeReq("?within_hours=336")); + const body = await res.json(); + expect(Array.isArray(body.scheduled)).toBe(true); + body.scheduled.forEach((s: Record) => { + expect(s).toHaveProperty("stream_id"); + expect(s).toHaveProperty("creator_id"); + expect(s).toHaveProperty("creator_name"); + expect(s).toHaveProperty("title"); + expect(s).toHaveProperty("category"); + expect(s).toHaveProperty("privacy"); + expect(s).toHaveProperty("starts_at"); + expect(s).toHaveProperty("thumbnail_url"); + }); + }); + }); + + describe("Seed data", () => { + it("produces ISO timestamps relative to the reference time", () => { + const base = 1_700_000_000_000; + const streams = getScheduledStreams(base); + const first = streams.find(s => s.stream_id === "sched_001")!; + expect(new Date(first.starts_at).getTime()).toBe(base + 2 * 60 * 60 * 1000); + }); + + it("includes a stale (past) entry in the raw seed", () => { + const base = 1_700_000_000_000; + const streams = getScheduledStreams(base); + const stale = streams.find(s => s.stream_id === "sched_009")!; + expect(new Date(stale.starts_at).getTime()).toBeLessThan(base); + }); + }); +}); diff --git a/app/api/routes-f/upcoming-streams/route.ts b/app/api/routes-f/upcoming-streams/route.ts new file mode 100644 index 00000000..c77514a1 --- /dev/null +++ b/app/api/routes-f/upcoming-streams/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { ScheduledStream, UpcomingStreamsResponse } from "./types"; +import { getScheduledStreams } from "./seed"; + +const DEFAULT_WITHIN_HOURS = 48; +const MAX_WITHIN_HOURS = 24 * 14; // two weeks + +/** + * GET /api/routes-f/upcoming-streams?within_hours=48&category=gaming + * + * Lists streams creators have scheduled but not yet started, within the given + * forward-looking time window, optionally filtered by category, sorted by + * starts_at ascending (soonest first). + */ +export async function GET(req: NextRequest): Promise { + const params = req.nextUrl.searchParams; + + let withinHours = DEFAULT_WITHIN_HOURS; + const withinRaw = params.get("within_hours"); + if (withinRaw !== null) { + const parsed = Number(withinRaw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return NextResponse.json( + { error: "within_hours must be a positive number" }, + { status: 400 } + ); + } + if (parsed > MAX_WITHIN_HOURS) { + return NextResponse.json( + { error: `within_hours must be at most ${MAX_WITHIN_HOURS}` }, + { status: 400 } + ); + } + withinHours = parsed; + } + + const category = params.get("category")?.trim().toLowerCase() || null; + + const now = Date.now(); + const windowEnd = now + withinHours * 60 * 60 * 1000; + + const scheduled: ScheduledStream[] = getScheduledStreams(now) + .filter(stream => { + const startsAt = new Date(stream.starts_at).getTime(); + // Must be in the future (not yet started) and within the window. + if (startsAt <= now || startsAt > windowEnd) return false; + if (category && stream.category.toLowerCase() !== category) return false; + return true; + }) + .sort( + (a, b) => + new Date(a.starts_at).getTime() - new Date(b.starts_at).getTime() + ); + + return NextResponse.json({ scheduled } as UpcomingStreamsResponse); +} diff --git a/app/api/routes-f/upcoming-streams/seed.ts b/app/api/routes-f/upcoming-streams/seed.ts new file mode 100644 index 00000000..77987e51 --- /dev/null +++ b/app/api/routes-f/upcoming-streams/seed.ts @@ -0,0 +1,118 @@ +import type { ScheduledStream } from "./types"; + +const HOUR = 60 * 60 * 1000; + +/** + * Seed schedule data. Timestamps are expressed as an offset (in hours) from + * "now" so the data stays meaningfully "upcoming" regardless of when the route + * is called. A negative offset represents a stream that was scheduled to start + * in the past (already started / stale schedule) and must never be returned. + */ +interface SeedEntry extends Omit { + starts_in_hours: number; +} + +const SEED: SeedEntry[] = [ + { + stream_id: "sched_001", + creator_id: "creator_a", + creator_name: "PixelQueen", + title: "Ranked grind to Diamond", + category: "gaming", + privacy: "public", + thumbnail_url: "https://stream.fi/thumbs/sched_001.jpg", + starts_in_hours: 2, + }, + { + stream_id: "sched_002", + creator_id: "creator_b", + creator_name: "ChainTalk", + title: "Stellar smart contracts deep dive", + category: "crypto", + privacy: "public", + thumbnail_url: "https://stream.fi/thumbs/sched_002.jpg", + starts_in_hours: 5, + }, + { + stream_id: "sched_003", + creator_id: "creator_c", + creator_name: "LoFiBeats", + title: "Late night coding & chill", + category: "music", + privacy: "subscribers-only", + thumbnail_url: "https://stream.fi/thumbs/sched_003.jpg", + starts_in_hours: 12, + }, + { + stream_id: "sched_004", + creator_id: "creator_a", + creator_name: "PixelQueen", + title: "Weekend speedrun marathon", + category: "gaming", + privacy: "public", + thumbnail_url: "https://stream.fi/thumbs/sched_004.jpg", + starts_in_hours: 26, + }, + { + stream_id: "sched_005", + creator_id: "creator_d", + creator_name: "ArtByMona", + title: "Digital painting commissions", + category: "art", + privacy: "unlisted", + thumbnail_url: "https://stream.fi/thumbs/sched_005.jpg", + starts_in_hours: 40, + }, + { + stream_id: "sched_006", + creator_id: "creator_b", + creator_name: "ChainTalk", + title: "Tipping economics AMA", + category: "crypto", + privacy: "public", + thumbnail_url: "https://stream.fi/thumbs/sched_006.jpg", + starts_in_hours: 47, + }, + { + stream_id: "sched_007", + creator_id: "creator_e", + creator_name: "CookWithKai", + title: "Sourdough from scratch", + category: "cooking", + privacy: "public", + thumbnail_url: "https://stream.fi/thumbs/sched_007.jpg", + starts_in_hours: 72, + }, + { + stream_id: "sched_008", + creator_id: "creator_c", + creator_name: "LoFiBeats", + title: "Synthwave live set", + category: "music", + privacy: "public", + thumbnail_url: "https://stream.fi/thumbs/sched_008.jpg", + starts_in_hours: 96, + }, + { + // Stale entry: was scheduled to start an hour ago. Should never be listed. + stream_id: "sched_009", + creator_id: "creator_a", + creator_name: "PixelQueen", + title: "Missed warmup stream", + category: "gaming", + privacy: "public", + thumbnail_url: "https://stream.fi/thumbs/sched_009.jpg", + starts_in_hours: -1, + }, +]; + +/** + * Materialize the seed schedule into concrete scheduled streams with absolute + * ISO timestamps, relative to the supplied reference time. + */ +export function getScheduledStreams(now: number = Date.now()): ScheduledStream[] { + return SEED.map(({ starts_in_hours, ...rest }) => ({ + ...rest, + starts_at: new Date(now + starts_in_hours * HOUR).toISOString(), + })); +} diff --git a/app/api/routes-f/upcoming-streams/types.ts b/app/api/routes-f/upcoming-streams/types.ts new file mode 100644 index 00000000..c3d9ea7d --- /dev/null +++ b/app/api/routes-f/upcoming-streams/types.ts @@ -0,0 +1,17 @@ +export type StreamPrivacy = "public" | "unlisted" | "subscribers-only"; + +export interface ScheduledStream { + stream_id: string; + creator_id: string; + creator_name: string; + title: string; + category: string; + privacy: StreamPrivacy; + /** ISO-8601 timestamp for when the stream is scheduled to begin. */ + starts_at: string; + thumbnail_url: string; +} + +export interface UpcomingStreamsResponse { + scheduled: ScheduledStream[]; +}