Skip to content

Commit 90a6324

Browse files
feat(reciprocity): dedicated post-gate slide + daily-cap countdown (#185)
Replace the bottom-sheet feed gate with a dedicated full-screen QueueGateSlide (Skip/Post, no scroll-through). Suppress the gate for daily-capped users and surface a 'post again in Xh Ym' countdown on explicit post attempts instead of a silent 'Posted 0'. Key the gate re-arm to feed progression so the credit a post spends — earned right back by watching the next reel — can't instantly re-open it.
1 parent 3a134f2 commit 90a6324

18 files changed

Lines changed: 930 additions & 48 deletions

File tree

docs/api.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,9 @@ Returns dismissed clips with thumbnail, platform, uploader info, and dismissal t
161161
### POST /api/clips/[id]/watched
162162
```
163163
Request: { "watchPercent": 85 } (optional, 0–100)
164-
Response: { "watched": true, "credits": 3 }
164+
Response: { "watched": true, "credits": 3, "nextPostAt": null }
165165
```
166-
`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.
166+
`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.
167167

168168
### PATCH /api/clips/[id]/watched
169169
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 }
281281
```
282282

283283
### POST /api/queue/post
284-
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).
284+
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.
285285
```
286286
Request: { "ids": ["entry-id-1", "entry-id-2"] }
287-
Response: { "posted": ["clip-id-1", "clip-id-2"], "credits": 3, "queueLength": 0 }
287+
Response: { "posted": ["clip-id-1", "clip-id-2"], "credits": 3, "queueLength": 0, "nextPostAt": null }
288288
```
289+
`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.
289290

290291
### DELETE /api/queue/[id]
291292
Cancels a single queued clip. Only the uploader can cancel.

docs/architecture.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ scrolly/
9898
│ │ │ ├── AddVideo.svelte # Add video form
9999
│ │ │ ├── AddVideoModal.svelte # Modal wrapper for AddVideo
100100
│ │ │ ├── AvatarCropModal.svelte # Profile picture crop UI
101-
│ │ │ ├── QueueSheet.svelte # Bottom sheet: multi-select + post queued clips
101+
│ │ │ ├── QueueSheet.svelte # Bottom sheet: multi-select + post queued clips (manual access)
102+
│ │ │ ├── QueueGateSlide.svelte # Full-screen reciprocity gate slide (skip/post; NOT a sheet)
102103
│ │ │ ├── CreditDots.svelte # Tappable credits indicator (opens CreditInfoModal)
103104
│ │ │ ├── CreditInfoModal.svelte # Explains the watch-to-post economy
104105
│ │ │ ├── CatchUpModal.svelte # Catch-up modal for bulk unwatched clips

