From ccf3819e6833d047bb4ba713047895c6f0a58f7c Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Sun, 31 May 2026 13:12:07 -0500 Subject: [PATCH 1/2] feat(reciprocity): dedicated post-gate slide + daily-cap countdown Replace the bottom-sheet feed gate with a dedicated full-screen QueueGateSlide: when a member is at the credit cap with queued clips, forward scroll yields to an in-feed slide offering SKIP (soft nudge, re-arms after a grace window) or POST (publish a queued clip, then advance). The manual QueueSheet stays for explicit queue access. The gate re-arm is keyed to activeIndex progression, not the live credit balance, so the credit a post spends being earned straight back by watching the next reel can't instantly re-open the gate. Daily-cap users are exempt from the gate (they can't post anyway); nextPostAvailableAt surfaces on the watch and queue-post responses and the layout seed so the gate stays suppressed and an explicit post attempt shows a 'you can post again in Xh Ym' countdown instead of a silent 'Posted 0'. --- docs/api.md | 9 +- docs/architecture.md | 3 +- src/lib/components/QueueGateSlide.svelte | 523 +++++++++++++++++++ src/lib/components/QueueSheet.svelte | 32 +- src/lib/feed.ts | 3 +- src/lib/server/credits.ts | 31 +- src/lib/stores/credits.ts | 34 +- src/lib/utils.ts | 17 + src/routes/(app)/+layout.server.ts | 6 +- src/routes/(app)/+layout.svelte | 3 +- src/routes/(app)/+page.svelte | 72 ++- src/routes/api/clips/[id]/watched/+server.ts | 10 +- src/routes/api/queue/post/+server.ts | 10 +- 13 files changed, 707 insertions(+), 46 deletions(-) create mode 100644 src/lib/components/QueueGateSlide.svelte diff --git a/docs/api.md b/docs/api.md index c4fae60..4a17623 100644 --- a/docs/api.md +++ b/docs/api.md @@ -161,9 +161,9 @@ Returns dismissed clips with thumbnail, platform, uploader info, and dismissal t ### POST /api/clips/[id]/watched ``` Request: { "watchPercent": 85 } (optional, 0–100) -Response: { "watched": true, "credits": 3 } +Response: { "watched": true, "credits": 3, "nextPostAt": null } ``` -`credits` is the user's reciprocity balance after this watch. The first watch of another member's clip earns a credit (subject to the group's `ratio` and the launch cutoff); self-watches and repeat watches don't. +`credits` is the user's reciprocity balance after this watch. The first watch of another member's clip earns a credit (subject to the group's `ratio` and the launch cutoff); self-watches and repeat watches don't. `nextPostAt` is an epoch-ms timestamp for when the user may post again if they're currently blocked by the group's daily post cap, else `null` — surfaced here so the feed keeps the post gate suppressed as the rolling-24h window slides. ### PATCH /api/clips/[id]/watched Updates watch percent without marking the clip as watched. Only updates existing watched records — does not create new ones. Used for periodic progress tracking while the user is still viewing. @@ -281,11 +281,12 @@ Response: { "count": 5 } ``` ### POST /api/queue/post -Posts selected queued clips to the feed, spending one credit each. Only the caller's own `'queued'` entries are eligible; the server self-limits to available credits (extra IDs are skipped once credits run out). +Posts selected queued clips to the feed, spending one credit each. Only the caller's own `'queued'` entries are eligible; the server self-limits to available credits (extra IDs are skipped once credits run out) and to the group's daily post cap. ``` Request: { "ids": ["entry-id-1", "entry-id-2"] } -Response: { "posted": ["clip-id-1", "clip-id-2"], "credits": 3, "queueLength": 0 } +Response: { "posted": ["clip-id-1", "clip-id-2"], "credits": 3, "queueLength": 0, "nextPostAt": null } ``` +`nextPostAt` is an epoch-ms timestamp for when the daily post cap next frees a slot, or `null` when the user isn't capped. When fewer clips post than were requested and `nextPostAt` is set, the daily cap (not credits) stopped the rest — the client surfaces a "you can post again in …" countdown instead of failing silently. ### DELETE /api/queue/[id] Cancels a single queued clip. Only the uploader can cancel. diff --git a/docs/architecture.md b/docs/architecture.md index 32294e7..03f8df7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,7 +98,8 @@ scrolly/ │ │ │ ├── AddVideo.svelte # Add video form │ │ │ ├── AddVideoModal.svelte # Modal wrapper for AddVideo │ │ │ ├── AvatarCropModal.svelte # Profile picture crop UI -│ │ │ ├── QueueSheet.svelte # Bottom sheet: multi-select + post queued clips +│ │ │ ├── QueueSheet.svelte # Bottom sheet: multi-select + post queued clips (manual access) +│ │ │ ├── QueueGateSlide.svelte # Full-screen reciprocity gate slide (skip/post; NOT a sheet) │ │ │ ├── CreditDots.svelte # Tappable credits indicator (opens CreditInfoModal) │ │ │ ├── CreditInfoModal.svelte # Explains the watch-to-post economy │ │ │ ├── CatchUpModal.svelte # Catch-up modal for bulk unwatched clips diff --git a/src/lib/components/QueueGateSlide.svelte b/src/lib/components/QueueGateSlide.svelte new file mode 100644 index 0000000..a63ce71 --- /dev/null +++ b/src/lib/components/QueueGateSlide.svelte @@ -0,0 +1,523 @@ + + + + +{#if showInfo} + (showInfo = false)} /> +{/if} + + diff --git a/src/lib/components/QueueSheet.svelte b/src/lib/components/QueueSheet.svelte index 09aec64..4e2d589 100644 --- a/src/lib/components/QueueSheet.svelte +++ b/src/lib/components/QueueSheet.svelte @@ -6,8 +6,8 @@ import { confirm } from '$lib/stores/confirm'; import { toast } from '$lib/stores/toasts'; import { fetchQueueCount, queueCount } from '$lib/stores/queue'; - import { credits, reciprocityCap, setCredits } from '$lib/stores/credits'; - import { basename } from '$lib/utils'; + import { credits, reciprocityCap, setCredits, setNextPostAt } from '$lib/stores/credits'; + import { basename, formatPostCooldown } from '$lib/utils'; import QueueIcon from 'phosphor-svelte/lib/QueueIcon'; import TrashIcon from 'phosphor-svelte/lib/TrashIcon'; import QuestionIcon from 'phosphor-svelte/lib/QuestionIcon'; @@ -108,16 +108,34 @@ } const data = await res.json(); setCredits(data.credits); + setNextPostAt(data.nextPostAt); queueCount.set(data.queueLength); // `data.posted` is the clipIds that were published — drop those entries. const postedClipIds: string[] = data.posted ?? []; items = items.filter((i) => !postedClipIds.includes(i.clipId)); selected.clear(); - toast.success( - postedClipIds.length === 1 - ? 'Posted to the feed' - : `Posted ${postedClipIds.length} to the feed` - ); + + // Fewer posted than selected + a daily-cap timestamp → the rolling-24h ceiling stopped + // the rest. Explain when they can post again instead of failing silently ("Posted 0"). + const cappedShort = postedClipIds.length < ids.length && typeof data.nextPostAt === 'number'; + if (postedClipIds.length === 0) { + toast.error( + cappedShort + ? `Daily limit reached — you can post again in ${formatPostCooldown(data.nextPostAt)}` + : 'Could not post right now' + ); + return; // keep the sheet open so they can see the queue / retry + } else if (cappedShort) { + toast.success( + `Posted ${postedClipIds.length} — daily limit reached, more in ${formatPostCooldown(data.nextPostAt)}` + ); + } else { + toast.success( + postedClipIds.length === 1 + ? 'Posted to the feed' + : `Posted ${postedClipIds.length} to the feed` + ); + } ondismiss(); } catch { toast.error('Failed to post clips'); diff --git a/src/lib/feed.ts b/src/lib/feed.ts index 7ada4e0..96ee577 100644 --- a/src/lib/feed.ts +++ b/src/lib/feed.ts @@ -1,7 +1,7 @@ import type { FeedClip } from '$lib/types'; import { fetchUnwatchedCount } from '$lib/stores/notifications'; import { clearPushNotifications } from '$lib/push'; -import { setCredits } from '$lib/stores/credits'; +import { setCredits, setNextPostAt } from '$lib/stores/credits'; export type FeedFilter = 'all' | 'unwatched' | 'watched' | 'favorites' | 'uploads'; export type FeedSort = 'oldest' | 'round-robin' | 'best'; @@ -44,6 +44,7 @@ export async function markClipWatched(clipId: string): Promise { try { const data = await res.json(); setCredits(data.credits); + setNextPostAt(data.nextPostAt); } catch { /* no body — ignore */ } diff --git a/src/lib/server/credits.ts b/src/lib/server/credits.ts index e527da6..5ec9981 100644 --- a/src/lib/server/credits.ts +++ b/src/lib/server/credits.ts @@ -1,6 +1,6 @@ import { db } from '$lib/server/db'; import { users, groups, watched, clips } from '$lib/server/db/schema'; -import { eq, and, ne, gte, sql } from 'drizzle-orm'; +import { eq, and, ne, gte, asc, sql } from 'drizzle-orm'; import { createLogger } from '$lib/server/logger'; const log = createLogger('credits'); @@ -75,6 +75,35 @@ export function dailyPostsRemaining(userId: string, groupId: string): number | n return Math.max(0, dailyPostLimit - postsInLast24h(userId)); } +/** + * Epoch-ms timestamp when a daily-capped user can post again, or null if they aren't + * currently capped (no group limit, or still under it → can post now). + * + * A post counts against the cap for 24h. With the user sitting at exactly `limit` posts + * in the window, the *oldest* one ages out first and frees a slot — so the answer is that + * post's `createdAt + 24h`. If the count exceeds the limit (e.g. the host lowered it after + * the fact), the binding post is the one at rank `count - limit` from the oldest, since that + * many must expire before the count drops below the limit. + */ +export function nextPostAvailableAt(userId: string, groupId: string): number | null { + const { dailyPostLimit } = getReciprocityConfig(groupId); + if (dailyPostLimit === null) return null; + + const since = new Date(Date.now() - DAY_MS); + const rows = db + .select({ createdAt: clips.createdAt }) + .from(clips) + .where(and(eq(clips.addedBy, userId), eq(clips.status, 'ready'), gte(clips.createdAt, since))) + .orderBy(asc(clips.createdAt)) + .all(); + + if (rows.length < dailyPostLimit) return null; // under the cap — can post now + + // rows ascending (oldest first); the post at index (count - limit) frees a slot on expiry. + const binding = rows[rows.length - dailyPostLimit]; + return binding.createdAt.getTime() + DAY_MS; +} + /** * Current post-credit balance for a user (0 if user is missing). */ diff --git a/src/lib/stores/credits.ts b/src/lib/stores/credits.ts index 7f61661..f212f4e 100644 --- a/src/lib/stores/credits.ts +++ b/src/lib/stores/credits.ts @@ -7,18 +7,42 @@ export const credits = writable(0); /** The group's max credits (cap). Seeded from page.data on mount. */ export const reciprocityCap = writable(5); +/** + * Epoch-ms timestamp when the user may post again, or null when they aren't capped (no group + * daily limit, or still under it). Set from the layout seed and refreshed off watch/post + * responses as the rolling-24h window slides. + */ +export const nextPostAt = writable(null); + +/** + * Whether the user is currently blocked by the group's rolling-24h daily post cap. A capped + * user can't post anything (queue or direct), so the feed gate is pointless for them — it gets + * suppressed and a countdown is shown only when they actively try to post. + */ +export const dailyCapped = derived(nextPostAt, ($next) => $next !== null); + /** * The soft feed gate. Active when the user has earned the max credits AND has clips - * waiting in their queue — they must post one to keep scrolling. With an empty queue - * the gate never fires (credits simply stop accruing past the cap), so a user is never - * trapped with nothing to post. + * waiting in their queue — they must post one (or skip) to keep scrolling. With an empty + * queue the gate never fires (credits simply stop accruing past the cap), and a daily-capped + * user is also exempt since they have no way to post right now. */ export const gateActive = derived( - [credits, reciprocityCap, queueCount], - ([$credits, $cap, $queue]) => $credits >= $cap && $queue > 0 + [credits, reciprocityCap, queueCount, dailyCapped], + ([$credits, $cap, $queue, $capped]) => $credits >= $cap && $queue > 0 && !$capped ); /** Update the balance from an API response that reports `credits`. */ export function setCredits(value: number | undefined): void { if (typeof value === 'number') credits.set(value); } + +/** + * Update daily-cap state from an API response that reports `nextPostAt`. Pass the raw value + * (a future epoch-ms when capped, or null when not). `undefined` means the response didn't + * carry the field — leave the current state untouched. + */ +export function setNextPostAt(value: number | null | undefined): void { + if (value === undefined) return; + nextPostAt.set(typeof value === 'number' ? value : null); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a03a972..05093a5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,6 +3,23 @@ export function basename(filepath: string): string { return filepath.split('/').pop() || filepath; } +/** + * Format the wait until a future epoch-ms as a short, friendly duration ("3h 20m", "12m", + * "under a minute"). Used to tell a daily-capped user when they can post again. Returns + * "now" if the timestamp is already in the past. + */ +export function formatPostCooldown(nextPostAt: number, now: number = Date.now()): string { + const ms = nextPostAt - now; + if (ms <= 0) return 'now'; + const totalMinutes = Math.ceil(ms / 60000); + if (totalMinutes < 1) return 'under a minute'; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours === 0) return `${minutes}m`; + if (minutes === 0) return `${hours}h`; + return `${hours}h ${minutes}m`; +} + export function relativeTime(dateStr: string): string { const now = Date.now(); const then = new Date(dateStr).getTime(); diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index 306b107..ae14e28 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit'; import { dev } from '$app/environment'; import type { LayoutServerLoad } from './$types'; import { env } from '$env/dynamic/private'; +import { nextPostAvailableAt } from '$lib/server/credits'; export const load: LayoutServerLoad = async ({ locals, url }) => { const returnTo = url.search ? url.pathname + url.search : ''; @@ -15,6 +16,9 @@ export const load: LayoutServerLoad = async ({ locals, url }) => { user: locals.user, group: locals.group, vapidPublicKey: env.VAPID_PUBLIC_KEY || '', - gifEnabled: !!env.GIPHY_API_KEY || dev + gifEnabled: !!env.GIPHY_API_KEY || dev, + // Seed the daily-cap state so the post gate is suppressed from first paint when the + // user is already at their rolling-24h ceiling (null = not capped). + nextPostAt: locals.group ? nextPostAvailableAt(locals.user.id, locals.group.id) : null }; }; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index e982dd3..e7198e6 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -12,7 +12,7 @@ stopPolling } from '$lib/stores/notifications'; import { queueCount, fetchQueueCount } from '$lib/stores/queue'; - import { credits, reciprocityCap } from '$lib/stores/credits'; + import { credits, reciprocityCap, setNextPostAt } from '$lib/stores/credits'; import { globalMuted } from '$lib/stores/mute'; import { initAudioContext } from '$lib/audio/normalizer'; import { fetchGroupMembers } from '$lib/stores/members'; @@ -66,6 +66,7 @@ // Seed reciprocity credit state for the feed gate if (user) credits.set(user.postCredits ?? 0); if (page.data?.group) reciprocityCap.set(page.data.group.reciprocityCap ?? 5); + setNextPostAt(page.data?.nextPostAt ?? null); fetchQueueCount(); // Initialize AudioContext on first user interaction (for volume normalization) diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 3c859dd..aed7f4e 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -9,6 +9,7 @@ import CheckCircleIcon from 'phosphor-svelte/lib/CheckCircleIcon'; import { addVideoModalOpen } from '$lib/stores/addVideoModal'; import ClipOverlay from '$lib/components/ClipOverlay.svelte'; + import QueueGateSlide from '$lib/components/QueueGateSlide.svelte'; import ShortcutUpgradeBanner from '$lib/components/ShortcutUpgradeBanner.svelte'; import { addToast, toast, clipReadySignal, clipOverlaySignal } from '$lib/stores/toasts'; import { @@ -25,7 +26,6 @@ import { feedUiHidden } from '$lib/stores/uiHidden'; import { anySheetOpen } from '$lib/stores/sheetOpen'; import { gateActive } from '$lib/stores/credits'; - import { queueSheetOpen } from '$lib/stores/queueSheet'; import { get } from 'svelte/store'; import { onMount } from 'svelte'; import { page } from '$app/state'; @@ -197,32 +197,58 @@ } } - // Reciprocity gate: once you've banked the max credits AND have clips waiting in your - // queue, you must post one before scrolling forward to the next clip. Backward scroll is - // always allowed; an empty queue never gates. - let gateShownForEpisode = $state(false); - - function openGate() { - gateShownForEpisode = true; - queueSheetOpen.set(true); + // Reciprocity gate: once you've banked the max credits AND have clips waiting in your queue, + // a dedicated post slide takes over instead of the next clip. You either POST a queued clip + // or SKIP. Backward scroll is always allowed; an empty queue (or a daily-capped user, who + // can't post anyway) never gates. SKIP is a soft nudge — the gate re-arms after a short + // grace window of further clips rather than blocking the very next swipe. + const GATE_SKIP_GRACE = 3; + let showGate = $state(false); + let skipGraceUntil = $state(-1); + + // The gate blocks forward motion only when it's active AND we're past any post-skip grace. + function gateShouldBlock(): boolean { + return get(gateActive) && activeIndex >= skipGraceUntil; } - // Re-arm the gate after the user posts (credits drop below cap) or empties the queue. + // Drive the gate slide reactively. + // + // `skipGraceUntil` is a FORWARD high-water mark on activeIndex, not a flag to reset: after a + // skip or a post we set it to activeIndex + GRACE, and the gate stays suppressed until the + // user scrolls past it. We deliberately do NOT clear it when `gateActive` momentarily goes + // false — because posting drops a credit (gate off) and then watching the very next reel earns + // it straight back to the cap (gate on again). Without a persistent grace that would re-open + // the gate on the next swipe, an endless post→scroll→gate loop. The grace expires naturally as + // activeIndex advances, so it re-arms after a few clips. $effect(() => { - if (!$gateActive) gateShownForEpisode = false; + if (!$gateActive || activeIndex < skipGraceUntil) { + showGate = false; + return; + } + // Past the grace window and gated. Only ever flip ON here (never off based on anySheetOpen, + // or the gate's own sheet-depth registration would immediately hide it). + if (!overlayActive && !get(anySheetOpen)) showGate = true; }); - // Surface the post sheet the moment the gate engages, so the block is explained rather - // than just feeling stuck. Only auto-opens once per episode and never over another sheet. - $effect(() => { - if ($gateActive && !gateShownForEpisode && !get(anySheetOpen)) openGate(); - }); + function skipGate() { + // Soft nudge: keep scrolling for a few clips before the gate comes back. + skipGraceUntil = activeIndex + GATE_SKIP_GRACE; + showGate = false; + } + + function handleGatePosted() { + // Arm the skip grace from here so the gate doesn't immediately re-open after the credit a + // post spends is earned right back by watching the next reel. Then advance as if scrolled. + skipGraceUntil = activeIndex + GATE_SKIP_GRACE; + showGate = false; + scrollToIndex(activeIndex + 1); + } function scrollToIndex(index: number) { if (!scrollContainer || index < 0) return; - // Forward advance while gated → open the post sheet instead of moving on. - if (index > activeIndex && get(gateActive)) { - openGate(); + // Forward advance while the gate is blocking → show the post slide instead of moving on. + if (index > activeIndex && gateShouldBlock()) { + showGate = true; return; } const slots = scrollContainer.querySelectorAll('.reel-slot'); @@ -946,7 +972,7 @@ {:else} -
+
{#each clips as clip, i (clip.id)}
{#if Math.abs(i - activeIndex) <= renderWindow} @@ -1034,6 +1060,10 @@ {/if} +{#if showGate} + +{/if} + {#if overlayClipId} { // Parse optional watchPercent from body @@ -53,7 +53,13 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u grantWatchCredit(user.id, clip.groupId, clip.createdAt); } - return json({ watched: true, credits: getCredits(user.id) }); + // nextPostAt lets the feed keep its daily-cap state fresh as the user scrolls (the rolling + // 24h window slides), so the post gate stays suppressed for as long as they're capped. + return json({ + watched: true, + credits: getCredits(user.id), + nextPostAt: nextPostAvailableAt(user.id, clip.groupId) + }); }); // PATCH: update watchPercent only — creates the row if needed but does NOT count as "watched" diff --git a/src/routes/api/queue/post/+server.ts b/src/routes/api/queue/post/+server.ts index 81d0b04..367771a 100644 --- a/src/routes/api/queue/post/+server.ts +++ b/src/routes/api/queue/post/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { withAuth, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; import { postQueuedClips, getQueueCount } from '$lib/server/queue'; -import { getCredits } from '$lib/server/credits'; +import { getCredits, nextPostAvailableAt } from '$lib/server/credits'; /** * Post selected queued clips to the feed. Spends one credit per clip; the server @@ -20,9 +20,15 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user }) => { const posted = await postQueuedClips(user.id, user.groupId, ids); + // nextPostAt is non-null only when the rolling-24h daily cap is now blocking further posts. + // If fewer clips posted than were selected and the user still has credits, the daily cap is + // the reason — the client uses this to explain the stall ("you can post again in …"). + const nextPostAt = nextPostAvailableAt(user.id, user.groupId); + return json({ posted, credits: getCredits(user.id), - queueLength: getQueueCount(user.id, user.groupId) + queueLength: getQueueCount(user.id, user.groupId), + nextPostAt }); }); From 3e3a1b74127f66494423c660ccea8914519d5ef2 Mon Sep 17 00:00:00 2001 From: GraysonCAdams Date: Sun, 31 May 2026 13:12:17 -0500 Subject: [PATCH 2/2] test(reciprocity): cover gate slide, daily-cap countdown, nextPostAvailableAt - unit: nextPostAvailableAt (no cap / under cap / at cap / over cap) - unit: formatPostCooldown formatting - api: queue/post reports nextPostAt and posts nothing when daily-capped - e2e: QueueGateSlide engages at cap, SKIP dismisses, POST publishes + dismisses (and stays dismissed despite the re-earned credit), and the gate never engages with an empty queue - e2e: drop queue-interactions' seeded user below the cap so the manual QueueSheet isn't shadowed by the auto-showing gate slide --- e2e/reciprocity/gate-slide.spec.ts | 103 +++++++++++++++++++++++ e2e/visual/queue-interactions.spec.ts | 5 ++ src/lib/__tests__/utils.test.ts | 28 +++++- src/lib/server/__tests__/credits.test.ts | 42 ++++++++- src/routes/api/__tests__/queue.test.ts | 47 +++++++++++ 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 e2e/reciprocity/gate-slide.spec.ts diff --git a/e2e/reciprocity/gate-slide.spec.ts b/e2e/reciprocity/gate-slide.spec.ts new file mode 100644 index 0000000..a92b043 --- /dev/null +++ b/e2e/reciprocity/gate-slide.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '../fixtures/test'; +import { seedUser, seedClip } from '../helpers/seed'; +import * as schema from '../../src/lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { v4 as uuid } from 'uuid'; +import type { E2eDb } from '../fixtures/db'; + +/** + * The reciprocity feed gate: once a user has banked the cap AND has clips waiting in their + * queue, a dedicated full-screen QueueGateSlide takes over instead of the next reel. It is NOT + * a bottom sheet — the only way off is the SKIP (soft nudge) or POST button. + */ +test.describe('reciprocity: feed gate slide', () => { + function setCredits(db: E2eDb, userId: string, n: number) { + (db as any) + .update(schema.users) + .set({ postCredits: n }) + .where(eq(schema.users.id, userId)) + .run(); + } + + function seedQueued(db: E2eDb, groupId: string, userId: string) { + const clip = seedClip(db, { groupId, addedBy: userId, status: 'queued' }); + (db as any) + .insert(schema.clipQueue) + .values({ id: uuid(), clipId: clip.id, userId, groupId, createdAt: new Date() }) + .run(); + return clip; + } + + // Cap is 5 by default; sit the user at the cap with clips waiting → the gate should engage. + async function primeGate(db: E2eDb, loggedIn: { userId: string; groupId: string }) { + setCredits(db, loggedIn.userId, 5); + const other = seedUser(db, { groupId: loggedIn.groupId }); + seedClip(db, { groupId: loggedIn.groupId, addedBy: other.id, status: 'ready' }); + seedQueued(db, loggedIn.groupId, loggedIn.userId); + seedQueued(db, loggedIn.groupId, loggedIn.userId); + } + + test('engages at the cap and SKIP dismisses it (soft nudge)', async ({ page, loggedIn, db }) => { + await primeGate(db, loggedIn); + await page.goto('/'); + + const gate = page.locator('.gate[role="dialog"]'); + await expect(gate).toBeVisible({ timeout: 8000 }); + await expect(gate).toContainText('Post one to keep scrolling'); + await expect(gate.locator('.queue-item')).toHaveCount(2); + + // It must NOT be the drag-to-dismiss bottom sheet. + await expect(page.locator('.base-sheet')).toHaveCount(0); + + // Retry the dismiss — a tap landing during the entrance animation can be dropped under + // heavy parallel CPU load. Skipping is idempotent, so a repeat tap is harmless. + const skipBtn = gate.getByRole('button', { name: 'Skip for now' }); + await expect(async () => { + if (await gate.isVisible()) await skipBtn.click({ timeout: 2000 }).catch(() => {}); + await expect(gate).toBeHidden({ timeout: 2000 }); + }).toPass({ timeout: 12000 }); + }); + + test('POST publishes a queued clip and dismisses the gate', async ({ + page, + request, + loggedIn, + db + }) => { + await primeGate(db, loggedIn); + await page.goto('/'); + + const gate = page.locator('.gate[role="dialog"]'); + await expect(gate).toBeVisible({ timeout: 8000 }); + + await gate.locator('.select-box').first().click(); + const postBtn = gate.locator('.post-btn'); + await expect(postBtn).toBeEnabled(); + await expect(postBtn).toContainText('Post 1'); + + // Wait for the post round-trip so we don't race the response. + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes('/api/queue/post') && r.request().method() === 'POST' + ), + postBtn.click() + ]); + + // Gate dismisses and stays gone (the credit spent is earned back by watching the next reel, + // but the post grace keeps the gate from instantly re-opening). One clip left the queue. + await expect(gate).toBeHidden({ timeout: 8000 }); + const count = await (await request.get('/api/queue/count')).json(); + expect(count.count).toBe(1); + }); + + test('never engages when the queue is empty', async ({ page, loggedIn, db }) => { + setCredits(db, loggedIn.userId, 5); // at the cap, but nothing queued + const other = seedUser(db, { groupId: loggedIn.groupId }); + seedClip(db, { groupId: loggedIn.groupId, addedBy: other.id, status: 'ready' }); + await page.goto('/'); + + // Give the feed a moment to settle, then assert the gate stayed away. + await page.waitForTimeout(1500); + await expect(page.locator('.gate[role="dialog"]')).toHaveCount(0); + }); +}); diff --git a/e2e/visual/queue-interactions.spec.ts b/e2e/visual/queue-interactions.spec.ts index adb58ea..59d2385 100644 --- a/e2e/visual/queue-interactions.spec.ts +++ b/e2e/visual/queue-interactions.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '../fixtures/test'; import { seedClip } from '../helpers/seed'; import * as schema from '../../src/lib/server/db/schema'; +import { eq } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { mkdirSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -46,6 +47,10 @@ function seedQueue( .run(); ids.push(c.id); } + // These specs cover the MANUAL queue sheet (opened via ?queue=true), not the reciprocity + // feed gate. Drop credits below the cap (default 5) so gateActive stays false and the + // full-screen QueueGateSlide doesn't auto-appear over the sheet. 4 is enough to post 2. + (db as any).update(schema.users).set({ postCredits: 4 }).where(eq(schema.users.id, userId)).run(); return ids; } diff --git a/src/lib/__tests__/utils.test.ts b/src/lib/__tests__/utils.test.ts index 00cf53d..21bd067 100644 --- a/src/lib/__tests__/utils.test.ts +++ b/src/lib/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { basename, relativeTime } from '../utils'; +import { basename, relativeTime, formatPostCooldown } from '../utils'; describe('basename', () => { it('returns filename from path', () => { @@ -81,3 +81,29 @@ describe('relativeTime', () => { vi.useRealTimers(); }); }); + +describe('formatPostCooldown', () => { + const NOW = 1_000_000_000_000; + + it('returns "now" when the timestamp is in the past', () => { + expect(formatPostCooldown(NOW - 5000, NOW)).toBe('now'); + expect(formatPostCooldown(NOW, NOW)).toBe('now'); + }); + + it('rounds sub-minute waits up to a minute label', () => { + expect(formatPostCooldown(NOW + 30 * 1000, NOW)).toBe('1m'); + }); + + it('formats minutes only when under an hour', () => { + expect(formatPostCooldown(NOW + 12 * 60 * 1000, NOW)).toBe('12m'); + }); + + it('formats whole hours without a minutes part', () => { + expect(formatPostCooldown(NOW + 3 * 60 * 60 * 1000, NOW)).toBe('3h'); + }); + + it('formats hours and minutes together', () => { + const ms = (3 * 60 + 20) * 60 * 1000; + expect(formatPostCooldown(NOW + ms, NOW)).toBe('3h 20m'); + }); +}); diff --git a/src/lib/server/__tests__/credits.test.ts b/src/lib/server/__tests__/credits.test.ts index 8cfc20d..0256248 100644 --- a/src/lib/server/__tests__/credits.test.ts +++ b/src/lib/server/__tests__/credits.test.ts @@ -14,9 +14,15 @@ const { spendCredit, earnCredit, grantWatchCredit, - dailyPostsRemaining + dailyPostsRemaining, + nextPostAvailableAt } = await import('../credits'); +const DAY_MS = 24 * 60 * 60 * 1000; +// SQLite stores timestamps at second precision, so build test dates on whole-second +// boundaries to make round-tripped createdAt values compare exactly. +const secondsAgo = (s: number) => new Date(Math.floor(Date.now() / 1000) * 1000 - s * 1000); + function setup( opts: { seed?: number; @@ -246,3 +252,37 @@ describe('dailyPostsRemaining (daily cap)', () => { expect(dailyPostsRemaining(userId, groupId)).toBe(0); }); }); + +describe('nextPostAvailableAt (daily-cap countdown)', () => { + it('returns null when the group has no daily cap', () => { + const { groupId, userId } = setup({ dailyPostLimit: null }); + insertClip(groupId, userId); + expect(nextPostAvailableAt(userId, groupId)).toBeNull(); + }); + + it('returns null when the user is still under the cap', () => { + const { groupId, userId } = setup({ dailyPostLimit: 3 }); + insertClip(groupId, userId); + insertClip(groupId, userId); // 2 of 3 — can still post + expect(nextPostAvailableAt(userId, groupId)).toBeNull(); + }); + + it('at the cap, frees up 24h after the OLDEST post', () => { + const { groupId, userId } = setup({ dailyPostLimit: 2 }); + const oldest = secondsAgo(10 * 3600); // 10h ago + const newer = secondsAgo(3600); // 1h ago + insertClip(groupId, userId, oldest); + insertClip(groupId, userId, newer); + expect(nextPostAvailableAt(userId, groupId)).toBe(oldest.getTime() + DAY_MS); + }); + + it('over the cap (limit lowered after the fact), uses the rank-(count−limit) post', () => { + const { groupId, userId } = setup({ dailyPostLimit: 1 }); + const t0 = secondsAgo(5 * 3600); // oldest + const t1 = secondsAgo(3 * 3600); // binding post (frees the 1 slot) + insertClip(groupId, userId, t0); + insertClip(groupId, userId, t1); + // count=2, limit=1 → binding = rows[count - limit] = rows[1] = t1 + expect(nextPostAvailableAt(userId, groupId)).toBe(t1.getTime() + DAY_MS); + }); +}); diff --git a/src/routes/api/__tests__/queue.test.ts b/src/routes/api/__tests__/queue.test.ts index 645665b..b72aed4 100644 --- a/src/routes/api/__tests__/queue.test.ts +++ b/src/routes/api/__tests__/queue.test.ts @@ -208,4 +208,51 @@ describe('POST /api/queue/post', () => { const body = await res.json(); expect(body.posted).toHaveLength(0); }); + + it('reports nextPostAt and posts nothing when the daily cap is hit', async () => { + // Cap the group at 2/24h and pre-fill it with 2 recent ready posts by the host. + (db as any) + .update(schema.groups) + .set({ dailyPostLimit: 2 }) + .where(eq(schema.groups.id, data.group.id)) + .run(); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + for (let i = 0; i < 2; i++) { + (db as any) + .insert(schema.clips) + .values({ + id: uuid(), + groupId: data.group.id, + addedBy: data.host.id, + originalUrl: `https://ready/${uuid()}`, + platform: 'tiktok', + status: 'ready', + contentType: 'video', + createdAt: oneHourAgo + }) + .run(); + } + const a = enqueue(); + setCredits(data.host.id, 5); // plenty of credits — the daily cap is what stops them + + const event = createMockEvent({ + method: 'POST', + path: '/api/queue/post', + body: { ids: [a.qId] }, + user: data.host, + group: data.group + }); + const res = await queuePostMod.POST(event); + const body = await res.json(); + expect(body.posted).toHaveLength(0); // not a silent failure — caller learns why via nextPostAt + expect(typeof body.nextPostAt).toBe('number'); + expect(body.nextPostAt).toBeGreaterThan(Date.now()); + + // restore so later tests aren't capped + (db as any) + .update(schema.groups) + .set({ dailyPostLimit: null }) + .where(eq(schema.groups.id, data.group.id)) + .run(); + }); });