Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions e2e/reciprocity/gate-slide.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 5 additions & 0 deletions e2e/visual/queue-interactions.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
28 changes: 27 additions & 1 deletion src/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
Loading
Loading