mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
6 Commits
f98ab24d2e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5e16c7e71 | ||
|
|
e45ac28169 | ||
|
|
3d578a9eb8 | ||
|
|
db04da6a2c | ||
|
|
321fac9aa8 | ||
|
|
4a5aef5a3d |
3
drizzle/0003_overjoyed_mindworm.sql
Normal file
3
drizzle/0003_overjoyed_mindworm.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `daily_completions` ADD `guesses` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `google_id` text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_google_id_unique` ON `user` (`google_id`);
|
||||||
296
drizzle/meta/0003_snapshot.json
Normal file
296
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "80883fb9-70cd-4fa5-b228-36358ffc4c40",
|
||||||
|
"prevId": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||||
|
"tables": {
|
||||||
|
"daily_completions": {
|
||||||
|
"name": "daily_completions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"anonymous_id": {
|
||||||
|
"name": "anonymous_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"guess_count": {
|
||||||
|
"name": "guess_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"guesses": {
|
||||||
|
"name": "guesses",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"anonymous_id_date_idx": {
|
||||||
|
"name": "anonymous_id_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_idx": {
|
||||||
|
"name": "date_idx",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_guess_idx": {
|
||||||
|
"name": "date_guess_idx",
|
||||||
|
"columns": [
|
||||||
|
"date",
|
||||||
|
"guess_count"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"daily_completions_anonymous_id_date_unique": {
|
||||||
|
"name": "daily_completions_anonymous_id_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"daily_verses": {
|
||||||
|
"name": "daily_verses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"book_id": {
|
||||||
|
"name": "book_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"verse_text": {
|
||||||
|
"name": "verse_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"name": "reference",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"daily_verses_date_unique": {
|
||||||
|
"name": "daily_verses_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_name": {
|
||||||
|
"name": "first_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_name": {
|
||||||
|
"name": "last_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"apple_id": {
|
||||||
|
"name": "apple_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_private": {
|
||||||
|
"name": "is_private",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_apple_id_unique": {
|
||||||
|
"name": "user_apple_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"apple_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1770961427714,
|
"when": 1770961427714,
|
||||||
"tag": "0002_outstanding_hiroim",
|
"tag": "0002_outstanding_hiroim",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1774416309647,
|
||||||
|
"tag": "0003_overjoyed_mindworm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"drizzle": "^1.4.0",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
|
|||||||
@@ -105,6 +105,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" action="/auth/google">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium mt-3"
|
||||||
|
data-umami-event="Sign in with Google"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||||
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="flex items-center my-4">
|
<div class="flex items-center my-4">
|
||||||
<div class="flex-1 border-t border-white/20"></div>
|
<div class="flex-1 border-t border-white/20"></div>
|
||||||
<span class="px-3 text-sm text-white/60">or</span>
|
<span class="px-3 text-sm text-white/60">or</span>
|
||||||
|
|||||||
102
src/lib/components/CollapsibleTable.svelte
Normal file
102
src/lib/components/CollapsibleTable.svelte
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts" generics="T">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Header {
|
||||||
|
label: string;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mode {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: T[];
|
||||||
|
headers: Header[];
|
||||||
|
row: Snippet<[item: T]>;
|
||||||
|
empty?: Snippet;
|
||||||
|
initialRows?: number;
|
||||||
|
modes?: Mode[];
|
||||||
|
mode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
rows,
|
||||||
|
headers,
|
||||||
|
row: rowSnippet,
|
||||||
|
empty,
|
||||||
|
initialRows = 3,
|
||||||
|
modes,
|
||||||
|
mode = $bindable(modes && modes.length > 0 ? modes[0].value : undefined),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
// Reset expanded when mode changes (e.g. switching Rolling 30d ↔ Calendar)
|
||||||
|
$effect(() => {
|
||||||
|
mode;
|
||||||
|
expanded = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedRows = $derived(expanded ? rows : rows.slice(0, initialRows));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if modes && modes.length > 1}
|
||||||
|
<div class="flex gap-1 bg-white/5 rounded-lg p-1 w-fit ml-auto mb-3">
|
||||||
|
{#each modes as m (m.value)}
|
||||||
|
{@const active = mode === m.value}
|
||||||
|
<button
|
||||||
|
onclick={() => (mode = m.value)}
|
||||||
|
class="px-3 py-1 text-xs rounded-md transition-colors {active ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if rows.length === 0}
|
||||||
|
{#if empty}
|
||||||
|
{@render empty()}
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||||
|
{#each headers as header (header.label)}
|
||||||
|
<th
|
||||||
|
class="{header.align === 'right' ? 'text-right' : 'text-left'} px-4 py-3{header.width ? ' ' + header.width : ''}"
|
||||||
|
>
|
||||||
|
{header.label}
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayedRows as item, i (i)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
{@render rowSnippet(item)}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rows.length > initialRows}
|
||||||
|
<button
|
||||||
|
onclick={toggleExpanded}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{expanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
let timeUntilNext = $state("");
|
let timeUntilNext = $state("");
|
||||||
let newVerseReady = $state(false);
|
let newVerseReady = $state(false);
|
||||||
|
let showEncouragement = $state(false);
|
||||||
let intervalId: number | null = null;
|
let intervalId: number | null = null;
|
||||||
let targetTime = 0;
|
let targetTime = 0;
|
||||||
|
|
||||||
@@ -41,6 +42,13 @@
|
|||||||
initTarget();
|
initTarget();
|
||||||
updateTimer();
|
updateTimer();
|
||||||
intervalId = window.setInterval(updateTimer, 1000);
|
intervalId = window.setInterval(updateTimer, 1000);
|
||||||
|
|
||||||
|
const winCount = Object.keys(localStorage).filter(
|
||||||
|
(k) =>
|
||||||
|
k.startsWith("bibdle-win-tracked-") &&
|
||||||
|
localStorage.getItem(k) === "true",
|
||||||
|
).length;
|
||||||
|
showEncouragement = winCount < 3;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -77,6 +85,13 @@
|
|||||||
>
|
>
|
||||||
{timeUntilNext}
|
{timeUntilNext}
|
||||||
</p>
|
</p>
|
||||||
|
{#if showEncouragement}
|
||||||
|
<p
|
||||||
|
class="text-xs text-center uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold px-4 mt-3"
|
||||||
|
>
|
||||||
|
Come back tomorrow for a new verse!
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
class="big-text text-center mb-6 px-4"
|
class="big-text text-center text-gray-800 dark:text-gray-100 mb-6 px-4"
|
||||||
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||||
>
|
>
|
||||||
{promptText}
|
{promptText}
|
||||||
|
|||||||
@@ -26,13 +26,7 @@
|
|||||||
if (guess.book.id === correctBookId) {
|
if (guess.book.id === correctBookId) {
|
||||||
return "background-color: #22c55e; border-color: #16a34a;";
|
return "background-color: #22c55e; border-color: #16a34a;";
|
||||||
}
|
}
|
||||||
const correctBook = bibleBooks.find((b) => b.id === correctBookId);
|
return "background-color: #ef4444; border-color: #dc2626;";
|
||||||
if (!correctBook)
|
|
||||||
return "background-color: #ef4444; border-color: #dc2626;";
|
|
||||||
const t = Math.abs(guess.book.order - correctBook.order) / 65;
|
|
||||||
const hue = 120 * Math.pow(1 - t, 3);
|
|
||||||
const lightness = 55 - (hue / 120) * 15;
|
|
||||||
return `background-color: #ef4444; border-color: hsl(${hue}, 80%, ${lightness}%);`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBoxContent(
|
function getBoxContent(
|
||||||
|
|||||||
@@ -237,7 +237,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
<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="big-text font-black! text-center text-gray-300!">
|
||||||
|
Share your result
|
||||||
|
</div>
|
||||||
<div class="chat-window">
|
<div class="chat-window">
|
||||||
<!-- Received bubble: primary action (share / copy) -->
|
<!-- Received bubble: primary action (share / copy) -->
|
||||||
<div class="bubble-wrapper received-wrapper">
|
<div class="bubble-wrapper received-wrapper">
|
||||||
@@ -331,12 +333,19 @@
|
|||||||
|
|
||||||
{#if isLoggedIn}
|
{#if isLoggedIn}
|
||||||
<div class="signin-prompt">
|
<div class="signin-prompt">
|
||||||
<a href="/progress" class="progress-btn"> 📈 See your progress </a>
|
<div class="rainbow-glow w-full">
|
||||||
|
<a
|
||||||
|
href="/progress"
|
||||||
|
class="flex flex-col items-center justify-center gap-1 w-full p-4 mb-2 bg-white dark:bg-gray-900 border-2 border-black/40 dark:border-white/40 rounded-2xl shadow-sm text-gray-800 dark:text-gray-100 text-base font-semibold no-underline transition-transform duration-100 hover:-translate-y-px active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
📈 See your progress
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="signin-prompt">
|
<div class="signin-prompt">
|
||||||
<p class="signin-text">
|
<p class="signin-text text-gray-800 dark:text-gray-300">
|
||||||
Sign in to save your streak & track your progress
|
Create an account (or sign in) to track your progress
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="/auth/apple" class="w-full">
|
<form method="POST" action="/auth/apple" class="w-full">
|
||||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
@@ -357,6 +366,38 @@
|
|||||||
Sign in with Apple
|
Sign in with Apple
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="POST" action="/auth/google" class="w-full">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="google-signin-btn"
|
||||||
|
data-umami-event="Sign in with Google"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="google-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -640,6 +681,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Apple Sign In prompt ── */
|
/* ── Apple Sign In prompt ── */
|
||||||
|
.rainbow-glow {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rainbow-glow::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0px;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: conic-gradient(
|
||||||
|
from var(--angle, 0deg),
|
||||||
|
#ff0080,
|
||||||
|
#ff8c00,
|
||||||
|
#ffd700,
|
||||||
|
#00ff88,
|
||||||
|
#00cfff,
|
||||||
|
#a855f7,
|
||||||
|
#ff0080
|
||||||
|
);
|
||||||
|
animation: rainbow-rotate 6s linear infinite;
|
||||||
|
filter: blur(8px);
|
||||||
|
opacity: 0.75;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --angle {
|
||||||
|
syntax: "<angle>";
|
||||||
|
initial-value: 0deg;
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rainbow-rotate {
|
||||||
|
0% {
|
||||||
|
--angle: 0deg;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
--angle: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.signin-prompt {
|
.signin-prompt {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -650,17 +732,10 @@
|
|||||||
|
|
||||||
.signin-text {
|
.signin-text {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #555;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.signin-text {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.apple-signin-btn {
|
.apple-signin-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -710,42 +785,51 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-btn {
|
.google-signin-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0.6rem;
|
background: #000;
|
||||||
background: #059669;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background 150ms ease,
|
background 150ms ease,
|
||||||
transform 80ms ease;
|
transform 80ms ease;
|
||||||
}
|
}
|
||||||
.progress-btn:hover {
|
|
||||||
background: #047857;
|
.google-signin-btn:hover {
|
||||||
|
background: #222;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.progress-btn:active {
|
|
||||||
background: #065f46;
|
.google-signin-btn:active {
|
||||||
|
background: #111;
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.progress-btn {
|
.google-signin-btn {
|
||||||
background: #10b981;
|
background: #fff;
|
||||||
color: #fff;
|
color: #000;
|
||||||
}
|
}
|
||||||
.progress-btn:hover {
|
.google-signin-btn:hover {
|
||||||
background: #059669;
|
background: #e5e5e5;
|
||||||
}
|
}
|
||||||
.progress-btn:active {
|
.google-signin-btn:active {
|
||||||
background: #047857;
|
background: #ccc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.google-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
|
|||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
appleId: null,
|
appleId: null,
|
||||||
|
googleId: null,
|
||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
isPrivate: false
|
isPrivate: false
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function validateSessionToken(token: string) {
|
|||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// Adjust user table here to tweak returned data
|
||||||
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId },
|
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId, googleId: table.user.googleId },
|
||||||
session: table.session
|
session: table.session
|
||||||
})
|
})
|
||||||
.from(table.session)
|
.from(table.session)
|
||||||
@@ -99,6 +99,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
|
|||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
appleId: null,
|
appleId: null,
|
||||||
|
googleId: null,
|
||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
isPrivate: false
|
isPrivate: false
|
||||||
@@ -117,6 +118,11 @@ export async function getUserByAppleId(appleId: string) {
|
|||||||
return user || null;
|
return user || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserByGoogleId(googleId: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.googleId, googleId));
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||||
if (!anonymousId || anonymousId === userId) return;
|
if (!anonymousId || anonymousId === userId) return;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const user = sqliteTable('user', {
|
|||||||
email: text('email').unique(),
|
email: text('email').unique(),
|
||||||
passwordHash: text('password_hash'),
|
passwordHash: text('password_hash'),
|
||||||
appleId: text('apple_id').unique(),
|
appleId: text('apple_id').unique(),
|
||||||
|
googleId: text('google_id').unique(),
|
||||||
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
65
src/lib/server/google-auth.ts
Normal file
65
src/lib/server/google-auth.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||||
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||||
|
|
||||||
|
export function getGoogleAuthUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.GOOGLE_CLIENT_ID!,
|
||||||
|
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
access_type: 'online',
|
||||||
|
prompt: 'select_account'
|
||||||
|
});
|
||||||
|
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeGoogleCode(
|
||||||
|
code: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
id_token: string;
|
||||||
|
scope: string;
|
||||||
|
}> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.GOOGLE_CLIENT_ID!,
|
||||||
|
client_secret: Bun.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Google token exchange failed: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Google's id_token JWT payload without signature verification.
|
||||||
|
* Safe because the token is received directly from Google's token endpoint over TLS.
|
||||||
|
*/
|
||||||
|
export function decodeGoogleIdToken(idToken: string): {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
name?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
} {
|
||||||
|
const [, payloadB64] = idToken.split('.');
|
||||||
|
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||||
|
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
282
src/lib/server/milestones.ts
Normal file
282
src/lib/server/milestones.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
|
import type { DailyCompletion } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export type Milestone = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
description: string;
|
||||||
|
achieved: boolean;
|
||||||
|
achievedDate: string | null; // YYYY-MM-DD of first achievement, or null
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClassicMilestoneInputs = {
|
||||||
|
bestSingleGame: { date: string; bookName: string } | null;
|
||||||
|
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function calculateMilestones(
|
||||||
|
completions: DailyCompletion[],
|
||||||
|
dateToBookId: Map<string, string>,
|
||||||
|
classic: ClassicMilestoneInputs,
|
||||||
|
): Promise<Milestone[]> {
|
||||||
|
const sorted = [...completions].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
// Helper: returns the date when all books in targetIds were first solved
|
||||||
|
function findSetDate(targetIds: Set<string>): string | null {
|
||||||
|
const solved = new Set<string>();
|
||||||
|
for (const c of sorted) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (bookId && targetIds.has(bookId)) {
|
||||||
|
solved.add(bookId);
|
||||||
|
if (solved.size === targetIds.size) return c.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Book sets
|
||||||
|
const ntIds = new Set(bibleBooks.filter(b => b.testament === 'new').map(b => b.id));
|
||||||
|
const otIds = new Set(bibleBooks.filter(b => b.testament === 'old').map(b => b.id));
|
||||||
|
const allIds = new Set(bibleBooks.map(b => b.id));
|
||||||
|
const gospelIds = new Set(['MAT', 'MRK', 'LUK', 'JHN']);
|
||||||
|
const pentateuchIds = new Set(['GEN', 'EXO', 'LEV', 'NUM', 'DEU']);
|
||||||
|
|
||||||
|
// Set-completion milestones
|
||||||
|
const ntScholarDate = findSetDate(ntIds);
|
||||||
|
const otScholarDate = findSetDate(otIds);
|
||||||
|
const theologianDate = findSetDate(allIds);
|
||||||
|
const fantasticFourDate = findSetDate(gospelIds);
|
||||||
|
const pentatonixDate = findSetDate(pentateuchIds);
|
||||||
|
|
||||||
|
// With God, All Things Are Possible — solved in 1 guess for at least one puzzle from each of the 66 books
|
||||||
|
const booksInOne = new Set<string>();
|
||||||
|
let withGodDate: string | null = null;
|
||||||
|
for (const c of sorted) {
|
||||||
|
if (c.guessCount === 1) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (bookId) {
|
||||||
|
booksInOne.add(bookId);
|
||||||
|
if (withGodDate === null && booksInOne.size === allIds.size) {
|
||||||
|
withGodDate = c.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allInOne = booksInOne.size === allIds.size;
|
||||||
|
|
||||||
|
// Is This A Joke To You? — guessed all 65 other books first (66 guesses total)
|
||||||
|
const jokeCompletion = sorted.find(c => c.guessCount >= 66);
|
||||||
|
|
||||||
|
// Prodigal Son — returned after a 30+ day gap
|
||||||
|
let prodigalDate: string | null = null;
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const prev = new Date(sorted[i - 1].date + 'T00:00:00Z');
|
||||||
|
const curr = new Date(sorted[i].date + 'T00:00:00Z');
|
||||||
|
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||||
|
if (diff >= 30) {
|
||||||
|
prodigalDate = sorted[i].date;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra Credit — solved on a Sunday
|
||||||
|
const sundayCompletion = sorted.find(c => {
|
||||||
|
const d = new Date(c.date + 'T00:00:00Z');
|
||||||
|
return d.getUTCDay() === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cross-user milestones: Overachiever, Procrastinator, Outlier
|
||||||
|
let overachieverDate: string | null = null;
|
||||||
|
let procrastinatorDate: string | null = null;
|
||||||
|
let outlierDate: string | null = null;
|
||||||
|
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
const userDates = sorted.map(c => c.date);
|
||||||
|
const allOnDates = await db
|
||||||
|
.select({
|
||||||
|
date: dailyCompletions.date,
|
||||||
|
completedAt: dailyCompletions.completedAt,
|
||||||
|
guessCount: dailyCompletions.guessCount,
|
||||||
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
|
})
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(inArray(dailyCompletions.date, userDates));
|
||||||
|
|
||||||
|
// Group all completions by date
|
||||||
|
const byDate = new Map<string, typeof allOnDates>();
|
||||||
|
for (const c of allOnDates) {
|
||||||
|
const arr = byDate.get(c.date) ?? [];
|
||||||
|
arr.push(c);
|
||||||
|
byDate.set(c.date, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userByDate = new Map(sorted.map(c => [c.date, c]));
|
||||||
|
|
||||||
|
for (const userComp of sorted) {
|
||||||
|
const allForDate = byDate.get(userComp.date) ?? [];
|
||||||
|
if (allForDate.length < 2) continue; // need multiple players
|
||||||
|
|
||||||
|
const validTimes = allForDate
|
||||||
|
.filter(c => c.completedAt != null)
|
||||||
|
.map(c => c.completedAt!.getTime());
|
||||||
|
|
||||||
|
if (!overachieverDate && userComp.completedAt && validTimes.length > 0) {
|
||||||
|
const earliest = Math.min(...validTimes);
|
||||||
|
if (userComp.completedAt.getTime() === earliest) {
|
||||||
|
overachieverDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!procrastinatorDate && userComp.completedAt && validTimes.length > 0) {
|
||||||
|
const latest = Math.max(...validTimes);
|
||||||
|
if (userComp.completedAt.getTime() === latest) {
|
||||||
|
procrastinatorDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outlierDate && allForDate.length >= 10) {
|
||||||
|
const sortedGuesses = allForDate.map(c => c.guessCount).sort((a, b) => a - b);
|
||||||
|
const cutoffIndex = Math.ceil(sortedGuesses.length * 0.1) - 1;
|
||||||
|
const cutoff = sortedGuesses[cutoffIndex];
|
||||||
|
if (userComp.guessCount <= cutoff) {
|
||||||
|
outlierDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'first-1-guess',
|
||||||
|
name: 'Lightning Strike',
|
||||||
|
emoji: '⚡',
|
||||||
|
description: `First 1-guess solve${classic.bestSingleGame ? ` — ${classic.bestSingleGame.bookName}` : ''}`,
|
||||||
|
achieved: classic.bestSingleGame !== null,
|
||||||
|
achievedDate: classic.bestSingleGame?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-7',
|
||||||
|
name: '7-Day Streak',
|
||||||
|
emoji: '🔥',
|
||||||
|
description: 'Solve Bibdle 7 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days7 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-14',
|
||||||
|
name: '14-Day Streak',
|
||||||
|
emoji: '💥',
|
||||||
|
description: 'Solve Bibdle 14 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days14 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-30',
|
||||||
|
name: '30-Day Streak',
|
||||||
|
emoji: '🏅',
|
||||||
|
description: 'Solve Bibdle 30 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days30 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nt-scholar',
|
||||||
|
name: 'NT Scholar',
|
||||||
|
emoji: '✝️',
|
||||||
|
description: 'Solve for every New Testament book',
|
||||||
|
achieved: ntScholarDate !== null,
|
||||||
|
achievedDate: ntScholarDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ot-scholar',
|
||||||
|
name: 'OT Scholar',
|
||||||
|
emoji: '📜',
|
||||||
|
description: 'Solve for every Old Testament book',
|
||||||
|
achieved: otScholarDate !== null,
|
||||||
|
achievedDate: otScholarDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theologian',
|
||||||
|
name: 'Theologian',
|
||||||
|
emoji: '🎓',
|
||||||
|
description: 'Solve for all 66 books of the Bible',
|
||||||
|
achieved: theologianDate !== null,
|
||||||
|
achievedDate: theologianDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fantastic-four',
|
||||||
|
name: 'The Fantastic Four',
|
||||||
|
emoji: '4️⃣',
|
||||||
|
description: 'Solve a puzzle for all four Gospels',
|
||||||
|
achieved: fantasticFourDate !== null,
|
||||||
|
achievedDate: fantasticFourDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pentatonix',
|
||||||
|
name: 'Pentatonix',
|
||||||
|
emoji: '📃',
|
||||||
|
description: 'Solve a puzzle for all five books of the Pentateuch',
|
||||||
|
achieved: pentatonixDate !== null,
|
||||||
|
achievedDate: pentatonixDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'with-god',
|
||||||
|
name: 'With God, All Things Are Possible',
|
||||||
|
emoji: '🙏',
|
||||||
|
description: 'Solve in 1 guess for each of the 66 books at least once',
|
||||||
|
achieved: allInOne,
|
||||||
|
achievedDate: withGodDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'is-this-a-joke',
|
||||||
|
name: 'Is This A Joke To You?',
|
||||||
|
emoji: '😤',
|
||||||
|
description: 'Guess all 65 other books before getting the right one',
|
||||||
|
achieved: jokeCompletion !== undefined,
|
||||||
|
achievedDate: jokeCompletion?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overachiever',
|
||||||
|
name: 'Overachiever',
|
||||||
|
emoji: '⚡',
|
||||||
|
description: 'Be the first person to solve Bibdle on a day',
|
||||||
|
achieved: overachieverDate !== null,
|
||||||
|
achievedDate: overachieverDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'procrastinator',
|
||||||
|
name: 'Procrastinator',
|
||||||
|
emoji: '🐢',
|
||||||
|
description: 'Be the last person to solve Bibdle on a day',
|
||||||
|
achieved: procrastinatorDate !== null,
|
||||||
|
achievedDate: procrastinatorDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prodigal-son',
|
||||||
|
name: 'Prodigal Son',
|
||||||
|
emoji: '🏠',
|
||||||
|
description: 'Return to Bibdle after at least 30 days away',
|
||||||
|
achieved: prodigalDate !== null,
|
||||||
|
achievedDate: prodigalDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'extra-credit',
|
||||||
|
name: 'Extra Credit',
|
||||||
|
emoji: '📅',
|
||||||
|
description: 'Solve Bibdle on a Sunday',
|
||||||
|
achieved: sundayCompletion !== undefined,
|
||||||
|
achievedDate: sundayCompletion?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outlier',
|
||||||
|
name: 'Outlier',
|
||||||
|
emoji: '📊',
|
||||||
|
description: 'Finish in the top 10% of solves on a day (fewest guesses)',
|
||||||
|
achieved: outlierDate !== null,
|
||||||
|
achievedDate: outlierDate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.ico";
|
import favicon from "$lib/assets/favicon.ico";
|
||||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
|
||||||
|
let isDev = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
isDev =
|
||||||
|
window.location.host === "localhost:5173" ||
|
||||||
|
window.location.host === "test.bibdle.com";
|
||||||
|
|
||||||
// Inject analytics script
|
// Inject analytics script
|
||||||
const script = document.createElement('script');
|
const script = document.createElement("script");
|
||||||
script.defer = true;
|
script.defer = true;
|
||||||
script.src = 'https://umami.snail.city/script.js';
|
script.src = "https://umami.snail.city/script.js";
|
||||||
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
script.setAttribute(
|
||||||
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
"data-website-id",
|
||||||
|
"5b8c31ad-71cd-4317-940b-6bccea732acc",
|
||||||
|
);
|
||||||
|
script.setAttribute("data-domains", "bibdle.com,www.bibdle.com");
|
||||||
document.body.appendChild(script);
|
document.body.appendChild(script);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,17 +31,28 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
<link
|
||||||
|
rel="alternate"
|
||||||
|
type="application/rss+xml"
|
||||||
|
title="Bibdle RSS Feed"
|
||||||
|
href="/feed.xml"
|
||||||
|
/>
|
||||||
<meta name="description" content="A daily Bible game" />
|
<meta name="description" content="A daily Bible game" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950">
|
<div
|
||||||
|
class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950"
|
||||||
|
>
|
||||||
<h1
|
<h1
|
||||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
|
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
|
||||||
>
|
>
|
||||||
<TitleAnimation />
|
<TitleAnimation />
|
||||||
<div class="font-normal"></div>
|
<div class="font-normal"></div>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="hidden"><ThemeToggle /></div>
|
{#if isDev}
|
||||||
|
<div class="flex justify-center pb-2"><ThemeToggle /></div>
|
||||||
|
{:else}
|
||||||
|
<div class="justify-center hidden pb-2"><ThemeToggle /></div>
|
||||||
|
{/if}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { PageProps } from "./$types";
|
import type { PageProps } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
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 } from "$lib/utils/game";
|
import { evaluateGuess, getFirstLetter } from "$lib/utils/game";
|
||||||
import {
|
import {
|
||||||
generateShareText,
|
generateShareText,
|
||||||
shareResult,
|
shareResult,
|
||||||
@@ -75,6 +76,62 @@
|
|||||||
!persistence.chapterGuessCompleted,
|
!persistence.chapterGuessCompleted,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let knownTestament = $derived(
|
||||||
|
persistence.guesses.some((g) => g.testamentMatch)
|
||||||
|
? correctBook?.testament
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
let knownSection = $derived(
|
||||||
|
persistence.guesses.some((g) => g.sectionMatch)
|
||||||
|
? correctBook?.section
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
let knownFirstLetter = $derived(
|
||||||
|
persistence.guesses.some((g) => g.firstLetterMatch)
|
||||||
|
? getFirstLetter(correctBook?.name ?? "").toUpperCase()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
let testamentVisible = $state(false);
|
||||||
|
let sectionVisible = $state(false);
|
||||||
|
let firstLetterVisible = $state(false);
|
||||||
|
let showHints = $state(false);
|
||||||
|
|
||||||
|
// On page load, show hints that are already known without animation
|
||||||
|
onMount(() => {
|
||||||
|
if (knownTestament) testamentVisible = true;
|
||||||
|
if (knownSection) sectionVisible = true;
|
||||||
|
if (knownFirstLetter) firstLetterVisible = true;
|
||||||
|
|
||||||
|
const winCount = Object.keys(localStorage).filter(
|
||||||
|
(k) => k.startsWith("bibdle-win-tracked-") && localStorage.getItem(k) === "true"
|
||||||
|
).length;
|
||||||
|
showHints = winCount < 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade in newly revealed hints after the guess animation completes
|
||||||
|
$effect(() => {
|
||||||
|
if (!knownTestament || testamentVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
testamentVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!knownSection || sectionVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
sectionVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!knownFirstLetter || firstLetterVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
firstLetterVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
|
||||||
async function submitGuess(bookId: string) {
|
async function submitGuess(bookId: string) {
|
||||||
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||||
|
|
||||||
@@ -318,6 +375,42 @@
|
|||||||
<div class="animate-fade-in-up animate-delay-400">
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
<GamePrompt guessCount={persistence.guesses.length} />
|
<GamePrompt guessCount={persistence.guesses.length} />
|
||||||
|
|
||||||
|
{#if showHints && (knownTestament || knownSection || knownFirstLetter)}
|
||||||
|
<div
|
||||||
|
class="text-xs uppercase tracking-widest font-bold text-center text-gray-500 dark:text-gray-400 flex flex-col gap-1 mb-4 -mt-2"
|
||||||
|
>
|
||||||
|
{#if knownTestament}
|
||||||
|
<p
|
||||||
|
style="transition: opacity 0.5s ease; opacity: {testamentVisible
|
||||||
|
? 1
|
||||||
|
: 0};"
|
||||||
|
>
|
||||||
|
It is in the {knownTestament === "old"
|
||||||
|
? "Old"
|
||||||
|
: "New"} Testament.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if knownSection}
|
||||||
|
<p
|
||||||
|
style="transition: opacity 0.5s ease; opacity: {sectionVisible
|
||||||
|
? 1
|
||||||
|
: 0};"
|
||||||
|
>
|
||||||
|
It is in the {knownSection} section.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if knownFirstLetter}
|
||||||
|
<p
|
||||||
|
style="transition: opacity 0.5s ease; opacity: {firstLetterVisible
|
||||||
|
? 1
|
||||||
|
: 0};"
|
||||||
|
>
|
||||||
|
The book's name starts with "{knownFirstLetter}".
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<SearchInput
|
<SearchInput
|
||||||
bind:searchQuery
|
bind:searchQuery
|
||||||
{guessedIds}
|
{guessedIds}
|
||||||
@@ -356,7 +449,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isWon}
|
{#if isWon}
|
||||||
<hr class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600" />
|
<hr
|
||||||
|
class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
<div class="animate-fade-in-up animate-delay-800">
|
<div class="animate-fade-in-up animate-delay-800">
|
||||||
<a
|
<a
|
||||||
href="https://discord.gg/yWQXbGK8SD"
|
href="https://discord.gg/yWQXbGK8SD"
|
||||||
@@ -433,7 +528,7 @@
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}{user.email
|
.join(" ")}{user.email
|
||||||
? ` (${user.email})`
|
? ` (${user.email})`
|
||||||
: ""}{user.appleId ? " using Apple" : ""} |
|
: ""}{user.appleId ? " using Apple" : user.googleId ? " using Google" : ""} |
|
||||||
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
|
|||||||
26
src/routes/auth/google/+page.server.ts
Normal file
26
src/routes/auth/google/+page.server.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import { getGoogleAuthUrl } from '$lib/server/google-auth';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ cookies, request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const anonymousId = data.get('anonymousId')?.toString() || '';
|
||||||
|
|
||||||
|
// Generate CSRF state
|
||||||
|
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const state = Buffer.from(stateBytes).toString('base64url');
|
||||||
|
|
||||||
|
// sameSite 'lax' is safe here because Google sends a GET redirect back
|
||||||
|
// (unlike Apple which POSTs cross-origin, requiring 'none')
|
||||||
|
cookies.set('google_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 600
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, getGoogleAuthUrl(state));
|
||||||
|
}
|
||||||
|
};
|
||||||
131
src/routes/auth/google/callback/+server.ts
Normal file
131
src/routes/auth/google/callback/+server.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { exchangeGoogleCode, decodeGoogleIdToken } from '$lib/server/google-auth';
|
||||||
|
import * as auth from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user as userTable } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const errorParam = url.searchParams.get('error');
|
||||||
|
|
||||||
|
// User denied access
|
||||||
|
if (errorParam) {
|
||||||
|
redirect(302, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedRaw = cookies.get('google_oauth_state');
|
||||||
|
if (!storedRaw || !state || !code) {
|
||||||
|
throw error(400, 'Invalid OAuth callback');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||||
|
if (stored.state !== state) {
|
||||||
|
throw error(400, 'State mismatch');
|
||||||
|
}
|
||||||
|
cookies.delete('google_oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
const anonId = stored.anonymousId;
|
||||||
|
if (!anonId) {
|
||||||
|
console.error('[Google auth] Missing anonymousId in state cookie');
|
||||||
|
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
const tokens = await exchangeGoogleCode(
|
||||||
|
code,
|
||||||
|
`${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`
|
||||||
|
);
|
||||||
|
const claims = decodeGoogleIdToken(tokens.id_token);
|
||||||
|
const googleId = claims.sub;
|
||||||
|
|
||||||
|
// --- User resolution ---
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
// 1. Check if a user with this googleId already exists (returning user)
|
||||||
|
const existingGoogleUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
|
||||||
|
if (existingGoogleUser) {
|
||||||
|
userId = existingGoogleUser.id;
|
||||||
|
console.log(`[Google auth] Returning Google user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else if (claims.email) {
|
||||||
|
// 2. Check if email matches an existing email/password or Apple user
|
||||||
|
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||||
|
if (existingEmailUser) {
|
||||||
|
// Link Google account to existing user
|
||||||
|
await db.update(userTable).set({ googleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||||
|
userId = existingEmailUser.id;
|
||||||
|
console.log(`[Google auth] Linked Google to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Google auth] New user (has email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: claims.email,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId: null,
|
||||||
|
googleId,
|
||||||
|
firstName: claims.given_name || null,
|
||||||
|
lastName: claims.family_name || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Handle race condition: if googleId was inserted between our check and insert
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Google auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No email from Google (edge case — Google almost always returns email)
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Google auth] New user (no email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: null,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId: null,
|
||||||
|
googleId,
|
||||||
|
firstName: claims.given_name || null,
|
||||||
|
lastName: claims.family_name || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Google auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionToken = auth.generateSessionToken();
|
||||||
|
const session = await auth.createSession(sessionToken, userId);
|
||||||
|
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||||
|
|
||||||
|
redirect(302, '/');
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Container from "$lib/components/Container.svelte";
|
import Container from "$lib/components/Container.svelte";
|
||||||
|
import CollapsibleTable from "$lib/components/CollapsibleTable.svelte";
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
todayCount: number;
|
todayCount: number;
|
||||||
@@ -86,14 +87,6 @@
|
|||||||
sessionDepthCards,
|
sessionDepthCards,
|
||||||
} = $derived(data);
|
} = $derived(data);
|
||||||
|
|
||||||
// Collapsible table state
|
|
||||||
let returnExpanded = $state(false);
|
|
||||||
let wauExpanded = $state(false);
|
|
||||||
let completionsExpanded = $state(false);
|
|
||||||
let streakExpanded = $state(false);
|
|
||||||
let ret7dExpanded = $state(false);
|
|
||||||
let ret30dExpanded = $state(false);
|
|
||||||
let mauExpanded = $state(false);
|
|
||||||
let mauMode = $state<'rolling' | 'calendar'>('rolling');
|
let mauMode = $state<'rolling' | 'calendar'>('rolling');
|
||||||
|
|
||||||
function signed(n: number, unit = ""): string {
|
function signed(n: number, unit = ""): string {
|
||||||
@@ -109,12 +102,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
|
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
|
||||||
|
|
||||||
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
|
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
|
||||||
|
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
|
||||||
const maxStreakCount = $derived(
|
const maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
|
||||||
Math.max(1, ...streakChart.map((r) => r.count)),
|
const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
|
||||||
);
|
|
||||||
|
|
||||||
const statCards = $derived([
|
const statCards = $derived([
|
||||||
{ label: "Completions Today", value: String(stats.todayCount) },
|
{ label: "Completions Today", value: String(stats.todayCount) },
|
||||||
@@ -129,6 +120,11 @@
|
|||||||
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
|
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const mauModes = [
|
||||||
|
{ value: 'rolling', label: 'Rolling 30d' },
|
||||||
|
{ value: 'calendar', label: 'Calendar' },
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -362,74 +358,34 @@
|
|||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if newPlayerReturnSeries.length > 0}
|
<CollapsibleTable
|
||||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
rows={newPlayerReturnSeries}
|
||||||
<table class="w-full text-sm">
|
headers={[
|
||||||
<thead>
|
{ label: 'Date' },
|
||||||
<tr
|
{ label: 'New Players', align: 'right' },
|
||||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
{ label: 'Return Rate', align: 'right' },
|
||||||
>
|
{ label: '7d Avg', align: 'right' },
|
||||||
<th class="text-left px-4 py-3">Date</th>
|
{ label: '', width: 'w-32' },
|
||||||
<th class="text-right px-4 py-3">New Players</th
|
]}
|
||||||
>
|
>
|
||||||
<th class="text-right px-4 py-3">Return Rate</th
|
{#snippet row(item)}
|
||||||
>
|
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||||
<th class="text-right px-4 py-3">7d Avg</th>
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
|
||||||
<th class="px-4 py-3 w-32"></th>
|
<td class="px-4 py-3 text-right text-gray-400">
|
||||||
</tr>
|
{item.rate != null ? `${item.rate}%` : '—'}
|
||||||
</thead>
|
</td>
|
||||||
<tbody>
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">
|
||||||
{#each (returnExpanded ? newPlayerReturnSeries : newPlayerReturnSeries.slice(0, 3)) as row (row.date)}
|
{item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
|
||||||
<tr
|
</td>
|
||||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
<td class="px-4 py-3">
|
||||||
>
|
<div class="w-full min-w-20">
|
||||||
<td class="px-4 py-3 text-gray-300"
|
{#if item.rollingAvg != null}
|
||||||
>{row.date}</td
|
<div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
|
||||||
>
|
{/if}
|
||||||
<td
|
</div>
|
||||||
class="px-4 py-3 text-right text-gray-400 text-xs"
|
</td>
|
||||||
>{row.cohort}</td
|
{/snippet}
|
||||||
>
|
</CollapsibleTable>
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-gray-400"
|
|
||||||
>{row.rate != null
|
|
||||||
? `${row.rate}%`
|
|
||||||
: "—"}</td
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
|
||||||
>{row.rollingAvg != null
|
|
||||||
? `${row.rollingAvg}%`
|
|
||||||
: "—"}</td
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="w-full min-w-20">
|
|
||||||
{#if row.rollingAvg != null}
|
|
||||||
<div
|
|
||||||
class="bg-sky-500 h-4 rounded"
|
|
||||||
style="width: {row.rollingAvg}%"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if newPlayerReturnSeries.length > 3}
|
|
||||||
<button
|
|
||||||
onclick={() => (returnExpanded = !returnExpanded)}
|
|
||||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<span>{returnExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<p class="text-gray-400 text-sm px-4 py-6">
|
|
||||||
Not enough data yet.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
@@ -440,184 +396,113 @@
|
|||||||
Unique players per 7-day window. Most recent week first. Avg
|
Unique players per 7-day window. Most recent week first. Avg
|
||||||
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
|
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
<CollapsibleTable
|
||||||
<table class="w-full text-sm">
|
rows={wauWeeks}
|
||||||
<thead>
|
headers={[
|
||||||
<tr
|
{ label: 'Week' },
|
||||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
{ label: 'Active Users', align: 'right' },
|
||||||
>
|
{ label: 'Wk/Wk Change', align: 'right' },
|
||||||
<th class="text-left px-4 py-3">Week</th>
|
{ label: '', width: 'w-48' },
|
||||||
<th class="text-right px-4 py-3">Active Users</th>
|
]}
|
||||||
<th class="text-right px-4 py-3">Wk/Wk Change</th>
|
>
|
||||||
<th class="px-4 py-3 w-48"></th>
|
{#snippet row(item)}
|
||||||
</tr>
|
{@const barPct = Math.round((item.wau / maxWau) * 100)}
|
||||||
</thead>
|
<td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} – {item.weekEnd}</td>
|
||||||
<tbody>
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
|
||||||
{#each (wauExpanded ? wauWeeks : wauWeeks.slice(0, 3)) as row (row.weekEnd)}
|
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||||
{@const barPct = Math.round(
|
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||||
(row.wau / maxWau) * 100,
|
: 'text-gray-500'}">
|
||||||
)}
|
{item.changePct != null ? signed(item.changePct, '%') : '—'}
|
||||||
<tr
|
</td>
|
||||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
<td class="px-4 py-3">
|
||||||
>
|
<div class="w-full min-w-24">
|
||||||
<td class="px-4 py-3 text-gray-300 text-xs"
|
<div class="bg-indigo-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
>{row.weekStart} – {row.weekEnd}</td
|
</div>
|
||||||
>
|
</td>
|
||||||
<td
|
{/snippet}
|
||||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
</CollapsibleTable>
|
||||||
>{row.wau}</td
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-xs font-medium {row.changePct !=
|
|
||||||
null
|
|
||||||
? row.changePct > 0
|
|
||||||
? 'text-green-400'
|
|
||||||
: row.changePct < 0
|
|
||||||
? 'text-red-400'
|
|
||||||
: 'text-gray-400'
|
|
||||||
: 'text-gray-500'}"
|
|
||||||
>
|
|
||||||
{row.changePct != null
|
|
||||||
? signed(row.changePct, "%")
|
|
||||||
: "—"}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="w-full min-w-24">
|
|
||||||
<div
|
|
||||||
class="bg-indigo-500 h-4 rounded"
|
|
||||||
style="width: {barPct}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if wauWeeks.length > 3}
|
|
||||||
<button
|
|
||||||
onclick={() => (wauExpanded = !wauExpanded)}
|
|
||||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<span>{wauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
<div class="flex items-center justify-between mb-1">
|
<h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
|
||||||
<h2 class="text-lg font-semibold text-gray-100">Monthly Active Users</h2>
|
|
||||||
<div class="flex gap-1 bg-white/5 rounded-lg p-1">
|
|
||||||
<button
|
|
||||||
onclick={() => (mauMode = 'rolling')}
|
|
||||||
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'rolling' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
|
||||||
>Rolling 30d</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (mauMode = 'calendar')}
|
|
||||||
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'calendar' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
|
||||||
>Calendar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-400 text-sm mb-4">
|
<p class="text-gray-400 text-sm mb-4">
|
||||||
{mauMode === 'rolling' ? 'Unique players per 30-day window. Most recent first.' : 'Unique players per calendar month. Current month projected to end of month.'}
|
{mauMode === 'rolling'
|
||||||
|
? 'Unique players per 30-day window. Most recent first.'
|
||||||
|
: 'Unique players per calendar month. Current month projected to end of month.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if mauMode === 'rolling'}
|
{#if mauMode === 'rolling'}
|
||||||
{@const displayedMauMonths = mauExpanded ? mauMonths : mauMonths.slice(0, 3)}
|
<CollapsibleTable
|
||||||
{@const maxMau = Math.max(1, ...mauMonths.map((m) => m.mau))}
|
rows={mauMonths}
|
||||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
headers={[
|
||||||
<table class="w-full text-sm">
|
{ label: 'Period' },
|
||||||
<thead>
|
{ label: 'Active Users', align: 'right' },
|
||||||
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
{ label: 'Mo/Mo Change', align: 'right' },
|
||||||
<th class="text-left px-4 py-3">Period</th>
|
{ label: '', width: 'w-48' },
|
||||||
<th class="text-right px-4 py-3">Active Users</th>
|
]}
|
||||||
<th class="text-right px-4 py-3">Mo/Mo Change</th>
|
modes={mauModes}
|
||||||
<th class="px-4 py-3 w-48"></th>
|
bind:mode={mauMode}
|
||||||
</tr>
|
>
|
||||||
</thead>
|
{#snippet row(item)}
|
||||||
<tbody>
|
{@const barPct = Math.round((item.mau / maxMau) * 100)}
|
||||||
{#each displayedMauMonths as row (row.monthEnd)}
|
<td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} – {item.monthEnd}</td>
|
||||||
{@const barPct = Math.round((row.mau / maxMau) * 100)}
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
|
||||||
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||||
<td class="px-4 py-3 text-gray-300 text-xs">{row.monthStart} – {row.monthEnd}</td>
|
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.mau}</td>
|
: 'text-gray-500'}">
|
||||||
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}">
|
{item.changePct != null ? signed(item.changePct, '%') : '—'}
|
||||||
{row.changePct != null ? signed(row.changePct, '%') : '—'}
|
</td>
|
||||||
</td>
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<div class="w-full min-w-24">
|
||||||
<div class="w-full min-w-24">
|
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
{/snippet}
|
||||||
</tr>
|
</CollapsibleTable>
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if mauMonths.length > 3}
|
|
||||||
<button
|
|
||||||
onclick={() => (mauExpanded = !mauExpanded)}
|
|
||||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
{:else}
|
||||||
{@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)}
|
<CollapsibleTable
|
||||||
{@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))}
|
rows={calendarMauMonths}
|
||||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
headers={[
|
||||||
<table class="w-full text-sm">
|
{ label: 'Month' },
|
||||||
<thead>
|
{ label: 'Active Users', align: 'right' },
|
||||||
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
{ label: 'Mo/Mo Change', align: 'right' },
|
||||||
<th class="text-left px-4 py-3">Month</th>
|
{ label: '', width: 'w-48' },
|
||||||
<th class="text-right px-4 py-3">Active Users</th>
|
]}
|
||||||
<th class="text-right px-4 py-3">Mo/Mo Change</th>
|
modes={mauModes}
|
||||||
<th class="px-4 py-3 w-48"></th>
|
bind:mode={mauMode}
|
||||||
</tr>
|
>
|
||||||
</thead>
|
{#snippet row(item)}
|
||||||
<tbody>
|
{@const displayMau = item.projectedMau ?? item.mau}
|
||||||
{#each displayedCalMau as row (row.monthStart)}
|
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
|
||||||
{@const displayMau = row.projectedMau ?? row.mau}
|
<td class="px-4 py-3 text-gray-300">
|
||||||
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
|
{item.label}
|
||||||
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
{#if item.isCurrentMonth}
|
||||||
<td class="px-4 py-3 text-gray-300">
|
<span class="text-xs text-gray-500 ml-1">(projected)</span>
|
||||||
{row.label}
|
{/if}
|
||||||
{#if row.isCurrentMonth}
|
</td>
|
||||||
<span class="text-xs text-gray-500 ml-1">(projected)</span>
|
<td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
|
||||||
{/if}
|
{#if item.isCurrentMonth}
|
||||||
</td>
|
<span class="text-gray-500 text-xs">{item.mau} → </span>{item.projectedMau ?? item.mau}
|
||||||
<td class="px-4 py-3 text-right font-medium {row.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
|
{:else}
|
||||||
{#if row.isCurrentMonth}
|
{item.mau}
|
||||||
<span class="text-gray-500 text-xs">{row.mau} → </span>{row.projectedMau ?? row.mau}
|
{/if}
|
||||||
{:else}
|
</td>
|
||||||
{row.mau}
|
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||||
{/if}
|
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||||
</td>
|
: 'text-gray-500'}">
|
||||||
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}">
|
{#if item.changePct != null}
|
||||||
{#if row.changePct != null}
|
{item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')}
|
||||||
{row.isCurrentMonth ? '~' : ''}{signed(row.changePct, '%')}
|
{:else}
|
||||||
{:else}
|
—
|
||||||
—
|
{/if}
|
||||||
{/if}
|
</td>
|
||||||
</td>
|
<td class="px-4 py-3">
|
||||||
<td class="px-4 py-3">
|
<div class="w-full min-w-24">
|
||||||
<div class="w-full min-w-24">
|
<div class="bg-teal-500 h-4 rounded {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
|
||||||
<div class="bg-teal-500 h-4 rounded {row.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
{/snippet}
|
||||||
</tr>
|
</CollapsibleTable>
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if calendarMauMonths.length > 3}
|
|
||||||
<button
|
|
||||||
onclick={() => (mauExpanded = !mauExpanded)}
|
|
||||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -625,112 +510,53 @@
|
|||||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||||
Last 14 Days — Completions
|
Last 14 Days — Completions
|
||||||
</h2>
|
</h2>
|
||||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
<CollapsibleTable
|
||||||
<table class="w-full text-sm">
|
rows={last14Days}
|
||||||
<thead>
|
headers={[
|
||||||
<tr
|
{ label: 'Date' },
|
||||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
{ label: 'Completions', align: 'right' },
|
||||||
>
|
{ label: '', width: 'w-48' },
|
||||||
<th class="text-left px-4 py-3">Date</th>
|
]}
|
||||||
<th class="text-right px-4 py-3">Completions</th>
|
>
|
||||||
<th class="px-4 py-3 w-48"></th>
|
{#snippet row(item)}
|
||||||
</tr>
|
{@const barPct = Math.round((item.count / maxCount) * 100)}
|
||||||
</thead>
|
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||||
<tbody>
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
|
||||||
{#each (completionsExpanded ? last14Days : last14Days.slice(0, 3)) as row (row.date)}
|
<td class="px-4 py-3">
|
||||||
{@const barPct = Math.round(
|
<div class="w-full min-w-24">
|
||||||
(row.count / maxCount) * 100,
|
<div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
)}
|
</div>
|
||||||
<tr
|
</td>
|
||||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
{/snippet}
|
||||||
>
|
</CollapsibleTable>
|
||||||
<td class="px-4 py-3 text-gray-300"
|
|
||||||
>{row.date}</td
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
|
||||||
>{row.count}</td
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="w-full min-w-24">
|
|
||||||
<div
|
|
||||||
class="bg-amber-500 h-4 rounded"
|
|
||||||
style="width: {barPct}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if last14Days.length > 3}
|
|
||||||
<button
|
|
||||||
onclick={() => (completionsExpanded = !completionsExpanded)}
|
|
||||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<span>{completionsExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||||
Active Streak Distribution
|
Active Streak Distribution
|
||||||
</h2>
|
</h2>
|
||||||
{#if streakChart.length === 0}
|
<CollapsibleTable
|
||||||
<p class="text-gray-400 text-sm px-4 py-6">
|
rows={streakChart}
|
||||||
No active streaks yet.
|
headers={[
|
||||||
</p>
|
{ label: 'Days' },
|
||||||
{:else}
|
{ label: 'Players', align: 'right' },
|
||||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
{ label: '', width: 'w-48' },
|
||||||
<table class="w-full text-sm">
|
]}
|
||||||
<thead>
|
>
|
||||||
<tr
|
{#snippet row(item)}
|
||||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
{@const barPct = Math.round((item.count / maxStreakCount) * 100)}
|
||||||
>
|
<td class="px-4 py-3 text-gray-300">{item.days}</td>
|
||||||
<th class="text-left px-4 py-3">Days</th>
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
|
||||||
<th class="text-right px-4 py-3">Players</th>
|
<td class="px-4 py-3">
|
||||||
<th class="px-4 py-3 w-48"></th>
|
<div class="w-full min-w-24">
|
||||||
</tr>
|
<div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
</td>
|
||||||
{#each (streakExpanded ? streakChart : streakChart.slice(0, 3)) as row (row.days)}
|
{/snippet}
|
||||||
{@const barPct = Math.round(
|
{#snippet empty()}
|
||||||
(row.count / maxStreakCount) * 100,
|
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
|
||||||
)}
|
{/snippet}
|
||||||
<tr
|
</CollapsibleTable>
|
||||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-gray-300"
|
|
||||||
>{row.days}</td
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
|
||||||
>{row.count}</td
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="w-full min-w-24">
|
|
||||||
<div
|
|
||||||
class="bg-blue-500 h-4 rounded"
|
|
||||||
style="width: {barPct}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if streakChart.length > 3}
|
|
||||||
<button
|
|
||||||
onclick={() => (streakExpanded = !streakExpanded)}
|
|
||||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<span>{streakExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
@@ -747,67 +573,26 @@
|
|||||||
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||||
7-Day Retention
|
7-Day Retention
|
||||||
</h3>
|
</h3>
|
||||||
{#if retention7dSeries.length === 0}
|
<CollapsibleTable
|
||||||
<p class="text-gray-400 text-sm px-4 py-6">
|
rows={retention7dSeries}
|
||||||
Not enough data yet.
|
headers={[
|
||||||
</p>
|
{ label: 'Cohort Date' },
|
||||||
{:else}
|
{ label: 'n', align: 'right' },
|
||||||
<div
|
{ label: 'Ret. %', align: 'right' },
|
||||||
class="overflow-x-auto rounded-xl border border-white/10"
|
{ label: '', width: 'w-32' },
|
||||||
>
|
]}
|
||||||
<table class="w-full text-sm">
|
>
|
||||||
<thead>
|
{#snippet row(item)}
|
||||||
<tr
|
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
|
||||||
>
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
|
||||||
<th class="text-left px-4 py-3"
|
<td class="px-4 py-3">
|
||||||
>Cohort Date</th
|
<div class="w-full min-w-20">
|
||||||
>
|
<div class="bg-emerald-500 h-4 rounded" style="width: {item.rate}%"></div>
|
||||||
<th class="text-right px-4 py-3">n</th>
|
</div>
|
||||||
<th class="text-right px-4 py-3"
|
</td>
|
||||||
>Ret. %</th
|
{/snippet}
|
||||||
>
|
</CollapsibleTable>
|
||||||
<th class="px-4 py-3 w-32"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each (ret7dExpanded ? retention7dSeries : retention7dSeries.slice(0, 3)) as row (row.date)}
|
|
||||||
<tr
|
|
||||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-gray-300"
|
|
||||||
>{row.date}</td
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-gray-400 text-xs"
|
|
||||||
>{row.cohortSize}</td
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
|
||||||
>{row.rate}%</td
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="w-full min-w-20">
|
|
||||||
<div
|
|
||||||
class="bg-emerald-500 h-4 rounded"
|
|
||||||
style="width: {row.rate}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if retention7dSeries.length > 3}
|
|
||||||
<button
|
|
||||||
onclick={() => (ret7dExpanded = !ret7dExpanded)}
|
|
||||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<span>{ret7dExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 30-day retention -->
|
<!-- 30-day retention -->
|
||||||
@@ -815,67 +600,26 @@
|
|||||||
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||||
30-Day Retention
|
30-Day Retention
|
||||||
</h3>
|
</h3>
|
||||||
{#if retention30dSeries.length === 0}
|
<CollapsibleTable
|
||||||
<p class="text-gray-400 text-sm px-4 py-6">
|
rows={retention30dSeries}
|
||||||
Not enough data yet.
|
headers={[
|
||||||
</p>
|
{ label: 'Cohort Date' },
|
||||||
{:else}
|
{ label: 'n', align: 'right' },
|
||||||
<div
|
{ label: 'Ret. %', align: 'right' },
|
||||||
class="overflow-x-auto rounded-xl border border-white/10"
|
{ label: '', width: 'w-32' },
|
||||||
>
|
]}
|
||||||
<table class="w-full text-sm">
|
>
|
||||||
<thead>
|
{#snippet row(item)}
|
||||||
<tr
|
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
|
||||||
>
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
|
||||||
<th class="text-left px-4 py-3"
|
<td class="px-4 py-3">
|
||||||
>Cohort Date</th
|
<div class="w-full min-w-20">
|
||||||
>
|
<div class="bg-violet-500 h-4 rounded" style="width: {item.rate}%"></div>
|
||||||
<th class="text-right px-4 py-3">n</th>
|
</div>
|
||||||
<th class="text-right px-4 py-3"
|
</td>
|
||||||
>Ret. %</th
|
{/snippet}
|
||||||
>
|
</CollapsibleTable>
|
||||||
<th class="px-4 py-3 w-32"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each (ret30dExpanded ? retention30dSeries : retention30dSeries.slice(0, 3)) as row (row.date)}
|
|
||||||
<tr
|
|
||||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-gray-300"
|
|
||||||
>{row.date}</td
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-gray-400 text-xs"
|
|
||||||
>{row.cohortSize}</td
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
|
||||||
>{row.rate}%</td
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="w-full min-w-20">
|
|
||||||
<div
|
|
||||||
class="bg-violet-500 h-4 rounded"
|
|
||||||
style="width: {row.rate}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if retention30dSeries.length > 3}
|
|
||||||
<button
|
|
||||||
onclick={() => (ret30dExpanded = !ret30dExpanded)}
|
|
||||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<span>{ret30dExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { dailyCompletions, dailyVerses } from '$lib/server/db/schema';
|
|||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { bibleBooks } from '$lib/types/bible';
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
import { calculateMilestones } from '$lib/server/milestones';
|
||||||
|
import type { Milestone } from '$lib/server/milestones';
|
||||||
|
|
||||||
export type BookTier = 'unseen' | 'explored' | 'mastered' | 'perfect';
|
export type BookTier = 'unseen' | 'explored' | 'mastered' | 'perfect';
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ export type TestamentStat = {
|
|||||||
count: number;
|
count: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
export type { Milestone };
|
||||||
|
|
||||||
export type ProgressData = {
|
export type ProgressData = {
|
||||||
completions: Array<{ date: string; guessCount: number }>;
|
completions: Array<{ date: string; guessCount: number }>;
|
||||||
chartPoints: ChartPoint[];
|
chartPoints: ChartPoint[];
|
||||||
@@ -44,6 +48,7 @@ export type ProgressData = {
|
|||||||
bestSingleGame: { date: string; bookName: string } | null;
|
bestSingleGame: { date: string; bookName: string } | null;
|
||||||
totalWords: number;
|
totalWords: number;
|
||||||
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
||||||
|
milestones: Milestone[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
@@ -82,6 +87,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
bestSingleGame: null,
|
bestSingleGame: null,
|
||||||
totalWords: 0,
|
totalWords: 0,
|
||||||
streakMilestones: { days7: null, days14: null, days30: null },
|
streakMilestones: { days7: null, days14: null, days30: null },
|
||||||
|
milestones: [],
|
||||||
} satisfies ProgressData,
|
} satisfies ProgressData,
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
@@ -235,6 +241,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const milestones = await calculateMilestones(completions, dateToBookId, { bestSingleGame, streakMilestones });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
progress: {
|
progress: {
|
||||||
completions: completions.map(c => ({ date: c.date, guessCount: c.guessCount })),
|
completions: completions.map(c => ({ date: c.date, guessCount: c.guessCount })),
|
||||||
@@ -251,6 +259,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
bestSingleGame,
|
bestSingleGame,
|
||||||
totalWords,
|
totalWords,
|
||||||
streakMilestones,
|
streakMilestones,
|
||||||
|
milestones,
|
||||||
} satisfies ProgressData,
|
} satisfies ProgressData,
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Milestone = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
description: string;
|
||||||
|
achieved: boolean;
|
||||||
|
achievedDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ProgressData = {
|
type ProgressData = {
|
||||||
completions: Array<{ date: string; guessCount: number }>;
|
completions: Array<{ date: string; guessCount: number }>;
|
||||||
chartPoints: ChartPoint[];
|
chartPoints: ChartPoint[];
|
||||||
@@ -49,6 +58,7 @@
|
|||||||
days14: string | null;
|
days14: string | null;
|
||||||
days30: string | null;
|
days30: string | null;
|
||||||
};
|
};
|
||||||
|
milestones: Milestone[];
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
@@ -78,9 +88,9 @@
|
|||||||
function bookTileClass(tier: BookTier): string {
|
function bookTileClass(tier: BookTier): string {
|
||||||
switch (tier) {
|
switch (tier) {
|
||||||
case "perfect":
|
case "perfect":
|
||||||
return "bg-amber-400 text-amber-900";
|
return "bg-emerald-500 text-white";
|
||||||
case "mastered":
|
case "mastered":
|
||||||
return "bg-emerald-600 text-white";
|
return "bg-purple-600 text-white";
|
||||||
case "explored":
|
case "explored":
|
||||||
return "bg-blue-700 text-blue-100";
|
return "bg-blue-700 text-blue-100";
|
||||||
default:
|
default:
|
||||||
@@ -288,14 +298,14 @@
|
|||||||
emoji="🏆"
|
emoji="🏆"
|
||||||
value={String(prog.booksMastered)}
|
value={String(prog.booksMastered)}
|
||||||
label="Books Mastered"
|
label="Books Mastered"
|
||||||
colorClass="text-emerald-400"
|
colorClass="text-purple-400"
|
||||||
suffix="/ 66"
|
suffix="/ 66"
|
||||||
/>
|
/>
|
||||||
<ProgressStatCard
|
<ProgressStatCard
|
||||||
emoji="⭐"
|
emoji="⭐"
|
||||||
value={String(prog.booksPerfect)}
|
value={String(prog.booksPerfect)}
|
||||||
label="Books Perfected"
|
label="Books Perfected"
|
||||||
colorClass="text-amber-400"
|
colorClass="text-emerald-400"
|
||||||
suffix="/ 66"
|
suffix="/ 66"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,7 +332,7 @@
|
|||||||
class="flex items-center gap-1 text-xs text-gray-400"
|
class="flex items-center gap-1 text-xs text-gray-400"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="inline-block w-5 h-5 rounded bg-emerald-600"
|
class="inline-block w-5 h-5 rounded bg-purple-600"
|
||||||
></span>
|
></span>
|
||||||
Mastered
|
Mastered
|
||||||
</span>
|
</span>
|
||||||
@@ -330,7 +340,7 @@
|
|||||||
class="flex items-center gap-1 text-xs text-gray-400"
|
class="flex items-center gap-1 text-xs text-gray-400"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="inline-block w-5 h-5 rounded bg-amber-400"
|
class="inline-block w-5 h-5 rounded bg-emerald-500"
|
||||||
></span>
|
></span>
|
||||||
Perfect
|
Perfect
|
||||||
</span>
|
</span>
|
||||||
@@ -358,11 +368,11 @@
|
|||||||
<p class="text-xs text-gray-500 mt-3 leading-relaxed">
|
<p class="text-xs text-gray-500 mt-3 leading-relaxed">
|
||||||
<span class="text-blue-400 font-medium">Explored</span>
|
<span class="text-blue-400 font-medium">Explored</span>
|
||||||
— played at least once<br />
|
— played at least once<br />
|
||||||
<span class="text-emerald-400 font-medium"
|
<span class="text-purple-400 font-medium"
|
||||||
>Mastered</span
|
>Mastered</span
|
||||||
>
|
>
|
||||||
— avg ≤ 3 guesses over 2+ plays<br />
|
— avg ≤ 3 guesses over 2+ plays<br />
|
||||||
<span class="text-amber-400 font-medium">Perfect</span> —
|
<span class="text-emerald-400 font-medium">Perfect</span> —
|
||||||
mastered and guessed in 1 at least once
|
mastered and guessed in 1 at least once
|
||||||
</p>
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -373,8 +383,8 @@
|
|||||||
<ActivityCalendar completions={prog.completions} />
|
<ActivityCalendar completions={prog.completions} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skill Growth Chart -->
|
<!-- Skill Growth Chart (hidden, needs rework) -->
|
||||||
{#if showChart}
|
{#if false && showChart}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<Container class="p-4 md:p-6 w-full">
|
<Container class="p-4 md:p-6 w-full">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@@ -499,108 +509,33 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Milestones -->
|
<!-- Achievements -->
|
||||||
{#if prog.bestSingleGame || prog.streakMilestones.days7 || prog.streakMilestones.days14 || prog.streakMilestones.days30}
|
{#if prog.milestones.length > 0}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h2 class="text-xl font-bold text-gray-100 mb-3">
|
<h2 class="text-xl font-bold text-gray-100 mb-3">🏆 Achievements</h2>
|
||||||
Milestones
|
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 md:gap-3">
|
||||||
</h2>
|
{#each prog.milestones.filter(m => m.achieved) as milestone (milestone.id)}
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<Container class="p-3 min-h-[130px]">
|
||||||
{#if prog.bestSingleGame}
|
<div class="text-center flex flex-col items-center justify-center h-full">
|
||||||
<Container class="p-4">
|
<div class="text-2xl mb-1">{milestone.emoji}</div>
|
||||||
<div class="text-center">
|
<div class="text-sm font-bold text-yellow-300 leading-tight mb-1">
|
||||||
<div class="text-3xl mb-1">⚡</div>
|
{milestone.name}
|
||||||
<div
|
|
||||||
class="text-sm font-bold text-yellow-300 leading-tight"
|
|
||||||
>
|
|
||||||
{prog.bestSingleGame.bookName}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="text-xs text-gray-400 leading-tight">
|
||||||
class="text-xs text-gray-300 font-medium mt-1"
|
{milestone.description}
|
||||||
>
|
|
||||||
First 1-Guess Win
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-[10px] text-gray-500 mt-0.5"
|
|
||||||
>
|
|
||||||
{formatDate(prog.bestSingleGame.date)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if milestone.achievedDate}
|
||||||
|
<div class="text-[10px] text-gray-500 mt-1">
|
||||||
|
{formatDate(milestone.achievedDate)}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-[10px] text-emerald-500 mt-1">Earned</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
{/if}
|
{/each}
|
||||||
{#if prog.streakMilestones.days7}
|
|
||||||
<Container class="p-4">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl mb-1">🔥</div>
|
|
||||||
<div
|
|
||||||
class="text-sm font-bold text-orange-300 leading-tight"
|
|
||||||
>
|
|
||||||
7-Day Streak
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-xs text-gray-300 font-medium mt-1"
|
|
||||||
>
|
|
||||||
First Achieved
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-[10px] text-gray-500 mt-0.5"
|
|
||||||
>
|
|
||||||
{formatDate(
|
|
||||||
prog.streakMilestones.days7,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
{/if}
|
|
||||||
{#if prog.streakMilestones.days14}
|
|
||||||
<Container class="p-4">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl mb-1">💥</div>
|
|
||||||
<div
|
|
||||||
class="text-sm font-bold text-orange-400 leading-tight"
|
|
||||||
>
|
|
||||||
14-Day Streak
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-xs text-gray-300 font-medium mt-1"
|
|
||||||
>
|
|
||||||
First Achieved
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-[10px] text-gray-500 mt-0.5"
|
|
||||||
>
|
|
||||||
{formatDate(
|
|
||||||
prog.streakMilestones.days14,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
{/if}
|
|
||||||
{#if prog.streakMilestones.days30}
|
|
||||||
<Container class="p-4">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl mb-1">🏅</div>
|
|
||||||
<div
|
|
||||||
class="text-sm font-bold text-amber-300 leading-tight"
|
|
||||||
>
|
|
||||||
30-Day Streak
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-xs text-gray-300 font-medium mt-1"
|
|
||||||
>
|
|
||||||
First Achieved
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-[10px] text-gray-500 mt-0.5"
|
|
||||||
>
|
|
||||||
{formatDate(
|
|
||||||
prog.streakMilestones.days30,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">{prog.milestones.filter(m => m.achieved).length} / {prog.milestones.length} achievements unlocked</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
36
todo.md
36
todo.md
@@ -59,6 +59,42 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
# done
|
# done
|
||||||
|
|
||||||
|
## march 25th
|
||||||
|
|
||||||
|
- Added Sign In with Google (OAuth)
|
||||||
|
- Added Google sign-in button to win screen and footer provider label
|
||||||
|
- Added rainbow glow effect
|
||||||
|
|
||||||
|
## march 24th
|
||||||
|
|
||||||
|
- Added achievements system, hint overlay, and progress page polish
|
||||||
|
|
||||||
|
## march 23rd
|
||||||
|
|
||||||
|
- Extracted CollapsibleTable component and fixed show more behavior
|
||||||
|
|
||||||
|
## march 22nd
|
||||||
|
|
||||||
|
- Added `/api/send-daily-verse` endpoint for daily Discord verse posting
|
||||||
|
- Improved guesses collapse timing, win screen CTA, and progress page polish
|
||||||
|
- Fixed Discord message format (italic date + bold verse)
|
||||||
|
|
||||||
|
## march 21st
|
||||||
|
|
||||||
|
- Added progress page with activity calendar, book grid, and insights
|
||||||
|
|
||||||
|
## march 19th
|
||||||
|
|
||||||
|
- Added Discord link and shrunk guesses grid for more than three guesses
|
||||||
|
- Added MAU section with projection to global stats
|
||||||
|
- Added survival curve metrics and table minimizing to global stats
|
||||||
|
|
||||||
|
## march 15th–16th
|
||||||
|
|
||||||
|
- Fixed instructions, added color border based on closeness between guess and target
|
||||||
|
- Added return rate and retention metrics to global stats
|
||||||
|
- Added WAU history table, fixed retention metric, added new logos and favicon
|
||||||
|
|
||||||
## march 14th
|
## march 14th
|
||||||
|
|
||||||
- Added /global public dashboard with 8 stat cards: completions today, all-time, unique players, players this week, active streaks, avg guesses today, registered users, avg completions per player
|
- Added /global public dashboard with 8 stat cards: completions today, all-time, unique players, players this week, active streaks, avg guesses today, registered users, avg completions per player
|
||||||
|
|||||||
Reference in New Issue
Block a user