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"> <script lang="ts">
import { fade, fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import { import { getBookById, toOrdinal } from "$lib/utils/game";
getBookById,
toOrdinal,
getNextGradeMessage,
} from "$lib/utils/game";
import { onMount } from "svelte"; import { onMount } from "svelte";
import Container from "./Container.svelte"; import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte"; import CountdownTimer from "./CountdownTimer.svelte";
@@ -25,7 +21,6 @@
} }
let { let {
grade,
statsData, statsData,
correctBookId, correctBookId,
handleShare, handleShare,
@@ -36,6 +31,19 @@
reference, reference,
onChapterGuessCompleted, onChapterGuessCompleted,
shareText, 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(); } = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? ""); let bookName = $derived(getBookById(correctBookId)?.name ?? "");
@@ -96,50 +104,15 @@
<p class="text-lg sm:text-xl md:text-2xl mt-2"> <p class="text-lg sm:text-xl md:text-2xl mt-2">
You guessed correctly after {guessCount} You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}. {guessCount === 1 ? "guess" : "guesses"}.
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
>{grade}</span
>
</p> </p>
{#if guessCount !== 1} {#if streak > 1}
<p class="pt-6 big-text text-gray-700!"> <p class="big-text text-orange-500! text-sm! mt-4">
{getNextGradeMessage(guessCount)} 🔥 {streak} day streak!
</p> </p>
{/if} {/if}
</Container> </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 --> <!-- S++ Bonus Challenge for first try -->
{#if guessCount === 1} {#if guessCount === 1}
<ChapterGuess <ChapterGuess
@@ -217,6 +190,38 @@
<div class="text-sm opacity-80">Submitting stats...</div> <div class="text-sm opacity-80">Submitting stats...</div>
</Container> </Container>
{/if} {/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> </div>
<style> <style>
@@ -237,8 +242,9 @@
/* ── Share card ── */ /* ── Share card ── */
.share-card { .share-card {
background: white; background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(0, 0, 0, 0.08); backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 1.25rem; border-radius: 1.25rem;
padding: 1.25rem; padding: 1.25rem;
display: flex; display: flex;
@@ -246,6 +252,17 @@
gap: 0.6rem; gap: 0.6rem;
width: 100%; width: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); 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 { .share-card-label {
@@ -260,8 +277,7 @@
/* ── Chat window ── */ /* ── Chat window ── */
.chat-window { .chat-window {
--sent-color: #0b93f6; --sent-color: #0b93f6;
--bg: white; --bg: oklch(93.996% 0.03041 300.209);
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 0 1.5rem 0; padding: 0 1.5rem 0;

View File

@@ -4,13 +4,12 @@ export function generateShareText(params: {
guesses: Guess[]; guesses: Guess[];
correctBookId: string; correctBookId: string;
dailyVerseDate: string; dailyVerseDate: string;
grade: string;
chapterCorrect: boolean; chapterCorrect: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
userStreak?: number; streak?: number;
origin: string; origin: string;
}): string { }): string {
const { guesses, correctBookId, dailyVerseDate, grade, chapterCorrect, isLoggedIn, userStreak, origin } = params; const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin } = params;
const emojis = guesses const emojis = guesses
.slice() .slice()
@@ -35,15 +34,14 @@ export function generateShareText(params: {
const bookEmoji = isLoggedIn ? "📜" : "📖"; const bookEmoji = isLoggedIn ? "📜" : "📖";
const guessWord = guesses.length === 1 ? "guess" : "guesses";
const streakPart = streak !== undefined && streak > 1 ? `, ${streak} days 🔥` : "";
const lines = [ const lines = [
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`, `${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( lines.push(
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`, `${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
origin, 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 DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte"; import AuthModal from "$lib/components/AuthModal.svelte";
import { evaluateGuess, getGrade } from "$lib/utils/game"; import { evaluateGuess } from "$lib/utils/game";
import { import {
generateShareText, generateShareText,
shareResult, shareResult,
copyToClipboard as clipboardCopy, copyToClipboard as clipboardCopy,
} from "$lib/utils/share"; } from "$lib/utils/share";
import { fetchStreak } from "$lib/utils/streak";
import { import {
submitCompletion, submitCompletion,
fetchExistingStats, fetchExistingStats,
@@ -39,6 +40,7 @@
let authModalOpen = $state(false); let authModalOpen = $state(false);
let showWinScreen = $state(false); let showWinScreen = $state(false);
let statsData = $state<StatsData | null>(null); let statsData = $state<StatsData | null>(null);
let streak = $state(0);
const persistence = createGamePersistence( const persistence = createGamePersistence(
() => dailyVerse.date, () => dailyVerse.date,
@@ -63,13 +65,6 @@
let isWon = $derived( let isWon = $derived(
persistence.guesses.some((g) => g.book.id === correctBookId), 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( let blurChapter = $derived(
isWon && isWon &&
persistence.guesses.length === 1 && 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 { function getShareText(): string {
return generateShareText({ return generateShareText({
guesses: persistence.guesses, guesses: persistence.guesses,
correctBookId, correctBookId,
dailyVerseDate: dailyVerse.date, dailyVerseDate: dailyVerse.date,
grade,
chapterCorrect: persistence.chapterCorrect, chapterCorrect: persistence.chapterCorrect,
isLoggedIn: !!user, isLoggedIn: !!user,
userStreak: user ? (user as any).streak : undefined, streak,
origin: window.location.origin, origin: window.location.origin,
}); });
} }
@@ -294,7 +297,6 @@
{:else if showWinScreen} {:else if showWinScreen}
<div class="animate-fade-in-up animate-delay-400"> <div class="animate-fade-in-up animate-delay-400">
<WinScreen <WinScreen
{grade}
{statsData} {statsData}
{correctBookId} {correctBookId}
{handleShare} {handleShare}
@@ -304,7 +306,8 @@
guessCount={persistence.guesses.length} guessCount={persistence.guesses.length}
reference={dailyVerse.reference} reference={dailyVerse.reference}
onChapterGuessCompleted={persistence.onChapterGuessCompleted} onChapterGuessCompleted={persistence.onChapterGuessCompleted}
shareText={getShareText()} shareText={getShareText()}
{streak}
/> />
</div> </div>
{/if} {/if}
@@ -388,6 +391,7 @@
)} )}
</div> </div>
<div>Daily Verse Date: {dailyVerse.date}</div> <div>Daily Verse Date: {dailyVerse.date}</div>
<div>Streak: {streak}</div>
</div> </div>
<DevButtons anonymousId={persistence.anonymousId} /> <DevButtons anonymousId={persistence.anonymousId} />
</div> </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 });
};