Skip to content

Commit 6f01021

Browse files
feat: clout rank-down protection and watch threshold (#122)
* docs: add UnderperformingClips to architecture directory tree * feat(db): add clout_tier_changed_at column to users table Tracks when a user's effective clout tier last changed, enabling rank-down protection with a 4-day cooldown. * feat(clout): add rank-down protection and 75% watch threshold - Rank-ups apply immediately, rank-downs require 4-day stability - Only clips watched by ≥75% of group members are eligible - Remove 48h maturity timer (watch threshold replaces it) - Add getEffectiveTier() for rank-down cooldown logic * feat(clout): wire effective tier into API and share pacing Pass stored tier history through getCloutScore for rank-down protection. Update cloutTierChangedAt on actual tier changes. Remove underperforming clips from API response. * refactor(ui): remove underperforming clips section With rank-down protection in place, showing clips that are "dragging your rank down" is misleading. Remove the component and all references from CloutTipsView and CloutChangeModal. * docs: update clout docs for rank protection and watch threshold - Document clout_tier_changed_at column and 4-day rank-down cooldown - Update clout algorithm description (75% watch threshold, no 48h timer) - Remove underperforming clips from API response docs - Remove UnderperformingClips from architecture directory tree
1 parent f3678c4 commit 6f01021

13 files changed

Lines changed: 1672 additions & 246 deletions

File tree

docs/api.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ Response: { "ok": true }
253253
| POST | `/api/clout` | Acknowledge tier change modal was shown |
254254

255255
### GET /api/clout
256-
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 matured clips (48h+ old). Users with fewer than 10 matured clips default to Rising tier.
256+
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.
257257
```
258258
Response: {
259259
"enabled": true,
@@ -266,17 +266,18 @@ Response: {
266266
"icon": "/icons/clout/viral.png",
267267
"breakdown": [{ "clipId": "...", "score": 2 }, ...],
268268
"nextTier": { "tier": "iconic", "tierName": "Iconic", "minScore": 1.0, "burst": 5, "queueLimit": null, "icon": "..." },
269-
"underperforming": [{ "clipId": "...", "title": "...", "platform": "tiktok", "originalUrl": "...", "thumbnailPath": "..." }],
270269
"lastTier": "rising",
271270
"tierChanged": true
272271
}
273272
```
274273

275274
**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.
276275

277-
**Per-clip scoring:** 0 = no reactions/favorites from others, 1 = reaction or favorite but no comment, 2 = reaction/favorite AND comment. Self-interactions excluded.
276+
**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.
278277

279-
**Tier change detection:** The server tracks each user's last acknowledged tier (`cloutTier`) and last modal shown time (`cloutChangeShownAt`). When the computed tier differs from the acked tier and the 3-day cooldown has elapsed, `tierChanged: true` is returned. The `lastTier` field shows what the user was previously at.
278+
**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.
279+
280+
**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.
280281

281282
### POST /api/clout
282283
Acknowledges that the tier change modal was shown. Updates the user's stored tier and resets the cooldown timer.

docs/data-model.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar
4444
| last_legacy_share_at | integer | Nullable. Unix timestamp of last legacy shortcut share. Used for upgrade banner. |
4545
| used_new_share_flow | integer | Boolean (0/1). Default 0. Tracks if user has adopted new web view share flow. |
4646
| clout_tier | text | Nullable. Last acknowledged clout tier (for tier change detection). |
47-
| clout_change_shown_at | integer | Nullable. Unix timestamp when tier change modal was last shown (3-day cooldown). |
47+
| clout_change_shown_at | integer | Nullable. Unix timestamp when tier change modal was last shown. |
48+
| 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). |
4849
| removed_at | integer | Nullable. Unix timestamp when removed from group. |
4950
| created_at | integer | Unix timestamp |
5051

