diff --git a/docs/api.md b/docs/api.md index b608d9d..c4fae60 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,7 +11,7 @@ All API routes are SvelteKit `+server.ts` files under `src/routes/api/`. Authent ### GET /api/auth ``` -Response: { user: { id, username, phone, groupId, themePreference, autoScroll, mutedByDefault, avatarPath }, group: { id, name, inviteCode, accentColor, retentionDays, ... } } +Response: { user: { id, username, phone, groupId, themePreference, autoScroll, mutedByDefault, avatarPath, postCredits }, group: { id, name, inviteCode, accentColor, retentionDays, reciprocitySeed, reciprocityCap, reciprocityRatio, ... } } ``` ### POST /api/auth @@ -81,17 +81,17 @@ Each clip includes: id, originalUrl, title, addedByUsername, addedByAvatar, stat ### POST /api/clips ``` Request: { "url": "https://tiktok.com/...", "title": "optional caption" } -Response: { "clip": { "id", "status": "downloading", "contentType" } } (201 Created) +Response: { "clip": { "id", "status": "downloading", "contentType" }, "queued": false, "credits": 4, "queueLength": 0 } (201 Created) ``` -Triggers the download pipeline via the active provider. Requires a download provider to be configured (see Settings). Returns immediately with status `downloading`. +Triggers the download pipeline via the active provider. Requires a download provider to be configured (see Settings). Returns immediately with status `downloading`. `queued` is `true` when the clip went to the user's queue instead of posting directly (no credit available or the queue was non-empty). The credit is spent at publish time, so a failed download costs nothing. ### POST /api/clips/share Authenticated via `?token=` query parameter (iOS Shortcut token) or session cookie (web view). Allows sharing clips without a session cookie. ``` Request: { "url": "https://tiktok.com/...", "phone": "+1234567890" } -Response: { "ok": true, "clipId": "...", "status": "downloading" } (201 Created) +Response: { "success": 1, "message": "...", "clipId": "...", "queued": false, "credits": 4, "queueLength": 0 } (201 Created) ``` -Also accepts `"phones": ["+1234567890"]` (array) for legacy Shortcut backward compatibility. Records legacy share timestamp for upgrade banner tracking. +Same reciprocity rule as `POST /api/clips`: posts directly when the sharer has a credit and an empty queue, otherwise holds it in the queue. Also accepts `"phones": ["+1234567890"]` (array) for legacy Shortcut backward compatibility. Records legacy share timestamp for upgrade banner tracking. ### GET /api/clips/[id] Returns full clip detail with user context, interaction state, and metadata. @@ -161,8 +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 } +Response: { "watched": true, "credits": 3 } ``` +`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. ### 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. @@ -245,62 +246,27 @@ Extends the trim deadline for a music clip in `pending_trim` status. The client Response: { "ok": true } ``` -## Clout (Reputation) +## Reciprocity (Credits) -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/clout` | Get user's clout score, tier, and breakdown | -| POST | `/api/clout` | Acknowledge tier change modal was shown | - -### GET /api/clout -Returns the user's clout score and tier when queue pacing is enabled. Clout is computed from the engagement on the user's last 10 eligible clips (watched by ≥75% of group). Users with fewer than 10 eligible clips default to Rising tier. -``` -Response: { - "enabled": true, - "score": 0.8, - "tier": "viral", - "tierName": "Viral", - "cooldownMinutes": 120, - "burstSize": 3, - "queueLimit": null, - "icon": "/icons/clout/viral.png", - "breakdown": [{ "clipId": "...", "score": 2 }, ...], - "nextTier": { "tier": "iconic", "tierName": "Iconic", "minScore": 1.0, "burst": 5, "queueLimit": null, "icon": "..." }, - "lastTier": "rising", - "tierChanged": true -} -``` - -**Tiers:** Fresh (<0.4) → Rising (0.4–0.6) → Viral (0.7–0.9) → Iconic (≥1.0). Each tier adjusts cooldown multiplier, burst size, and queue depth limit. - -**Per-clip scoring:** 0 = no reactions/favorites from others, 1 = reaction or favorite but no comment, 2 = reaction/favorite AND comment. Self-interactions excluded. Only clips watched by ≥75% of other group members are eligible. - -**Rank-down protection:** Rank-ups apply immediately. Rank-downs only take effect if the user has held their current tier for ≥4 days. The `cloutTierChangedAt` column tracks when the effective tier last changed. - -**Tier change detection:** The server tracks each user's last effective tier (`cloutTier`) and when it changed (`cloutTierChangedAt`). When the tier actually changes (after rank-down protection), `tierChanged: true` is returned. - -### POST /api/clout -Acknowledges that the tier change modal was shown. Updates the user's stored tier and resets the cooldown timer. -``` -Response: { "ok": true } -``` +Pacing is governed by a per-user credit balance: watching others' clips earns credits, posting spends them. See `docs/data-model.md` for the full model (including the launch cutoff that stops the pre-launch backlog from minting credits). The balance is surfaced on `GET /api/auth` (`user.postCredits`) and returned by the watch and queue-post endpoints; there is no standalone GET endpoint. ## Queue Management -Manage queued clips when share pacing is enabled. +The queue is an unlimited personal holding area. Clips wait here (`status: 'queued'`) until the user spends a credit to post them — there is no timed/automatic publishing. | Method | Path | Description | |--------|------|-------------| -| GET | `/api/queue` | List queued clips for user | +| GET | `/api/queue` | List queued clips for user (paginated) | | DELETE | `/api/queue` | Clear entire queue | | GET | `/api/queue/count` | Queued clip count | +| POST | `/api/queue/post` | Post selected queued clips to the feed | | DELETE | `/api/queue/[id]` | Cancel a queued clip | -| POST | `/api/queue/[id]/move-to-top` | Move entry to top of queue | -| PATCH | `/api/queue/reorder` | Reorder queue entries | ### GET /api/queue +Sorted newest-first by default. ``` -Response: { "queue": [{ "id", "clipId", "position", "scheduledAt", "sharesIn", "createdAt", "title", "originalUrl", "platform", "contentType", "status", "thumbnailPath" }] } +Query params: ?limit=20&offset=0&order=newest|oldest +Response: { "queue": [{ "id", "clipId", "createdAt", "title", "originalUrl", "platform", "contentType", "status", "thumbnailPath" }], "total": 12, "hasMore": true } ``` ### DELETE /api/queue @@ -314,22 +280,16 @@ Response: { "cleared": 3 } Response: { "count": 5 } ``` -### DELETE /api/queue/[id] -Cancels a single queued clip. Only the uploader can cancel. -``` -Response: { "ok": true } -``` - -### POST /api/queue/[id]/move-to-top -Moves a queue entry to position 0 (next to publish). +### 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). ``` -Response: { "ok": true } +Request: { "ids": ["entry-id-1", "entry-id-2"] } +Response: { "posted": ["clip-id-1", "clip-id-2"], "credits": 3, "queueLength": 0 } ``` -### PATCH /api/queue/reorder -Reorders all queue entries and recalculates scheduled publish times. +### DELETE /api/queue/[id] +Cancels a single queued clip. Only the uploader can cancel. ``` -Request: { "orderedIds": ["entry-id-1", "entry-id-2", "entry-id-3"] } Response: { "ok": true } ``` @@ -344,8 +304,7 @@ Host-only endpoints (unless noted). Requires `createdBy === currentUser`. | PATCH | `/api/group/retention` | Set retention policy | | PATCH | `/api/group/max-file-size` | Set max file size limit | | PATCH | `/api/group/platforms` | Set platform filter | -| PATCH | `/api/group/daily-share-limit` | Set daily share limit per user | -| PATCH | `/api/group/share-pacing` | Configure share pacing mode | +| PATCH | `/api/group/reciprocity` | Configure reciprocity pacing (seed/cap/ratio) | | GET | `/api/group/provider` | List download providers | | PATCH | `/api/group/provider` | Set active provider | | POST | `/api/group/provider/install` | Install a provider | @@ -389,23 +348,16 @@ Request: { "mode": "all", "platforms": [] } (mode: "all" | "allow" | "block") Response: { "platformFilterMode": "all", "platformFilterList": null } ``` -### PATCH /api/group/daily-share-limit -``` -Request: { "dailyShareLimit": 5 } (positive integer, or null to remove limit) -Response: { "dailyShareLimit": 5 } -``` - -### PATCH /api/group/share-pacing -Host-only. Configures queue-based share pacing. When switching away from `queue` mode, all queued clips are flushed to `ready`. +### PATCH /api/group/reciprocity +Host-only. Configures the watch-to-post credit economy. Partial updates merge with current values. Lowering the cap clamps existing members' balances down to it. ``` -Request: { "sharePacingMode": "queue", "shareBurst": 2, "shareCooldownMinutes": 120, "dailyShareLimit": null, "cloutEnabled": true } -Response: { "sharePacingMode": "queue", "shareBurst": 2, "shareCooldownMinutes": 120, "dailyShareLimit": null, "cloutEnabled": true } +Request: { "seed": 5, "cap": 5, "ratio": 1, "dailyPostLimit": null } +Response: { "seed": 5, "cap": 5, "ratio": 1, "dailyPostLimit": null } ``` -- `sharePacingMode`: `"off"` | `"daily_cap"` | `"queue"` -- `shareBurst`: 1–10 (clips per scheduled time slot) -- `shareCooldownMinutes`: 30 | 60 | 120 | 240 | 360 -- `dailyShareLimit`: positive integer or null -- `cloutEnabled`: boolean (enables reputation-based queue adjustments) +- `seed`: starting credits for new members (`0 ≤ seed ≤ cap`) +- `cap`: max credits a member can hold (`1`–`20`) +- `ratio`: qualifying watches needed to earn one credit (`1`–`10`) +- `dailyPostLimit`: hard ceiling on posts per rolling 24h, or `null` for unlimited (`1`–`100`) ### GET /api/group/provider ``` diff --git a/docs/architecture.md b/docs/architecture.md index 758fc54..32294e7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,8 @@ scrolly/ │ │ │ ├── auth.ts # Session management, invite code validation │ │ │ ├── push.ts # web-push wrapper, group notifications │ │ │ ├── share-limit.ts # Share pacing modes and daily limit enforcement -│ │ │ ├── queue.ts # Clip queue management (enqueue, publish, reorder) +│ │ │ ├── queue.ts # Personal holding queue (enqueue, publish, post-selected) +│ │ │ ├── credits.ts # Reciprocity credit economy (earn/spend/config/cutoff) │ │ │ ├── scheduler.ts # Retention policy enforcement (periodic cleanup) │ │ │ └── download-lock.ts # Prevents duplicate concurrent downloads │ │ ├── components/ @@ -97,10 +98,9 @@ scrolly/ │ │ │ ├── AddVideo.svelte # Add video form │ │ │ ├── AddVideoModal.svelte # Modal wrapper for AddVideo │ │ │ ├── AvatarCropModal.svelte # Profile picture crop UI -│ │ │ ├── QueueSheet.svelte # Bottom sheet for viewing/reordering queued clips -│ │ │ ├── QueueCloutBanner.svelte # Clout tier banner inside QueueSheet -│ │ │ ├── CloutChangeModal.svelte # Tier change celebration modal -│ │ │ ├── CloutTipsView.svelte # Clout tips/breakdown view (tier details, scoring) +│ │ │ ├── QueueSheet.svelte # Bottom sheet: multi-select + post queued clips +│ │ │ ├── CreditDots.svelte # Tappable credits indicator (opens CreditInfoModal) +│ │ │ ├── CreditInfoModal.svelte # Explains the watch-to-post economy │ │ │ ├── CatchUpModal.svelte # Catch-up modal for bulk unwatched clips │ │ │ ├── MeGrid.svelte # Profile clip grid (favorites/uploads) │ │ │ ├── MeReelView.svelte # Profile reel overlay view @@ -122,15 +122,13 @@ scrolly/ │ │ │ ├── PlatformIcon.svelte # Platform logo (TikTok, IG, etc.) │ │ │ ├── InlineError.svelte │ │ │ ├── FilterBar.svelte # Feed filter tabs -│ │ │ ├── ShareLimitDots.svelte # Daily share limit indicator dots │ │ │ ├── ShortcutGuideSheet.svelte # iOS Shortcut setup guide │ │ │ ├── ShortcutUpgradeBanner.svelte # Legacy shortcut upgrade prompt │ │ │ └── settings/ │ │ │ ├── GroupNameEdit.svelte │ │ │ ├── InviteLink.svelte │ │ │ ├── MemberList.svelte -│ │ │ ├── DailyShareLimitPicker.svelte # Daily per-user share limit control -│ │ │ ├── SharePacingPicker.svelte # Share pacing mode, burst, and cooldown config +│ │ │ ├── ReciprocityPicker.svelte # Watch-to-post seed/cap/ratio config │ │ │ ├── RetentionPicker.svelte │ │ │ ├── SkippedClips.svelte # Dismissed/skipped clips viewer with restore │ │ │ ├── ClipsManager.svelte @@ -155,6 +153,7 @@ scrolly/ │ │ │ ├── homeTap.ts # Double-tap home to scroll to top │ │ │ ├── catchUpModal.ts # Catch-up modal dismissal state (12-hour cooldown) │ │ │ ├── queue.ts # Queue count store and fetch function +│ │ │ ├── credits.ts # Reciprocity credits, cap, derived feed-gate state │ │ │ ├── queueSheet.ts # Queue sheet visibility state │ │ │ ├── shortcutNudge.ts # Share shortcut install nudge │ │ │ └── shortcutUpgrade.ts # Shortcut upgrade banner state @@ -191,12 +190,12 @@ scrolly/ │ │ │ │ └── [id]/publish/+server.ts # Publish after trim │ │ │ ├── gifs/ │ │ │ ├── queue/ -│ │ │ │ ├── +server.ts # GET list / DELETE clear queue +│ │ │ │ ├── +server.ts # GET list (paginated) / DELETE clear queue │ │ │ │ ├── count/+server.ts # GET queue count -│ │ │ │ ├── reorder/+server.ts # PATCH reorder entries +│ │ │ │ ├── post/+server.ts # POST selected queued clips to feed │ │ │ │ └── [id]/+server.ts # DELETE cancel entry │ │ │ ├── group/ -│ │ │ │ ├── share-pacing/+server.ts # PATCH configure share pacing +│ │ │ │ ├── reciprocity/+server.ts # PATCH configure watch-to-post pacing │ │ │ ├── notifications/ │ │ │ │ └── [id]/+server.ts # Delete single notification │ │ │ ├── profile/ diff --git a/docs/data-model.md b/docs/data-model.md index 1b6a95f..f566131 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -18,11 +18,11 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | download_provider | text | Nullable. Active download provider ID. | | platform_filter_mode | text | Default `'all'`. `'all'` / `'allow'` / `'block'`. | | platform_filter_list | text | Nullable. Comma-separated list of platforms for allow/block filtering. | -| daily_share_limit | integer | Nullable. Max clips per user per calendar day. | -| share_pacing_mode | text | Default `'off'`. `'off'` / `'daily_cap'` / `'queue'`. | -| share_burst | integer | Default 2. Clips per scheduled time slot in queue mode (1–10). | -| share_cooldown_minutes | integer | Default 120. Minutes between clip groups in queue mode. | -| clout_enabled | integer | Boolean (0/1). Default 1. Enables reputation-based queue adjustments. | +| reciprocity_seed | integer | Default 5. Credits a new member starts with. | +| reciprocity_cap | integer | Default 5. Max credits a member can hold at once. | +| reciprocity_ratio | integer | Default 1. Qualifying watches needed to earn one credit. | +| reciprocity_started_at | integer | Nullable. Launch cutoff — only clips created at/after this earn watch credits (backlog stays watchable but pays out zero). Stamped at group creation / first boot after the cutoff migration. | +| daily_post_limit | integer | Nullable. Hard ceiling on posts per rolling 24h, on top of credits. Null = unlimited (default). | | shortcut_token | text | Nullable, unique. Token for iOS Shortcut clip sharing. | | shortcut_url | text | Nullable. URL for iOS Shortcut integration. | | created_by | text | FK → users.id (host/admin) | @@ -43,9 +43,7 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | avatar_path | text | Nullable. Path to uploaded profile picture. | | last_legacy_share_at | integer | Nullable. Unix timestamp of last legacy shortcut share. Used for upgrade banner. | | used_new_share_flow | integer | Boolean (0/1). Default 0. Tracks if user has adopted new web view share flow. | -| clout_tier | text | Nullable. Last acknowledged clout tier (for tier change detection). | -| clout_change_shown_at | integer | Nullable. Unix timestamp when tier change modal was last shown. | -| clout_tier_changed_at | integer | Nullable. Unix timestamp when the user's effective clout tier last changed. Used for rank-down protection (4-day cooldown). | +| post_credits | integer | Default 5. Reciprocity credit balance (0..group cap). Earned by watching others' clips, spent by posting. | | removed_at | integer | Nullable. Unix timestamp when removed from group. | | created_at | integer | Unix timestamp | @@ -175,11 +173,9 @@ Unique constraint on `(clip_id, user_id)` — tracks clips dismissed by users in | clip_id | text | FK → clips.id | | user_id | text | FK → users.id | | group_id | text | FK → groups.id | -| position | integer | Order in queue (0-based) | -| scheduled_at | integer | Unix timestamp when clip will be published | | created_at | integer | Unix timestamp | -Index on `(user_id, group_id)` for efficient queue lookups. +Index on `(user_id, group_id)` for efficient queue lookups. The queue is an unlimited personal holding area: clips wait here (status `'queued'`) until the user spends a credit to post them. There is no scheduled/timed publishing — posting is always a deliberate user action. ### push_subscriptions @@ -253,4 +249,6 @@ users 1──∞ verification_codes - **Duplicate URL prevention:** A unique index on `(group_id, original_url)` prevents the same link from being shared twice within a group. - **Music clip trim workflow:** Music clips enter `pending_trim` status after download. The user can trim audio via the trim UI or skip trimming. If neither occurs before `trim_deadline`, the clip auto-publishes to `ready` status via the scheduler. - **Dismissed clips:** The `dismissed_clips` table tracks clips dismissed by users in the catch-up modal. Users can dismiss unwatched clips in bulk, then restore them later from the Skipped Clips viewer in settings. -- **Clout (reputation):** Computed on-demand from `reactions`, `favorites`, and `comments` tables. A user's clout score is the rolling average of per-clip engagement scores (0/1/2) for their last 10 eligible clips. Only clips watched by ≥75% of other group members are eligible. Self-interactions are excluded. Tiers (Fresh/Rising/Viral/Iconic) determine queue cooldown multiplier, burst size, and queue depth limits. Rank-ups apply immediately; rank-downs require the user to have held their current tier for ≥4 days before taking effect. The `clout_enabled` flag on `groups` controls whether clout adjustments are applied. The `clout_tier` and `clout_tier_changed_at` columns on `users` track the effective tier and when it last changed. +- **Reciprocity pacing (watch-to-post):** The single pacing system. Each user holds an integer credit balance (`users.post_credits`, range `0..group.reciprocity_cap`). Watching another member's clip for the first time earns one credit (every `reciprocity_ratio` qualifying watches); self-watches and repeat watches never earn. Posting a clip to the feed spends one credit. New members seed at `reciprocity_seed`. A clip posts straight to the feed only when the uploader has a credit *and* an empty queue; otherwise it waits in `clip_queue` until posted. Credits are spent at publish time, so failed downloads cost nothing. This produces a conservation law — group-wide, total posts can't exceed total watches-of-others — so a prolific poster's ceiling is the rest of the group's output. Replaces the former clout/burst/cooldown/daily-cap systems. +- **Daily post cap (`groups.daily_post_limit`):** Optional hard ceiling (null = unlimited, the default) on how many clips a member can post to the feed per rolling 24h, layered on top of credits. Enforced at the share decision, at publish (`setClipReady`), and when posting from the queue — over-cap clips are held in the queue rather than rejected. Useful as a rollout safety valve since credits alone bound instantaneous balance but not total daily volume. +- **Launch cutoff (`groups.reciprocity_started_at`):** Only clips created at/after this timestamp mint watch credits. Clips that predate it remain fully watchable in the feed but pay out zero, so adopting reciprocity over an existing backlog can't convert that backlog into a flood of posting credits. It's stamped at group creation, and backfilled to the deploy time for any pre-existing group on the first boot after the cutoff migration. The ratio counter only advances on eligible (post-cutoff, non-self) watches. diff --git a/e2e/api/group-host.spec.ts b/e2e/api/group-host.spec.ts index affb923..c0a2f26 100644 --- a/e2e/api/group-host.spec.ts +++ b/e2e/api/group-host.spec.ts @@ -19,15 +19,13 @@ test.describe('API: host group management', () => { expect(res.ok()).toBe(true); }); - test('PATCH /api/group/share-pacing accepts queue mode', async ({ request, loggedInAsHost }) => { + test('PATCH /api/group/reciprocity updates pacing config', async ({ + request, + loggedInAsHost + }) => { expect(loggedInAsHost.userId).toBeTruthy(); - const res = await request.patch('/api/group/share-pacing', { - data: { - sharePacingMode: 'queue', - shareBurst: 2, - shareCooldownMinutes: 60, - cloutEnabled: true - } + const res = await request.patch('/api/group/reciprocity', { + data: { seed: 3, cap: 8, ratio: 2 } }); expect(res.ok()).toBe(true); }); diff --git a/e2e/api/reciprocity.spec.ts b/e2e/api/reciprocity.spec.ts new file mode 100644 index 0000000..3dc77b2 --- /dev/null +++ b/e2e/api/reciprocity.spec.ts @@ -0,0 +1,86 @@ +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'; + +/** + * End-to-end credit lifecycle against the real server build: + * - sharing while out of credits queues the clip + * - watching another member's clip earns a credit + * - posting from the queue spends a credit and publishes + */ +test.describe('reciprocity: credit lifecycle', () => { + function setCredits(db: import('../fixtures/db').E2eDb, userId: string, n: number) { + (db as any) + .update(schema.users) + .set({ postCredits: n }) + .where(eq(schema.users.id, userId)) + .run(); + } + + test('sharing with no credits queues the clip', async ({ request, loggedIn, db }) => { + setCredits(db, loggedIn.userId, 0); + // The download pipeline needs an active provider; wire up the TEST_MODE fake one. + (db as any) + .update(schema.groups) + .set({ downloadProvider: 'fake' }) + .where(eq(schema.groups.id, loggedIn.groupId)) + .run(); + const res = await request.post('/api/clips', { + data: { url: 'https://www.tiktok.com/@x/video/recip-queue-1' } + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body.queued).toBe(true); + }); + + test('watching another member’s clip earns a credit', async ({ request, loggedIn, db }) => { + setCredits(db, loggedIn.userId, 0); + // A second member posts a clip the logged-in user can watch. + const other = seedUser(db, { groupId: loggedIn.groupId }); + const clip = seedClip(db, { + groupId: loggedIn.groupId, + addedBy: other.id, + status: 'ready' + }); + const res = await request.post(`/api/clips/${clip.id}/watched`, { data: {} }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.credits).toBe(1); + }); + + test('posting a queued clip spends a credit and clears it from the queue', async ({ + request, + loggedIn, + db + }) => { + setCredits(db, loggedIn.userId, 1); + // Seed a queued clip + holding-queue row for the logged-in user. + const clip = seedClip(db, { + groupId: loggedIn.groupId, + addedBy: loggedIn.userId, + status: 'queued' + }); + const entryId = uuid(); + (db as any) + .insert(schema.clipQueue) + .values({ + id: entryId, + clipId: clip.id, + userId: loggedIn.userId, + groupId: loggedIn.groupId, + createdAt: new Date() + }) + .run(); + + const res = await request.post('/api/queue/post', { data: { ids: [entryId] } }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.posted).toContain(clip.id); + expect(body.credits).toBe(0); + + const count = await (await request.get('/api/queue/count')).json(); + expect(count.count).toBe(0); + }); +}); diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index d25db02..c59cc01 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -25,9 +25,9 @@ export interface LoginOptions { username?: string; groupName?: string; accentColor?: string; - sharePacingMode?: 'off' | 'daily_cap' | 'queue'; - dailyShareLimit?: number | null; - cloutEnabled?: boolean; + reciprocitySeed?: number; + reciprocityCap?: number; + reciprocityRatio?: number; } /** @@ -43,9 +43,9 @@ export async function loginAs( const group = seedGroup(db, { name: opts.groupName, accentColor: opts.accentColor, - sharePacingMode: opts.sharePacingMode, - dailyShareLimit: opts.dailyShareLimit, - cloutEnabled: opts.cloutEnabled + reciprocitySeed: opts.reciprocitySeed, + reciprocityCap: opts.reciprocityCap, + reciprocityRatio: opts.reciprocityRatio }); const user = seedUser(db, { groupId: group.id, diff --git a/e2e/helpers/page-listeners.ts b/e2e/helpers/page-listeners.ts index e5d628e..e072a33 100644 --- a/e2e/helpers/page-listeners.ts +++ b/e2e/helpers/page-listeners.ts @@ -58,8 +58,11 @@ export function watchPage( const url = req.url(); if (matchesIgnoreReq(url)) return; const failure = req.failure()?.errorText ?? 'unknown'; - // Filter out aborted requests — they often happen during navigation. - if (failure.includes('aborted') || failure.includes('Cancelled')) return; + // Filter out aborted/cancelled requests — they happen routinely during navigation as + // in-flight video/font loads are torn down. Match case-insensitively and both spellings: + // Chromium says "net::ERR_ABORTED"/"canceled", WebKit says "Load request cancelled". + const failureLc = failure.toLowerCase(); + if (failureLc.includes('aborted') || failureLc.includes('cancel')) return; issues.push({ kind: 'request-failed', url, diff --git a/e2e/helpers/seed.ts b/e2e/helpers/seed.ts index b237e20..d0fa431 100644 --- a/e2e/helpers/seed.ts +++ b/e2e/helpers/seed.ts @@ -43,9 +43,9 @@ function ensureFixtureMedia( export interface SeedGroupOptions { name?: string; accentColor?: string; - dailyShareLimit?: number | null; - sharePacingMode?: 'off' | 'daily_cap' | 'queue'; - cloutEnabled?: boolean; + reciprocitySeed?: number; + reciprocityCap?: number; + reciprocityRatio?: number; } export function seedGroup(db: E2eDb, opts: SeedGroupOptions = {}) { @@ -59,9 +59,10 @@ export function seedGroup(db: E2eDb, opts: SeedGroupOptions = {}) { name: opts.name ?? 'E2E Group', inviteCode, accentColor: opts.accentColor ?? 'coral', - dailyShareLimit: opts.dailyShareLimit ?? null, - sharePacingMode: opts.sharePacingMode ?? 'off', - cloutEnabled: opts.cloutEnabled ?? true, + reciprocitySeed: opts.reciprocitySeed ?? 5, + reciprocityCap: opts.reciprocityCap ?? 5, + reciprocityRatio: opts.reciprocityRatio ?? 1, + reciprocityStartedAt: new Date(0), shortcutToken, createdAt: now }) diff --git a/e2e/visual/queue-interactions.spec.ts b/e2e/visual/queue-interactions.spec.ts index 7c60e41..adb58ea 100644 --- a/e2e/visual/queue-interactions.spec.ts +++ b/e2e/visual/queue-interactions.spec.ts @@ -15,7 +15,8 @@ function snapPath(project: string, name: string) { /** * Seeds N queued clips for the logged-in user. Each goes into both `clips` and - * `clip_queue` so the QueueSheet can render them with thumbnails and counts. + * `clip_queue` so the QueueSheet can render them. clip_queue.createdAt is staggered so + * newest-first ordering is deterministic. */ function seedQueue( db: import('../fixtures/db').E2eDb, @@ -40,9 +41,7 @@ function seedQueue( clipId: c.id, userId, groupId, - position: i, - scheduledAt: new Date(now + (i + 1) * 30 * 60_000), // 30 min apart - createdAt: new Date(now) + createdAt: new Date(now - (count - i) * 60_000) }) .run(); ids.push(c.id); @@ -51,9 +50,6 @@ function seedQueue( } async function openQueueSheet(page: import('@playwright/test').Page) { - // First load the feed cleanly, then navigate to /?queue=true so the layout - // effect that listens for `?queue=true` fires after hydration. Going - // straight to /?queue=true sometimes misses the effect on first paint. await page.goto('/'); await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {}); await page.evaluate(() => { @@ -64,11 +60,7 @@ async function openQueueSheet(page: import('@playwright/test').Page) { } test.describe('queue: list renders with seeded entries', () => { - test('opening ?queue=true shows all seeded items in order', async ({ - page, - loggedIn, - db - }, testInfo) => { + test('opening ?queue=true shows all seeded items', async ({ page, loggedIn, db }, testInfo) => { seedQueue(db, loggedIn.userId, loggedIn.groupId, 4); await openQueueSheet(page); await page.waitForTimeout(600); @@ -76,11 +68,8 @@ test.describe('queue: list renders with seeded entries', () => { const items = page.locator('.queue-item'); await expect(items).toHaveCount(4, { timeout: 8_000 }); - - // Titles should be present in seeded order - for (let i = 0; i < 4; i++) { - await expect(items.nth(i)).toContainText(`queued clip #${i + 1}`); - } + // Newest-first by default → clip #4 leads. + await expect(items.first()).toContainText('queued clip #4'); }); }); @@ -97,7 +86,6 @@ test.describe('queue: cancel single entry', () => { await expect(page.locator('.queue-item')).toHaveCount(3); await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-before-cancel') }); - // Cancel the second item await page.locator('.queue-item').nth(1).locator('button[aria-label="Remove"]').click(); await page.waitForTimeout(700); await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-after-cancel') }); @@ -106,82 +94,32 @@ test.describe('queue: cancel single entry', () => { }); }); -test.describe('queue: drag-reorder via pointer events', () => { - test('dragging the handle moves items and persists via API', async ({ +test.describe('queue: multi-select post', () => { + test('selecting clips and posting publishes them and spends credits', async ({ page, loggedIn, db, request }, testInfo) => { - seedQueue(db, loggedIn.userId, loggedIn.groupId, 4); + seedQueue(db, loggedIn.userId, loggedIn.groupId, 3); await openQueueSheet(page); await page.waitForTimeout(500); - await expect(page.locator('.queue-item')).toHaveCount(4); - - await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-before-drag') }); - - // Capture initial server-side order - const before = await request.get('/api/queue'); - const beforeBody = await before.json(); - const beforeIds = (beforeBody.queue ?? []).map((q: { clipId: string }) => q.clipId); - expect(beforeIds.length).toBe(4); - - // Drag the first item's handle down past three siblings, then drop. - const firstHandle = page.locator('.queue-item').first().locator('.drag-handle'); - const lastItem = page.locator('.queue-item').nth(3); - const fromBox = await firstHandle.boundingBox(); - const toBox = await lastItem.boundingBox(); - if (!fromBox || !toBox) throw new Error('Could not measure drag handle / target'); - - const startX = fromBox.x + fromBox.width / 2; - const startY = fromBox.y + fromBox.height / 2; - const endX = toBox.x + toBox.width / 2; - const endY = toBox.y + toBox.height / 2; + await expect(page.locator('.queue-item')).toHaveCount(3); - // Synthesize pointerdown → series of pointermoves → pointerup. The handle - // uses pointer events directly (not Playwright's mouse). - const dispatch = async ( - el: import('@playwright/test').Locator, - type: string, - x: number, - y: number - ) => - el.dispatchEvent(type, { - clientX: x, - clientY: y, - pointerId: 1, - pointerType: 'mouse', - button: 0, - buttons: type === 'pointerup' ? 0 : 1, - bubbles: true, - cancelable: true - }); + // Select the first two clips. + await page.locator('.queue-item').nth(0).locator('.select-box').click(); + await page.locator('.queue-item').nth(1).locator('.select-box').click(); + await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-selected-2') }); - await dispatch(firstHandle, 'pointerdown', startX, startY); - // Several moves so the drag visibly progresses - const steps = 10; - for (let i = 1; i <= steps; i++) { - const x = startX + ((endX - startX) * i) / steps; - const y = startY + ((endY - startY) * i) / steps; - await dispatch(firstHandle, 'pointermove', x, y); - await page.waitForTimeout(40); - } - await dispatch(firstHandle, 'pointerup', endX, endY); - await page.waitForTimeout(700); - await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-after-drag') }); + const postBtn = page.locator('.post-btn'); + await expect(postBtn).toContainText('Post 2'); + await postBtn.click(); + await page.waitForTimeout(800); - // Server-side order should have changed — assert the first clip moved out - // of position 0. We don't lock the exact final order because the drag - // tracking step may settle 1 position off depending on hover heuristics; - // the important test is "reorder API was actually called". - const after = await request.get('/api/queue'); - const afterBody = await after.json(); - const afterIds = (afterBody.queue ?? []).map((q: { clipId: string }) => q.clipId); - expect(afterIds.length).toBe(4); - expect( - afterIds[0], - `Expected first item to move; before=${beforeIds.join(',')}; after=${afterIds.join(',')}` - ).not.toBe(beforeIds[0]); + // Two clips left the queue (one remains). + const countRes = await request.get('/api/queue/count'); + const { count } = await countRes.json(); + expect(count).toBe(1); }); }); @@ -195,16 +133,3 @@ test.describe('queue: clear all', () => { expect(body.queue ?? []).toHaveLength(0); }); }); - -test.describe('queue: move-to-top', () => { - test('move-to-top API repositions item to position 0', async ({ request, loggedIn, db }) => { - seedQueue(db, loggedIn.userId, loggedIn.groupId, 3); - const list = await request.get('/api/queue'); - const body = await list.json(); - const last = body.queue[2]; - const move = await request.post(`/api/queue/${last.id}/move-to-top`); - expect(move.ok()).toBe(true); - const after = await (await request.get('/api/queue')).json(); - expect(after.queue[0].id).toBe(last.id); - }); -}); diff --git a/src/lib/components/AddVideo.svelte b/src/lib/components/AddVideo.svelte index be9ac9e..cf328f5 100644 --- a/src/lib/components/AddVideo.svelte +++ b/src/lib/components/AddVideo.svelte @@ -1,6 +1,7 @@ - -{#if change} - - -
- -- {#if isSameRank} - Your rank - {:else if isRankUp} - You ranked up! - {:else} - Your rank changed - {/if} -
-- {#if isSameRank && change.newTier === 'iconic'} - Your clips hit different. Maximum speed unlocked. - {:else if isSameRank && change.newTier === 'viral'} - The group loves your taste. Keep it coming. - {:else if isSameRank && change.newTier === 'rising'} - You're building momentum. Keep sharing hits. - {:else if isSameRank} - Share clips the group engages with to climb up. - {:else if change.newTier === 'iconic'} - Your clips hit different. Maximum speed unlocked. - {:else if change.newTier === 'viral' && isRankUp} - The group loves your taste. Keep it coming. - {:else if change.newTier === 'viral'} - Still solid — get more comments to climb back. - {:else if change.newTier === 'rising' && isRankUp} - You're building momentum. Nice picks. - {:else if change.newTier === 'rising'} - Share clips the group reacts to and you'll bounce back. - {:else if isRankUp} - Your clips are starting to land. - {:else} - Share clips the group engages with to climb back up. - {/if} -
-- {neededUpgrades} clip{neededUpgrades === 1 ? '' : 's'} need better engagement to reach - {nextTier.tierName} -
- {/if} -You're at the top
-Maximum speed unlocked. Keep sharing clips the group loves.
-Your clip will share in ~{queuedSharesIn}
- {/if} -Post it from your queue when you're ready.
{description}
-{description}
+No limits on sharing.
-Hard limit that resets at midnight.
-Clips always accepted — extras space out over time.
-All caught up
Drop a clip to kick things off