mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
2 Commits
e592751a1c
...
c3307b3920
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3307b3920 | ||
|
|
19646c72ca |
@@ -1,10 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } 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,
|
||||||
@@ -35,6 +30,20 @@
|
|||||||
guessCount,
|
guessCount,
|
||||||
reference,
|
reference,
|
||||||
onChapterGuessCompleted,
|
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();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -86,64 +95,20 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Container
|
<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">
|
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||||
{congratulationsMessage} The verse is from
|
{congratulationsMessage} The verse is from<br />
|
||||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
||||||
</p>
|
</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}
|
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>
|
||||||
|
|
||||||
<div class="flex justify-center mt-6">
|
{#if streak > 1}
|
||||||
{#if hasWebShare}
|
<p class="big-text text-orange-500! text-sm! mt-4">
|
||||||
<!-- mobile and arc in production -->
|
🔥 {streak} day streak!
|
||||||
<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)}
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</Container>
|
</Container>
|
||||||
@@ -225,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>
|
||||||
@@ -242,4 +239,141 @@
|
|||||||
.fade-in {
|
.fade-in {
|
||||||
animation: fadeIn 0.5s ease-out;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
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 {
|
function getOrCreateAnonymousId(): string {
|
||||||
if (!browser) return "";
|
if (!browser) return "";
|
||||||
const key = "bibdle-anonymous-id";
|
const key = "bibdle-anonymous-id";
|
||||||
@@ -12,6 +14,9 @@ function getOrCreateAnonymousId(): string {
|
|||||||
return id;
|
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(
|
export function createGamePersistence(
|
||||||
getDate: () => string,
|
getDate: () => string,
|
||||||
getReference: () => string,
|
getReference: () => string,
|
||||||
@@ -24,7 +29,8 @@ export function createGamePersistence(
|
|||||||
let chapterGuessCompleted = $state(false);
|
let chapterGuessCompleted = $state(false);
|
||||||
let chapterCorrect = $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(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
@@ -36,6 +42,7 @@ export function createGamePersistence(
|
|||||||
anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tell Umami analytics which player this is so events are grouped correctly.
|
||||||
if ((window as any).umami) {
|
if ((window as any).umami) {
|
||||||
(window as any).umami.identify(anonymousId);
|
(window as any).umami.identify(anonymousId);
|
||||||
}
|
}
|
||||||
@@ -43,8 +50,11 @@ export function createGamePersistence(
|
|||||||
const date = getDate();
|
const date = getDate();
|
||||||
const reference = getReference();
|
const reference = getReference();
|
||||||
|
|
||||||
|
// Restore whether today's completion was already submitted to the server.
|
||||||
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
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}`;
|
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||||
if (chapterGuessCompleted) {
|
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(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
@@ -72,13 +84,14 @@ export function createGamePersistence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let savedIds: string[] = JSON.parse(saved);
|
let savedIds: string[] = JSON.parse(saved);
|
||||||
savedIds = Array.from(new Set(savedIds));
|
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
|
||||||
guesses = savedIds
|
guesses = savedIds
|
||||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||||
.filter((g): g is Guess => g !== null);
|
.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(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const date = getDate();
|
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() {
|
function markStatsSubmitted() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
statsSubmitted = true;
|
statsSubmitted = true;
|
||||||
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "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() {
|
function markWinTracked() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const key = `bibdle-win-tracked-${getDate()}`;
|
const key = `bibdle-win-tracked-${getDate()}`;
|
||||||
@@ -102,11 +119,16 @@ export function createGamePersistence(
|
|||||||
return true;
|
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 {
|
function isWinAlreadyTracked(): boolean {
|
||||||
if (!browser) return false;
|
if (!browser) return false;
|
||||||
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
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[]) {
|
function hydrateFromServer(guessIds: string[]) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const correctBookId = getCorrectBookId();
|
const correctBookId = getCorrectBookId();
|
||||||
@@ -116,6 +138,8 @@ export function createGamePersistence(
|
|||||||
.filter((g): g is Guess => g !== null);
|
.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() {
|
function onChapterGuessCompleted() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
chapterGuessCompleted = true;
|
chapterGuessCompleted = true;
|
||||||
|
|||||||
@@ -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
7
src/lib/utils/streak.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,6 +306,8 @@
|
|||||||
guessCount={persistence.guesses.length}
|
guessCount={persistence.guesses.length}
|
||||||
reference={dailyVerse.reference}
|
reference={dailyVerse.reference}
|
||||||
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||||
|
shareText={getShareText()}
|
||||||
|
{streak}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -387,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>
|
||||||
|
|||||||
36
src/routes/api/streak/+server.ts
Normal file
36
src/routes/api/streak/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user