@@ -252,4 +253,4 @@ users 1──∞ verification_codes
252253
- **Duplicate URL prevention:** A unique index on `(group_id, original_url)` prevents the same link from being shared twice within a group.
253254
- **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.
254255
- **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.
255-
- **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 matured clips (48h+ old). Self-interactions are excluded. Tiers (Fresh/Rising/Viral/Iconic) determine queue cooldown multiplier, burst size, and queue depth limits. The `clout_enabled` flag on `groups` controls whether clout adjustments are applied. The `clout_tier` and `clout_change_shown_at` columns on `users` track server-driven tier change notifications with a 3-day cooldown.
256+
- **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.

src/lib/components/CloutChangeModal.svelte

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,11 @@
2323
icon: string;
2424
}
2525
26-
interface Underperformer {
27-
clipId: string;
28-
title: string | null;
29-
platform: string;
30-
originalUrl: string;
31-
thumbnailPath: string | null;
32-
}
33-
3426
let visible = $state(false);
3527
let change = $state<typeof $cloutChange>(null);
3628
let showTips = $state(false);
3729
let tipsData = $state<{
3830
nextTier: NextTierInfo | null;
39-
underperforming: Underperformer[];
4031
breakdown: { clipId: string; score: number }[];
4132
baseCooldownMinutes: number;
4233
} | null>(null);
@@ -175,7 +166,6 @@
175166
const data = await res.json();
176167
tipsData = {
177168
nextTier: data.nextTier,
178-
underperforming: data.underperforming ?? [],
179169
breakdown: data.breakdown ?? [],
180170
baseCooldownMinutes: data.baseCooldownMinutes ?? 120
181171
};
@@ -318,10 +308,8 @@
318308
<CloutTipsView
319309
currentTier={change.newTier}
320310
nextTier={tipsData.nextTier}
321-
underperforming={tipsData.underperforming}
322311
breakdown={tipsData.breakdown}
323312
baseCooldownMinutes={tipsData.baseCooldownMinutes}
324-
ondismiss={dismiss}
325313
/>
326314
{/if}
327315
</div>

src/lib/components/CloutTipsView.svelte

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
import UnderperformingClips from './UnderperformingClips.svelte';
32
import MinusCircleIcon from 'phosphor-svelte/lib/MinusCircleIcon';
43
import ThumbsUpIcon from 'phosphor-svelte/lib/ThumbsUpIcon';
54
import FireIcon from 'phosphor-svelte/lib/FireIcon';
@@ -36,13 +35,6 @@
3635
selectedTier = selectedTier === tier ? null : tier;
3736
}
3837
39-
interface Underperformer {
40-
clipId: string;
41-
title: string | null;
42-
platform: string;
43-
originalUrl: string;
44-
thumbnailPath: string | null;
45-
}
4638
interface NextTierInfo {
4739
tier: string;
4840
tierName: string;
@@ -55,17 +47,13 @@
5547
let {
5648
currentTier,
5749
nextTier,
58-
underperforming,
5950
breakdown,
60-
baseCooldownMinutes = 120,
61-
ondismiss
51+
baseCooldownMinutes = 120
6252
}: {
6353
currentTier: string;
6454
nextTier: NextTierInfo | null;
65-
underperforming: Underperformer[];
6655
breakdown: { clipId: string; score: number }[];
6756
baseCooldownMinutes?: number;
68-
ondismiss: () => void;
6957
} = $props();
7058
7159
function formatCooldown(minutes: number): string {
@@ -191,10 +179,6 @@
191179
<p class="at-top-sub">Maximum speed unlocked. Keep sharing clips the group loves.</p>
192180
</div>
193181
{/if}
194-
195-
{#if underperforming.length > 0}
196-
<UnderperformingClips clips={underperforming} {ondismiss} />
197-
{/if}
198182
</div>
199183

200184
<style>

src/lib/components/UnderperformingClips.svelte

Lines changed: 0 additions & 160 deletions
This file was deleted.

0 commit comments

Comments
 (0)