e2e/reciprocity/gate-slide.spec.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { test, expect } from '../fixtures/test';
2+
import { seedUser, seedClip } from '../helpers/seed';
3+
import * as schema from '../../src/lib/server/db/schema';
4+
import { eq } from 'drizzle-orm';
5+
import { v4 as uuid } from 'uuid';
6+
import type { E2eDb } from '../fixtures/db';
7+
8+
/**
9+
* The reciprocity feed gate: once a user has banked the cap AND has clips waiting in their
10+
* queue, a dedicated full-screen QueueGateSlide takes over instead of the next reel. It is NOT
11+
* a bottom sheet — the only way off is the SKIP (soft nudge) or POST button.
12+
*/
13+
test.describe('reciprocity: feed gate slide', () => {
14+
function setCredits(db: E2eDb, userId: string, n: number) {
15+
(db as any)
16+
.update(schema.users)
17+
.set({ postCredits: n })
18+
.where(eq(schema.users.id, userId))
19+
.run();
20+
}
21+
22+
function seedQueued(db: E2eDb, groupId: string, userId: string) {
23+
const clip = seedClip(db, { groupId, addedBy: userId, status: 'queued' });
24+
(db as any)
25+
.insert(schema.clipQueue)
26+
.values({ id: uuid(), clipId: clip.id, userId, groupId, createdAt: new Date() })
27+
.run();
28+
return clip;
29+
}
30+
31+
// Cap is 5 by default; sit the user at the cap with clips waiting → the gate should engage.
32+
async function primeGate(db: E2eDb, loggedIn: { userId: string; groupId: string }) {
33+
setCredits(db, loggedIn.userId, 5);
34+
const other = seedUser(db, { groupId: loggedIn.groupId });
35+
seedClip(db, { groupId: loggedIn.groupId, addedBy: other.id, status: 'ready' });
36+
seedQueued(db, loggedIn.groupId, loggedIn.userId);
37+
seedQueued(db, loggedIn.groupId, loggedIn.userId);
38+
}
39+
40+
test('engages at the cap and SKIP dismisses it (soft nudge)', async ({ page, loggedIn, db }) => {
41+
await primeGate(db, loggedIn);
42+
await page.goto('/');
43+
44+
const gate = page.locator('.gate[role="dialog"]');
45+
await expect(gate).toBeVisible({ timeout: 8000 });
46+
await expect(gate).toContainText('Post one to keep scrolling');
47+
await expect(gate.locator('.queue-item')).toHaveCount(2);
48+
49+
// It must NOT be the drag-to-dismiss bottom sheet.
50+
await expect(page.locator('.base-sheet')).toHaveCount(0);
51+
52+
// Retry the dismiss — a tap landing during the entrance animation can be dropped under
53+
// heavy parallel CPU load. Skipping is idempotent, so a repeat tap is harmless.
54+
const skipBtn = gate.getByRole('button', { name: 'Skip for now' });
55+
await expect(async () => {
56+
if (await gate.isVisible()) await skipBtn.click({ timeout: 2000 }).catch(() => {});
57+
await expect(gate).toBeHidden({ timeout: 2000 });
58+
}).toPass({ timeout: 12000 });
59+
});
60+
61+
test('POST publishes a queued clip and dismisses the gate', async ({
62+
page,
63+
request,
64+
loggedIn,
65+
db
66+
}) => {
67+
await primeGate(db, loggedIn);
68+
await page.goto('/');
69+
70+
const gate = page.locator('.gate[role="dialog"]');
71+
await expect(gate).toBeVisible({ timeout: 8000 });
72+
73+
await gate.locator('.select-box').first().click();
74+
const postBtn = gate.locator('.post-btn');
75+
await expect(postBtn).toBeEnabled();
76+
await expect(postBtn).toContainText('Post 1');
77+
78+
// Wait for the post round-trip so we don't race the response.
79+
await Promise.all([
80+
page.waitForResponse(
81+
(r) => r.url().includes('/api/queue/post') && r.request().method() === 'POST'
82+
),
83+
postBtn.click()
84+
]);
85+
86+
// Gate dismisses and stays gone (the credit spent is earned back by watching the next reel,
87+
// but the post grace keeps the gate from instantly re-opening). One clip left the queue.
88+
await expect(gate).toBeHidden({ timeout: 8000 });
89+
const count = await (await request.get('/api/queue/count')).json();
90+
expect(count.count).toBe(1);
91+
});
92+
93+
test('never engages when the queue is empty', async ({ page, loggedIn, db }) => {
94+
setCredits(db, loggedIn.userId, 5); // at the cap, but nothing queued
95+
const other = seedUser(db, { groupId: loggedIn.groupId });
96+
seedClip(db, { groupId: loggedIn.groupId, addedBy: other.id, status: 'ready' });
97+
await page.goto('/');
98+
99+
// Give the feed a moment to settle, then assert the gate stayed away.
100+
await page.waitForTimeout(1500);
101+
await expect(page.locator('.gate[role="dialog"]')).toHaveCount(0);
102+
});
103+
});

e2e/visual/queue-interactions.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test, expect } from '../fixtures/test';
22
import { seedClip } from '../helpers/seed';
33
import * as schema from '../../src/lib/server/db/schema';
4+
import { eq } from 'drizzle-orm';
45
import { v4 as uuid } from 'uuid';
56
import { mkdirSync } from 'node:fs';
67
import { resolve } from 'node:path';
@@ -46,6 +47,10 @@ function seedQueue(
4647
.run();
4748
ids.push(c.id);
4849
}
50+
// These specs cover the MANUAL queue sheet (opened via ?queue=true), not the reciprocity
51+
// feed gate. Drop credits below the cap (default 5) so gateActive stays false and the
52+
// full-screen QueueGateSlide doesn't auto-appear over the sheet. 4 is enough to post 2.
53+
(db as any).update(schema.users).set({ postCredits: 4 }).where(eq(schema.users.id, userId)).run();
4954
return ids;
5055
}
5156

src/lib/__tests__/utils.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest';
2-
import { basename, relativeTime } from '../utils';
2+
import { basename, relativeTime, formatPostCooldown } from '../utils';
33

44
describe('basename', () => {
55
it('returns filename from path', () => {
@@ -81,3 +81,29 @@ describe('relativeTime', () => {
8181
vi.useRealTimers();
8282
});
8383
});
84+
85+
describe('formatPostCooldown', () => {
86+
const NOW = 1_000_000_000_000;
87+
88+
it('returns "now" when the timestamp is in the past', () => {
89+
expect(formatPostCooldown(NOW - 5000, NOW)).toBe('now');
90+
expect(formatPostCooldown(NOW, NOW)).toBe('now');
91+
});
92+
93+
it('rounds sub-minute waits up to a minute label', () => {
94+
expect(formatPostCooldown(NOW + 30 * 1000, NOW)).toBe('1m');
95+
});
96+
97+
it('formats minutes only when under an hour', () => {
98+
expect(formatPostCooldown(NOW + 12 * 60 * 1000, NOW)).toBe('12m');
99+
});
100+
101+
it('formats whole hours without a minutes part', () => {
102+
expect(formatPostCooldown(NOW + 3 * 60 * 60 * 1000, NOW)).toBe('3h');
103+
});
104+
105+
it('formats hours and minutes together', () => {
106+
const ms = (3 * 60 + 20) * 60 * 1000;
107+
expect(formatPostCooldown(NOW + ms, NOW)).toBe('3h 20m');
108+
});
109+
});

0 commit comments

Comments
 (0)