mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
3 Commits
f9f3f3de12
...
e1a665ba63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a665ba63 | ||
|
|
f3c9feaf97 | ||
|
|
a5cf248e29 |
41
scripts/test-share-text.ts
Normal file
41
scripts/test-share-text.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||
import { generateShareText } from '../src/lib/utils/share';
|
||||
import { bibleBooks } from '../src/lib/types/bible';
|
||||
|
||||
const NUM_VERSES = 10;
|
||||
|
||||
for (let i = 0; i < NUM_VERSES; i++) {
|
||||
const verse = await fetchRandomVerse();
|
||||
|
||||
// Build a fake "solved in N guesses" scenario with some wrong guesses first
|
||||
const correctBook = bibleBooks.find((b) => b.id === verse.bookId)!;
|
||||
const wrongBook = bibleBooks.find((b) => b.id !== verse.bookId)!;
|
||||
const guessCount = Math.floor(Math.random() * 5) + 1;
|
||||
const guesses = [
|
||||
...Array(guessCount - 1).fill(null).map(() => ({
|
||||
book: wrongBook,
|
||||
testamentMatch: wrongBook.testament === correctBook.testament,
|
||||
sectionMatch: wrongBook.section === correctBook.section,
|
||||
adjacent: Math.abs(wrongBook.order - correctBook.order) === 1,
|
||||
})),
|
||||
{ book: correctBook, testamentMatch: true, sectionMatch: true, adjacent: false },
|
||||
];
|
||||
|
||||
const fakeStreak = Math.random() > 0.5 ? Math.floor(Math.random() * 14) + 2 : 0;
|
||||
|
||||
const shareText = generateShareText({
|
||||
guesses,
|
||||
correctBookId: verse.bookId,
|
||||
dailyVerseDate: new Date().toISOString().slice(0, 10),
|
||||
chapterCorrect: guessCount === 1 && Math.random() > 0.5,
|
||||
isLoggedIn: Math.random() > 0.5,
|
||||
streak: fakeStreak > 0 ? fakeStreak : undefined,
|
||||
origin: 'https://bibdle.com',
|
||||
verseText: verse.verseText,
|
||||
});
|
||||
|
||||
console.log(`\n── Verse ${i + 1}: ${verse.reference} ──`);
|
||||
console.log(`RAW: ${verse.verseText}`);
|
||||
console.log('─'.repeat(40));
|
||||
console.log(shareText);
|
||||
}
|
||||
10
scripts/test-verse-snippets.ts
Normal file
10
scripts/test-verse-snippets.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||
import { getVerseSnippet } from '../src/lib/utils/share';
|
||||
|
||||
const NUM_VERSES = 10;
|
||||
|
||||
for (let i = 0; i < NUM_VERSES; i++) {
|
||||
const verse = await fetchRandomVerse();
|
||||
|
||||
console.log(getVerseSnippet(verse.verseText));
|
||||
}
|
||||
@@ -50,9 +50,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full flex flex-col flex-1">
|
||||
<div
|
||||
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
||||
class="flex flex-col items-center justify-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full flex-1"
|
||||
>
|
||||
{#if newVerseReady}
|
||||
<p
|
||||
@@ -73,7 +73,7 @@
|
||||
Next Verse In
|
||||
</p>
|
||||
<p
|
||||
class="text-4xl font-triodion font-black text-gray-800 tabular-nums"
|
||||
class="text-4xl font-triodion font-black text-gray-800 tabular-nums whitespace-nowrap"
|
||||
>
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
|
||||
31
src/lib/components/StreakCounter.svelte
Normal file
31
src/lib/components/StreakCounter.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
streak,
|
||||
streakPercentile = null,
|
||||
}: {
|
||||
streak: number;
|
||||
streakPercentile?: number | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white/50 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 shadow-sm flex-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="text-5xl font-triodion font-black text-orange-500 leading-none tabular-nums"
|
||||
>
|
||||
{streak}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs uppercase justify-center tracking-widest text-gray-500 font-triodion font-bold py-2 leading-tight"
|
||||
>
|
||||
day{streak === 1 ? "" : "s"} in a row
|
||||
</p>
|
||||
{#if streakPercentile !== null && streakPercentile <= 50}
|
||||
<p
|
||||
class="text-xs text-black w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 pt-2"
|
||||
>
|
||||
Top {streakPercentile}%
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -80,19 +80,6 @@
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
<div
|
||||
transition:fade={{ duration: 300 }}
|
||||
class="flex justify-center mt-3"
|
||||
>
|
||||
<button
|
||||
onclick={copyVerse}
|
||||
data-umami-event="Copy Verse"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs big-text text-gray-600 bg-white/50 hover:bg-white/70 border border-gray-300 rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
{copied ? "✅" : "📋"}
|
||||
{copied ? "Copied!" : "Copy verse to clipboard"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||
import {
|
||||
getVerseSnippet,
|
||||
shareResult,
|
||||
copyToClipboard as clipboardCopy,
|
||||
} from "$lib/utils/share";
|
||||
import Container from "./Container.svelte";
|
||||
import CountdownTimer from "./CountdownTimer.svelte";
|
||||
import StreakCounter from "./StreakCounter.svelte";
|
||||
import ChapterGuess from "./ChapterGuess.svelte";
|
||||
|
||||
interface StatsData {
|
||||
@@ -30,6 +36,7 @@
|
||||
reference,
|
||||
onChapterGuessCompleted,
|
||||
shareText,
|
||||
verseText,
|
||||
streak = 0,
|
||||
streakPercentile = null,
|
||||
}: {
|
||||
@@ -43,6 +50,7 @@
|
||||
reference: string;
|
||||
onChapterGuessCompleted: () => void;
|
||||
shareText: string;
|
||||
verseText: string;
|
||||
streak?: number;
|
||||
streakPercentile?: number | null;
|
||||
} = $props();
|
||||
@@ -53,6 +61,23 @@
|
||||
);
|
||||
let copySuccess = $state(false);
|
||||
let bubbleCopied = $state(false);
|
||||
let copyTracked = $state(false);
|
||||
let showSnippetOption = $state(false);
|
||||
let includeSnippet = $state(false);
|
||||
|
||||
let effectiveShareText = $derived(
|
||||
includeSnippet
|
||||
? (() => {
|
||||
const snippet = getVerseSnippet(verseText);
|
||||
const lines = shareText.split("\n");
|
||||
return [
|
||||
...lines.slice(0, -1),
|
||||
snippet,
|
||||
lines[lines.length - 1],
|
||||
].join("\n");
|
||||
})()
|
||||
: shareText,
|
||||
);
|
||||
|
||||
// List of congratulations messages with weights
|
||||
const congratulationsMessages: WeightedMessage[] = [
|
||||
@@ -97,37 +122,27 @@
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Container
|
||||
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"
|
||||
class="w-full px-4 sm:px-6 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"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<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>.
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||
>{bookName}</span
|
||||
>.
|
||||
</p>
|
||||
<p class="text-lg sm:text-xl md:text-2xl mt-2">
|
||||
<p class="text-lg sm:text-xl md:text-2xl">
|
||||
You guessed correctly after {guessCount}
|
||||
{guessCount === 1 ? "guess" : "guesses"}.
|
||||
</p>
|
||||
|
||||
{#if streak > 1}
|
||||
<div class="flex flex-col gap-4 my-4">
|
||||
<p class="big-text text-orange-500! text-lg!">
|
||||
🔥 {streak} days in a row!
|
||||
<!-- {#if streak >= 7}
|
||||
<p
|
||||
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||
>
|
||||
Thank you for making BIBDLE part of your daily routine!
|
||||
</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 text-gray-700 font-triodion">
|
||||
{streakPercentile <= 50
|
||||
? "Only "
|
||||
: ""}{streakPercentile}% of players have a streak of {streak}
|
||||
or greater.
|
||||
</p>
|
||||
{/if}
|
||||
{/if} -->
|
||||
</div>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<!-- S++ Bonus Challenge for first try -->
|
||||
@@ -139,7 +154,16 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||
<div class="flex-2 min-w-0 flex flex-col">
|
||||
<CountdownTimer />
|
||||
</div>
|
||||
{#if streak > 0}
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<StreakCounter {streak} {streakPercentile} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Statistics Display -->
|
||||
{#if statsData}
|
||||
@@ -223,10 +247,15 @@
|
||||
onclick={() => {
|
||||
if (hasWebShare) {
|
||||
(window as any).rybbit?.event("Share");
|
||||
handleShare();
|
||||
shareResult(effectiveShareText);
|
||||
} else {
|
||||
(window as any).rybbit?.event("Copy to Clipboard");
|
||||
copyToClipboard();
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event(
|
||||
"Copy to Clipboard",
|
||||
);
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
copySuccess = false;
|
||||
@@ -251,13 +280,17 @@
|
||||
aria-label="Copy to clipboard"
|
||||
data-umami-event="Copy to Clipboard"
|
||||
onclick={() => {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event("Copy to Clipboard");
|
||||
copyToClipboard();
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
showSnippetOption = true;
|
||||
bubbleCopied = true;
|
||||
setTimeout(() => {
|
||||
bubbleCopied = false;
|
||||
}, 2000);
|
||||
}}>{shareText}</button
|
||||
}}>{effectiveShareText}</button
|
||||
>
|
||||
{#if hasWebShare}
|
||||
<span class="copy-hint"
|
||||
@@ -271,6 +304,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSnippetOption}
|
||||
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
||||
<span class="snippet-label">Show verse snippet in share?</span>
|
||||
<button
|
||||
class="snippet-toggle"
|
||||
class:on={includeSnippet}
|
||||
onclick={() => (includeSnippet = !includeSnippet)}
|
||||
aria-pressed={includeSnippet}
|
||||
aria-label="Show snippet in share"
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -476,4 +524,53 @@
|
||||
transform-origin: right center;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
/* ── Snippet toggle row ── */
|
||||
.snippet-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.snippet-label {
|
||||
font-size: 0.72rem;
|
||||
color: #666;
|
||||
letter-spacing: 0.01em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snippet-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #ccc;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snippet-toggle.on {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.snippet-toggle.on .toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const user = sqliteTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
import type { Guess } from './game';
|
||||
|
||||
export function getVerseSnippet(verseText: string): string {
|
||||
const words = verseText.trim().split(/\s+/);
|
||||
const slice = words.slice(0, 25);
|
||||
const text = slice.join(' ');
|
||||
|
||||
// Returns character index immediately after the Nth word (1-indexed)
|
||||
function posAfterWord(n: number): number {
|
||||
let pos = 0;
|
||||
for (let w = 0; w < Math.min(n, slice.length); w++) {
|
||||
if (w > 0) pos++; // space between words
|
||||
pos += slice[w].length;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
const start = posAfterWord(9);
|
||||
const end = posAfterWord(25);
|
||||
|
||||
// Find first punctuation mark between words 10 and 25
|
||||
const range = text.substring(start, end);
|
||||
const match = range.match(/[,;:.!?—–-]/);
|
||||
|
||||
function withClosedQuotes(snippet: string): string {
|
||||
const opens = (snippet.match(/\u201C/g) ?? []).length;
|
||||
const closes = (snippet.match(/\u201D/g) ?? []).length;
|
||||
const closeQuote = opens > closes ? '\u201D' : '';
|
||||
return `\u201C${snippet}...${closeQuote}\u201D`;
|
||||
}
|
||||
|
||||
if (match && match.index !== undefined) {
|
||||
const cutPos = start + match.index;
|
||||
return withClosedQuotes(text.substring(0, cutPos).trimEnd());
|
||||
}
|
||||
|
||||
return withClosedQuotes(text);
|
||||
}
|
||||
|
||||
export function generateShareText(params: {
|
||||
guesses: Guess[];
|
||||
correctBookId: string;
|
||||
@@ -8,8 +45,9 @@ export function generateShareText(params: {
|
||||
isLoggedIn: boolean;
|
||||
streak?: number;
|
||||
origin: string;
|
||||
verseText: string;
|
||||
}): string {
|
||||
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin } = params;
|
||||
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
|
||||
|
||||
const emojis = guesses
|
||||
.slice()
|
||||
@@ -35,17 +73,15 @@ export function generateShareText(params: {
|
||||
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
||||
|
||||
const guessWord = guesses.length === 1 ? "guess" : "guesses";
|
||||
const streakPart = streak !== undefined && streak > 1 ? `, ${streak} days 🔥` : "";
|
||||
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
|
||||
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
|
||||
|
||||
const lines = [
|
||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||
`${guesses.length} ${guessWord}${streakPart}`,
|
||||
];
|
||||
|
||||
lines.push(
|
||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
||||
`${guesses.length} ${guessWord},${streakPart}`,
|
||||
`${emojis}${chapterStar}`,
|
||||
origin,
|
||||
);
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@
|
||||
isLoggedIn: !!user,
|
||||
streak,
|
||||
origin: window.location.origin,
|
||||
verseText: dailyVerse.verseText,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,6 +319,7 @@
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||
shareText={getShareText()}
|
||||
verseText={dailyVerse.verseText}
|
||||
{streak}
|
||||
{streakPercentile}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user