Added streak counter

This commit is contained in:
George Powell
2026-02-21 01:24:16 -05:00
parent 19646c72ca
commit c3307b3920
5 changed files with 129 additions and 68 deletions

View File

@@ -1,10 +1,6 @@
<script lang="ts">
import { fade, fly } from "svelte/transition";
import {
getBookById,
toOrdinal,
getNextGradeMessage,
} from "$lib/utils/game";
import { getBookById, toOrdinal } from "$lib/utils/game";
import { onMount } from "svelte";
import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte";
@@ -25,7 +21,6 @@
}
let {
grade,
statsData,
correctBookId,
handleShare,
@@ -36,6 +31,19 @@
reference,
onChapterGuessCompleted,
shareText,
streak = 0,
}: {
statsData: StatsData | null;
correctBookId: string;
handleShare: () => void;
copyToClipboard: () => void;
copied: boolean;
statsSubmitted: boolean;
guessCount: number;
reference: string;
onChapterGuessCompleted: () => void;
shareText: string;
streak?: number;
} = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
@@ -96,50 +104,15 @@
<p class="text-lg sm:text-xl md:text-2xl mt-2">
You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}.
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
>{grade}</span
>
</p>
{#if guessCount !== 1}
<p class="pt-6 big-text text-gray-700!">
{getNextGradeMessage(guessCount)}
{#if streak > 1}
<p class="big-text text-orange-500! text-sm! mt-4">
🔥 {streak} day streak!
</p>
{/if}
</Container>
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
<div class="big-text font-black! text-center">Share your result</div>
<div class="share-buttons">
{#if hasWebShare}
<button
onclick={handleShare}
data-umami-event="Share"
class="share-btn primary"
>
📤 Click to share
</button>
{:else}
<button
onclick={() => {
copyToClipboard();
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}}
data-umami-event="Copy to Clipboard"
class={`share-btn primary ${copySuccess ? "success" : ""}`}
>
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
</button>
{/if}
</div>
<div class="chat-window">
<p class="bubble">{shareText}</p>
</div>
</div>
<!-- S++ Bonus Challenge for first try -->
{#if guessCount === 1}
<ChapterGuess
@@ -217,6 +190,38 @@
<div class="text-sm opacity-80">Submitting stats...</div>
</Container>
{/if}
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
<div class="big-text font-black! text-center">Share your result</div>
<div class="share-buttons">
{#if hasWebShare}
<button
onclick={handleShare}
data-umami-event="Share"
class="share-btn primary"
>
📤 Click to share
</button>
{:else}
<button
onclick={() => {
copyToClipboard();
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}}
data-umami-event="Copy to Clipboard"
class={`share-btn primary ${copySuccess ? "success" : ""}`}
>
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
</button>
{/if}
</div>
<div class="chat-window">
<p class="bubble">{shareText}</p>
</div>
</div>
</div>
<style>
@@ -237,8 +242,9 @@
/* ── Share card ── */
.share-card {
background: white;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 1.25rem;
padding: 1.25rem;
display: flex;
@@ -246,6 +252,17 @@
gap: 0.6rem;
width: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
}
.share-card::before {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
opacity: 0.04;
pointer-events: none;
}
.share-card-label {
@@ -260,8 +277,7 @@
/* ── Chat window ── */
.chat-window {
--sent-color: #0b93f6;
--bg: white;
--bg: oklch(93.996% 0.03041 300.209);
display: flex;
justify-content: center;
padding: 0 1.5rem 0;

View File

@@ -4,13 +4,12 @@ export function generateShareText(params: {
guesses: Guess[];
correctBookId: string;
dailyVerseDate: string;
grade: string;
chapterCorrect: boolean;
isLoggedIn: boolean;
userStreak?: number;
streak?: number;
origin: string;
}): string {
const { guesses, correctBookId, dailyVerseDate, grade, chapterCorrect, isLoggedIn, userStreak, origin } = params;
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin } = params;
const emojis = guesses
.slice()
@@ -35,15 +34,14 @@ export function generateShareText(params: {
const bookEmoji = isLoggedIn ? "📜" : "📖";
const guessWord = guesses.length === 1 ? "guess" : "guesses";
const streakPart = streak !== undefined && streak > 1 ? `, ${streak} days 🔥` : "";
const lines = [
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
`${guesses.length} ${guessWord}${streakPart}`,
];
if (isLoggedIn && userStreak !== undefined) {
lines.push(`🔥 ${userStreak} day streak`);
}
lines.push(
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
origin,

7
src/lib/utils/streak.ts Normal file
View File

@@ -0,0 +1,7 @@
export async function fetchStreak(anonymousId: string, localDate: string): Promise<number> {
const params = new URLSearchParams({ anonymousId, localDate });
const res = await fetch(`/api/streak?${params}`);
if (!res.ok) return 0;
const data = await res.json();
return typeof data.streak === 'number' ? data.streak : 0;
}

View File

@@ -12,12 +12,13 @@
import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
import { evaluateGuess, getGrade } from "$lib/utils/game";
import { evaluateGuess } from "$lib/utils/game";
import {
generateShareText,
shareResult,
copyToClipboard as clipboardCopy,
} from "$lib/utils/share";
import { fetchStreak } from "$lib/utils/streak";
import {
submitCompletion,
fetchExistingStats,
@@ -39,6 +40,7 @@
let authModalOpen = $state(false);
let showWinScreen = $state(false);
let statsData = $state<StatsData | null>(null);
let streak = $state(0);
const persistence = createGamePersistence(
() => dailyVerse.date,
@@ -63,13 +65,6 @@
let isWon = $derived(
persistence.guesses.some((g) => g.book.id === correctBookId),
);
let grade = $derived(
isWon
? persistence.guesses.length === 1 && persistence.chapterCorrect
? "S++"
: getGrade(persistence.guesses.length)
: "",
);
let blurChapter = $derived(
isWon &&
persistence.guesses.length === 1 &&
@@ -216,15 +211,23 @@
}
});
// Fetch streak when the player wins
$effect(() => {
if (!browser || !isWon || !persistence.anonymousId) return;
const localDate = new Date().toLocaleDateString("en-CA");
fetchStreak(persistence.anonymousId, localDate).then((result) => {
streak = result;
});
});
function getShareText(): string {
return generateShareText({
guesses: persistence.guesses,
correctBookId,
dailyVerseDate: dailyVerse.date,
grade,
chapterCorrect: persistence.chapterCorrect,
isLoggedIn: !!user,
userStreak: user ? (user as any).streak : undefined,
streak,
origin: window.location.origin,
});
}
@@ -294,7 +297,6 @@
{:else if showWinScreen}
<div class="animate-fade-in-up animate-delay-400">
<WinScreen
{grade}
{statsData}
{correctBookId}
{handleShare}
@@ -305,6 +307,7 @@
reference={dailyVerse.reference}
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
shareText={getShareText()}
{streak}
/>
</div>
{/if}
@@ -388,6 +391,7 @@
)}
</div>
<div>Daily Verse Date: {dailyVerse.date}</div>
<div>Streak: {streak}</div>
</div>
<DevButtons anonymousId={persistence.anonymousId} />
</div>

View File

@@ -0,0 +1,36 @@
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 { eq, desc } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const anonymousId = url.searchParams.get('anonymousId');
const localDate = url.searchParams.get('localDate');
if (!anonymousId || !localDate) {
error(400, 'Missing anonymousId or localDate');
}
// Fetch all completion dates for this user, newest first
const rows = await db
.select({ date: dailyCompletions.date })
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId))
.orderBy(desc(dailyCompletions.date));
const completedDates = new Set(rows.map((r) => r.date));
// Walk backwards from localDate, counting consecutive completed days
let streak = 0;
let cursor = new Date(`${localDate}T00:00:00`);
while (true) {
const dateStr = cursor.toLocaleDateString('en-CA'); // YYYY-MM-DD
if (!completedDates.has(dateStr)) break;
streak++;
cursor.setDate(cursor.getDate() - 1);
}
return json({ streak });
};