mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Added streak percentage
This commit is contained in:
26
scripts/seed-fake-completions.sh
Executable file
26
scripts/seed-fake-completions.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
# Seed the database with 10 fake completions with random anonymous_ids
|
||||||
|
# Useful for testing streak percentile and stats features
|
||||||
|
|
||||||
|
DB_PATH="dev.db"
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
NOW=$(date +%s)
|
||||||
|
|
||||||
|
echo "Seeding 10 fake completions for date: $TODAY"
|
||||||
|
|
||||||
|
for i in {1..50}; do
|
||||||
|
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
ANON_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
GUESS_COUNT=$(( (RANDOM % 6) + 1 )) # 1–6 guesses
|
||||||
|
|
||||||
|
sqlite3 "$DB_PATH" "
|
||||||
|
INSERT OR IGNORE INTO daily_completions (id, anonymous_id, date, guess_count, completed_at)
|
||||||
|
VALUES ('$ID', '$ANON_ID', '$TODAY', $GUESS_COUNT, $NOW);
|
||||||
|
"
|
||||||
|
|
||||||
|
echo " [$i] anon=$ANON_ID guesses=$GUESS_COUNT"
|
||||||
|
done
|
||||||
|
|
||||||
|
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '$TODAY';")
|
||||||
|
echo "✓ Done. Total completions for $TODAY: $TOTAL"
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
return chapterCounts[bookId] || 1;
|
return chapterCounts[bookId] || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6 random chapter options including the correct one
|
// Generate 4 random chapter options including the correct one
|
||||||
function generateChapterOptions(
|
function generateChapterOptions(
|
||||||
correctChapter: number,
|
correctChapter: number,
|
||||||
totalChapters: number,
|
totalChapters: number,
|
||||||
@@ -98,14 +98,14 @@
|
|||||||
const options = new Set<number>();
|
const options = new Set<number>();
|
||||||
options.add(correctChapter);
|
options.add(correctChapter);
|
||||||
|
|
||||||
if (totalChapters >= 6) {
|
if (totalChapters >= 4) {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter =
|
const randomChapter =
|
||||||
Math.floor(Math.random() * totalChapters) + 1;
|
Math.floor(Math.random() * totalChapters) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
@@ -167,18 +167,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
Bonus Challenge
|
||||||
Guess the chapter for an even higher grade
|
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||||
|
>— guess the chapter for an even higher grade</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
{#each chapterOptions as chapter (chapter)}
|
||||||
>
|
|
||||||
{#each chapterOptions as chapter}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => handleChapterSelect(chapter)}
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
disabled={hasAnswered}
|
disabled={hasAnswered}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
onChapterGuessCompleted,
|
onChapterGuessCompleted,
|
||||||
shareText,
|
shareText,
|
||||||
streak = 0,
|
streak = 0,
|
||||||
|
streakPercentile = null,
|
||||||
}: {
|
}: {
|
||||||
statsData: StatsData | null;
|
statsData: StatsData | null;
|
||||||
correctBookId: string;
|
correctBookId: string;
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
onChapterGuessCompleted: () => void;
|
onChapterGuessCompleted: () => void;
|
||||||
shareText: string;
|
shareText: string;
|
||||||
streak?: number;
|
streak?: number;
|
||||||
|
streakPercentile?: number | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -66,9 +68,9 @@
|
|||||||
if (guessCount === 1) {
|
if (guessCount === 1) {
|
||||||
const n = Math.random();
|
const n = Math.random();
|
||||||
if (n < 0.99) {
|
if (n < 0.99) {
|
||||||
return "🌟 First try! 🌟";
|
return "First try!";
|
||||||
} else {
|
} else {
|
||||||
return "🗣️ Axios! 🗣️";
|
return "Axios!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +109,20 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if streak > 1}
|
{#if streak > 1}
|
||||||
<p class="big-text text-orange-500! text-sm! mt-4">
|
<p class="big-text text-orange-500! text-lg! my-4">
|
||||||
🔥 {streak} day streak!
|
🔥 {streak} days in a row!
|
||||||
</p>
|
</p>
|
||||||
|
{#if streak >= 7}
|
||||||
|
<p class="font-black text-lg font-triodion">
|
||||||
|
Thank you for making Bibdle part of your daily routine!
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if streakPercentile !== null}
|
||||||
|
<p class="text-sm mt-4 text-gray-700 font-triodion">
|
||||||
|
Only {streakPercentile}% of players have a streak of {streak}
|
||||||
|
or greater.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ export async function fetchStreak(anonymousId: string, localDate: string): Promi
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return typeof data.streak === 'number' ? data.streak : 0;
|
return typeof data.streak === 'number' ? data.streak : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchStreakPercentile(streak: number, localDate: string): Promise<number | null> {
|
||||||
|
const params = new URLSearchParams({ streak: String(streak), localDate });
|
||||||
|
const res = await fetch(`/api/streak-percentile?${params}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return typeof data.percentile === 'number' ? data.percentile : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
shareResult,
|
shareResult,
|
||||||
copyToClipboard as clipboardCopy,
|
copyToClipboard as clipboardCopy,
|
||||||
} from "$lib/utils/share";
|
} from "$lib/utils/share";
|
||||||
import { fetchStreak } from "$lib/utils/streak";
|
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
|
||||||
import {
|
import {
|
||||||
submitCompletion,
|
submitCompletion,
|
||||||
fetchExistingStats,
|
fetchExistingStats,
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
let showWinScreen = $state(false);
|
let showWinScreen = $state(false);
|
||||||
let statsData = $state<StatsData | null>(null);
|
let statsData = $state<StatsData | null>(null);
|
||||||
let streak = $state(0);
|
let streak = $state(0);
|
||||||
|
let streakPercentile = $state<number | null>(null);
|
||||||
|
|
||||||
const persistence = createGamePersistence(
|
const persistence = createGamePersistence(
|
||||||
() => dailyVerse.date,
|
() => dailyVerse.date,
|
||||||
@@ -217,6 +218,11 @@
|
|||||||
const localDate = new Date().toLocaleDateString("en-CA");
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||||
streak = result;
|
streak = result;
|
||||||
|
if (result >= 2) {
|
||||||
|
fetchStreakPercentile(result, localDate).then((p) => {
|
||||||
|
streakPercentile = p;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -308,6 +314,7 @@
|
|||||||
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||||
shareText={getShareText()}
|
shareText={getShareText()}
|
||||||
{streak}
|
{streak}
|
||||||
|
{streakPercentile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
72
src/routes/api/streak-percentile/+server.ts
Normal file
72
src/routes/api/streak-percentile/+server.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const streakParam = url.searchParams.get('streak');
|
||||||
|
const localDate = url.searchParams.get('localDate');
|
||||||
|
|
||||||
|
if (!streakParam || !localDate) {
|
||||||
|
error(400, 'Missing streak or localDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStreak = parseInt(streakParam, 10);
|
||||||
|
if (isNaN(targetStreak) || targetStreak < 1) {
|
||||||
|
error(400, 'Invalid streak');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all completions ordered by anonymous_id and date desc
|
||||||
|
// so we can walk each user's history to compute their current streak.
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
|
date: dailyCompletions.date,
|
||||||
|
})
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
// Group dates by user
|
||||||
|
const byUser = new Map<string, string[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byUser.get(row.anonymousId);
|
||||||
|
if (list) {
|
||||||
|
list.push(row.date);
|
||||||
|
} else {
|
||||||
|
byUser.set(row.anonymousId, [row.date]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the current streak for each user
|
||||||
|
const streaks: number[] = [];
|
||||||
|
for (const [, dates] of byUser) {
|
||||||
|
// dates are already desc-sorted
|
||||||
|
const dateSet = new Set(dates);
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date(`${localDate}T00:00:00`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const dateStr = cursor.toLocaleDateString('en-CA');
|
||||||
|
if (!dateSet.has(dateStr)) break;
|
||||||
|
streak++;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
streaks.push(streak);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only count users who have an active streak (streak >= 1)
|
||||||
|
const activeStreaks = streaks.filter((s) => s >= 1);
|
||||||
|
|
||||||
|
if (activeStreaks.length === 0) {
|
||||||
|
return json({ percentile: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentage of active-streak users who have a streak >= targetStreak
|
||||||
|
const atOrAbove = activeStreaks.filter((s) => s >= targetStreak).length;
|
||||||
|
const raw = (atOrAbove / activeStreaks.length) * 100;
|
||||||
|
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
|
||||||
|
|
||||||
|
return json({ percentile });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user