Compare commits

...

2 Commits

Author SHA1 Message Date
George Powell
c3307b3920 Added streak counter 2026-02-21 01:24:16 -05:00
George Powell
19646c72ca WIP new share menu 2026-02-21 00:38:09 -05:00
6 changed files with 285 additions and 81 deletions

View File

@@ -1,10 +1,6 @@
<script lang="ts">
import { fade } from "svelte/transition";
import {
getBookById,
toOrdinal,
getNextGradeMessage,
} from "$lib/utils/game";
import { fade, fly } from "svelte/transition";
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,
@@ -35,6 +30,20 @@
guessCount,
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 ?? "");
@@ -86,64 +95,20 @@
<div class="flex flex-col gap-6">
<Container
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
class="w-full px-2 sm:px-4 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
>
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
{congratulationsMessage} The verse is from
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
{congratulationsMessage} The verse is from<br />
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
</p>
<p class="text-lg sm:text-xl md:text-2xl mt-4">
<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>
<div class="flex justify-center mt-6">
{#if hasWebShare}
<!-- mobile and arc in production -->
<button
onclick={handleShare}
data-umami-event="Share"
class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
>
📤 Share
</button>
<button
onclick={() => {
copyToClipboard();
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}}
data-umami-event="Copy to Clipboard"
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
copySuccess
? "bg-white/30"
: "bg-white/70 hover:bg-white/80"
}`}
>
{copySuccess ? "✅ Copied!" : "📋 Copy"}
</button>
{:else}
<!-- dev mode and desktop browsers -->
<button
onclick={handleShare}
data-umami-event="Copy to Clipboard"
class={`text-2xl font-bold p-4 ${
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
>
{copied ? "✅ Copied!" : "📋 Share"}
</button>
{/if}
</div>
{#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>
@@ -225,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>
@@ -242,4 +239,141 @@
.fade-in {
animation: fadeIn 0.5s ease-out;
}
/* ── Share card ── */
.share-card {
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;
flex-direction: column;
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 {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8e8e93;
margin: 0;
}
/* ── Chat window ── */
.chat-window {
--sent-color: #0b93f6;
--bg: oklch(93.996% 0.03041 300.209);
display: flex;
justify-content: center;
padding: 0 1.5rem 0;
}
/* ── Bubble (from article technique) ── */
.bubble {
position: relative;
max-width: 255px;
margin-bottom: 8px;
padding: 10px 20px;
line-height: 1.3;
word-wrap: break-word;
border-radius: 25px;
text-align: left;
white-space: pre-wrap;
font-size: 0.9rem;
transform: rotate(-2deg);
color: white;
background: var(--sent-color);
}
.bubble::before,
.bubble::after {
position: absolute;
bottom: 0;
height: 25px;
content: "";
}
.bubble::before {
width: 20px;
right: -7px;
background-color: var(--sent-color);
border-bottom-left-radius: 16px 14px;
}
.bubble::after {
width: 26px;
right: -26px;
border-bottom-left-radius: 10px;
background-color: var(--bg);
}
/* ── Share buttons ── */
.share-buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.share-btn {
width: 100%;
padding: 1rem 1.5rem;
font-size: 1.1rem;
font-weight: 700;
border: 3px solid rgba(0, 0, 0, 0.25);
border-radius: 1rem;
cursor: pointer;
transition:
transform 80ms ease,
box-shadow 80ms ease,
opacity 80ms ease;
letter-spacing: 0.01em;
}
.share-btn:active {
transform: scale(0.97);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.share-btn.primary {
background: #7c3aed;
color: white;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.4);
}
.share-btn.primary:hover {
opacity: 0.92;
}
.share-btn.primary.success {
background: #636363;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.share-btn.secondary {
background: #f2f2f7;
color: #1c1c1e;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.share-btn.secondary:hover {
background: #e5e5ea;
}
</style>

View File

@@ -1,6 +1,8 @@
import { browser } from "$app/environment";
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
// Used to attribute stats to a player who hasn't signed in.
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
@@ -12,6 +14,9 @@ function getOrCreateAnonymousId(): string {
return id;
}
// Reactive store that keeps in-memory game state in sync with localStorage.
// Accepts getter functions (rather than plain values) so Svelte's reactivity
// system can track dependencies and re-run effects when they change.
export function createGamePersistence(
getDate: () => string,
getReference: () => string,
@@ -24,7 +29,8 @@ export function createGamePersistence(
let chapterGuessCompleted = $state(false);
let chapterCorrect = $state(false);
// Initialize anonymous ID and load persisted flags
// On mount (and if the user logs in/out), resolve the player's identity and
// restore per-day flags from localStorage.
$effect(() => {
if (!browser) return;
@@ -36,6 +42,7 @@ export function createGamePersistence(
anonymousId = getOrCreateAnonymousId();
}
// Tell Umami analytics which player this is so events are grouped correctly.
if ((window as any).umami) {
(window as any).umami.identify(anonymousId);
}
@@ -43,8 +50,11 @@ export function createGamePersistence(
const date = getDate();
const reference = getReference();
// Restore whether today's completion was already submitted to the server.
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
// Restore the chapter bonus guess result. The stored value includes the
// chapter the player selected, so we can re-derive whether it was correct.
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
if (chapterGuessCompleted) {
@@ -58,7 +68,9 @@ export function createGamePersistence(
}
});
// Load saved guesses from localStorage
// On mount (and if the date or correct answer changes), load today's guesses
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
// each stored book ID against the correct answer.
$effect(() => {
if (!browser) return;
@@ -72,13 +84,14 @@ export function createGamePersistence(
}
let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds));
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
guesses = savedIds
.map((bookId) => evaluateGuess(bookId, correctBookId))
.filter((g): g is Guess => g !== null);
});
// Save guesses to localStorage whenever they change
// Persist guesses to localStorage whenever they change. Only the book IDs are
// stored — the full Guess shape is re-derived on load (see effect above).
$effect(() => {
if (!browser) return;
const date = getDate();
@@ -88,12 +101,16 @@ export function createGamePersistence(
);
});
// Called after stats are successfully submitted to the server so that
// returning to the page doesn't trigger a duplicate submission.
function markStatsSubmitted() {
if (!browser) return;
statsSubmitted = true;
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
}
// Marks the win as tracked for analytics. Returns true the first time (new
// win), false on subsequent calls so the analytics event fires exactly once.
function markWinTracked() {
if (!browser) return;
const key = `bibdle-win-tracked-${getDate()}`;
@@ -102,11 +119,16 @@ export function createGamePersistence(
return true;
}
// Returns true if the win has already been tracked in a previous render/session.
// Used to skip the animation delay when returning to an already-won game.
function isWinAlreadyTracked(): boolean {
if (!browser) return false;
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
}
// Overwrites local state with the server's authoritative guess record.
// Called when a logged-in user opens the game on a new device so their
// progress from another device is restored.
function hydrateFromServer(guessIds: string[]) {
if (!browser) return;
const correctBookId = getCorrectBookId();
@@ -116,6 +138,8 @@ export function createGamePersistence(
.filter((g): g is Guess => g !== null);
}
// Called by the WinScreen after the player submits their chapter bonus guess.
// Reads the result written to localStorage by WinScreen and updates reactive state.
function onChapterGuessCompleted() {
if (!browser) return;
chapterGuessCompleted = true;

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}
@@ -304,6 +306,8 @@
guessCount={persistence.guesses.length}
reference={dailyVerse.reference}
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
shareText={getShareText()}
{streak}
/>
</div>
{/if}
@@ -387,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 });
};