mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
10 Commits
290fb06fe9
...
2de4e9e2a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de4e9e2a7 | ||
|
|
ea7a848125 | ||
|
|
1719e0bbbf | ||
|
|
885adad756 | ||
|
|
1b96919acd | ||
|
|
8ef2a41a69 | ||
|
|
ac6ec051d4 | ||
|
|
a12c7d011a | ||
|
|
77ffd6fbee | ||
|
|
f6652e59a7 |
@@ -10,7 +10,7 @@ echo "Installing dependencies..."
|
||||
bun i
|
||||
|
||||
echo "Building..."
|
||||
bun run build
|
||||
bun --bun run build
|
||||
|
||||
echo "Restarting service..."
|
||||
sudo systemctl restart bibdle
|
||||
|
||||
10
drizzle/0002_outstanding_hiroim.sql
Normal file
10
drizzle/0002_outstanding_hiroim.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `email` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint
|
||||
CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`);
|
||||
275
drizzle/meta/0002_snapshot.json
Normal file
275
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||
"prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d",
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1770266674489,
|
||||
"tag": "0001_loose_kree",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1770961427714,
|
||||
"tag": "0002_outstanding_hiroim",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
75
scripts/deduplicate-completions.ts
Normal file
75
scripts/deduplicate-completions.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import Database from 'bun:sqlite';
|
||||
|
||||
// Database path - adjust if your database is located elsewhere
|
||||
const dbPath = Bun.env.DATABASE_URL || './local.db';
|
||||
console.log(`Connecting to database: ${dbPath}`);
|
||||
const db = new Database(dbPath);
|
||||
|
||||
interface DuplicateGroup {
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface Completion {
|
||||
id: string;
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
guess_count: number;
|
||||
completed_at: number;
|
||||
}
|
||||
|
||||
console.log('Finding duplicates...\n');
|
||||
|
||||
// Find all (anonymous_id, date) pairs with duplicates
|
||||
const duplicatesQuery = db.query<DuplicateGroup, []>(`
|
||||
SELECT anonymous_id, date, COUNT(*) as count
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id, date
|
||||
HAVING count > 1
|
||||
`);
|
||||
|
||||
const duplicates = duplicatesQuery.all();
|
||||
console.log(`Found ${duplicates.length} duplicate groups\n`);
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log('No duplicates to clean up!');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
|
||||
// Process each duplicate group
|
||||
for (const dup of duplicates) {
|
||||
// Get all completions for this (anonymous_id, date) pair
|
||||
const completionsQuery = db.query<Completion, [string, string]>(`
|
||||
SELECT id, anonymous_id, date, guess_count, completed_at
|
||||
FROM daily_completions
|
||||
WHERE anonymous_id = ? AND date = ?
|
||||
ORDER BY completed_at ASC
|
||||
`);
|
||||
|
||||
const completions = completionsQuery.all(dup.anonymous_id, dup.date);
|
||||
console.log(` ${dup.anonymous_id} on ${dup.date}: ${completions.length} entries`);
|
||||
|
||||
// Keep the first (earliest completion), delete the rest
|
||||
const toKeep = completions[0];
|
||||
const toDelete = completions.slice(1);
|
||||
|
||||
console.log(` Keeping: ${toKeep.id} (completed at ${new Date(toKeep.completed_at * 1000).toISOString()})`);
|
||||
|
||||
const deleteQuery = db.query('DELETE FROM daily_completions WHERE id = ?');
|
||||
|
||||
for (const comp of toDelete) {
|
||||
console.log(` Deleting: ${comp.id} (completed at ${new Date(comp.completed_at * 1000).toISOString()})`);
|
||||
deleteQuery.run(comp.id);
|
||||
totalDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Deduplication complete!`);
|
||||
console.log(`Total records deleted: ${totalDeleted}`);
|
||||
console.log(`Unique completions preserved: ${duplicates.length}`);
|
||||
|
||||
db.close();
|
||||
@@ -73,7 +73,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
@@ -91,6 +91,25 @@
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/auth/apple">
|
||||
<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"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center my-4">
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
<span class="px-3 text-sm text-white/60">or</span>
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { fade } from "svelte/transition";
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
@@ -20,19 +22,52 @@
|
||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||
.replace(/[,:;-—]$/, "...")
|
||||
);
|
||||
|
||||
let showReference = $state(false);
|
||||
|
||||
// Delay showing reference until GuessesTable animation completes
|
||||
$effect(() => {
|
||||
if (!isWon) {
|
||||
showReference = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user already won today (page reload case)
|
||||
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
const alreadyWonToday = browser && localStorage.getItem(winTrackedKey) === "true";
|
||||
|
||||
if (alreadyWonToday) {
|
||||
// User already won and is refreshing - show immediately
|
||||
showReference = true;
|
||||
} else {
|
||||
// User just won this session - delay for animation
|
||||
const animationDelay = 1800;
|
||||
const timeoutId = setTimeout(() => {
|
||||
showReference = true;
|
||||
}, animationDelay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
||||
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
{#if isWon}
|
||||
<p
|
||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||
style="max-height: {showReference ? '200px' : '0px'};"
|
||||
>
|
||||
{#if showReference}
|
||||
<p
|
||||
transition:fade={{ duration: 400 }}
|
||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
144
src/lib/server/apple-auth.ts
Normal file
144
src/lib/server/apple-auth.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
|
||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||
|
||||
export function getAppleAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.APPLE_ID!,
|
||||
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/apple/callback`,
|
||||
response_type: 'code',
|
||||
response_mode: 'form_post',
|
||||
scope: 'name email',
|
||||
state
|
||||
});
|
||||
return `${APPLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function generateAppleClientSecret(): Promise<string> {
|
||||
const header = { alg: 'ES256', kid: Bun.env.APPLE_KEY_ID! };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: Bun.env.APPLE_TEAM_ID!,
|
||||
iat: now,
|
||||
exp: now + 86400 * 180,
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: Bun.env.APPLE_ID!
|
||||
};
|
||||
|
||||
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
||||
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Import PEM private key
|
||||
const pemBody = Bun.env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBuffer,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const signature = new Uint8Array(signatureBuffer);
|
||||
|
||||
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||
const encodedSignature = encodeBase64url(rawSignature);
|
||||
|
||||
return `${signingInput}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
|
||||
*/
|
||||
function derToRaw(der: Uint8Array): Uint8Array {
|
||||
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
|
||||
let offset = 2; // skip 0x30 and total length
|
||||
|
||||
// Read r
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const rLen = der[offset];
|
||||
offset++;
|
||||
let r = der.slice(offset, offset + rLen);
|
||||
offset += rLen;
|
||||
|
||||
// Read s
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const sLen = der[offset];
|
||||
offset++;
|
||||
let s = der.slice(offset, offset + sLen);
|
||||
|
||||
// Remove leading zero padding (DER uses it for positive sign)
|
||||
if (r.length === 33 && r[0] === 0) r = r.slice(1);
|
||||
if (s.length === 33 && s[0] === 0) s = s.slice(1);
|
||||
|
||||
// Pad to 32 bytes each
|
||||
const raw = new Uint8Array(64);
|
||||
raw.set(r, 32 - r.length);
|
||||
raw.set(s, 64 - s.length);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function exchangeAppleCode(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
id_token: string;
|
||||
}> {
|
||||
const clientSecret = await generateAppleClientSecret();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.APPLE_ID!,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
});
|
||||
|
||||
const response = await fetch(APPLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Apple token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Apple's id_token JWT payload without signature verification.
|
||||
* Safe because the token is received directly from Apple's token endpoint over TLS.
|
||||
*/
|
||||
export function decodeAppleIdToken(idToken: string): {
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: string;
|
||||
is_private_email?: 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;
|
||||
}
|
||||
@@ -101,6 +101,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
|
||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
|
||||
@@ -101,6 +101,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
|
||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
@@ -113,3 +114,48 @@ export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function getUserByAppleId(appleId: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||
if (!anonymousId || anonymousId === userId) return;
|
||||
|
||||
try {
|
||||
const { dailyCompletions } = await import('$lib/server/db/schema');
|
||||
|
||||
const anonCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const userDates = new Set(userCompletions.map((c) => c.date));
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const completion of anonCompletions) {
|
||||
if (!userDates.has(completion.date)) {
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: userId })
|
||||
.where(eq(dailyCompletions.id, completion.id));
|
||||
migrated++;
|
||||
} else {
|
||||
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id));
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||
} catch (error) {
|
||||
console.error('Error migrating anonymous stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/lib/server/daily-verse.ts
Normal file
33
src/lib/server/daily-verse.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
|
||||
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
throw new Error('Invalid date format');
|
||||
}
|
||||
|
||||
// If there's an existing verse for this date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse for this date
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: Bun.randomUUIDv7(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const user = sqliteTable('user', {
|
||||
lastName: text('last_name'),
|
||||
email: text('email').unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
appleId: text('apple_id').unique(),
|
||||
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||
});
|
||||
|
||||
@@ -41,6 +42,8 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
||||
anonymousIdDateIndex: index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||
dateIndex: index('date_idx').on(table.date),
|
||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
||||
// Ensures schema matches the database migration and prevents duplicate submissions
|
||||
uniqueAnonymousIdDate: unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
|
||||
}));
|
||||
|
||||
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, sql, asc } from 'drizzle-orm';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
async function getTodayVerse(): Promise<DailyVerse> {
|
||||
// Get the current date (server-side)
|
||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
|
||||
// If there's an existing verse for the current date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: crypto.randomUUID(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const dailyVerse = await getTodayVerse();
|
||||
// Use UTC date for initial SSR; client will fetch timezone-correct verse if needed
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
const dailyVerse = await getVerseForDate(dateStr);
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import { getGrade } from "$lib/utils/game";
|
||||
import { enhance } from '$app/forms';
|
||||
import { enhance } from "$app/forms";
|
||||
|
||||
interface Guess {
|
||||
book: BibleBook;
|
||||
@@ -25,8 +25,9 @@
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let correctBookId = $derived(data.correctBookId);
|
||||
let dailyVerse = $state(data.dailyVerse);
|
||||
let correctBookId = $state(data.correctBookId);
|
||||
let correctBook = $state(data.correctBook);
|
||||
let user = $derived(data.user);
|
||||
let session = $derived(data.session);
|
||||
|
||||
@@ -63,6 +64,7 @@
|
||||
);
|
||||
|
||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
||||
let showWinScreen = $state(false);
|
||||
let grade = $derived(
|
||||
isWon
|
||||
? guesses.length === 1 && chapterCorrect
|
||||
@@ -178,13 +180,83 @@
|
||||
return id;
|
||||
}
|
||||
|
||||
// If server date doesn't match client's local date, fetch timezone-correct verse
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
console.log("Date check:", {
|
||||
localDate,
|
||||
verseDate: dailyVerse.date,
|
||||
match: dailyVerse.date === localDate,
|
||||
});
|
||||
|
||||
if (dailyVerse.date === localDate) return;
|
||||
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
console.log("Fetching timezone-correct verse:", {
|
||||
localDate,
|
||||
timezone,
|
||||
});
|
||||
|
||||
fetch("/api/daily-verse", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
date: localDate,
|
||||
timezone,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
console.log("Received verse data:", result);
|
||||
dailyVerse = result.dailyVerse;
|
||||
correctBookId = result.correctBookId;
|
||||
correctBook = result.correctBook;
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error("Failed to fetch timezone-correct verse:", err),
|
||||
);
|
||||
});
|
||||
|
||||
// Reload when the user returns to a stale tab on a new calendar day
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const loadedDate = new Date().toLocaleDateString("en-CA");
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) return;
|
||||
const now = new Date().toLocaleDateString("en-CA");
|
||||
if (now !== loadedDate) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
onVisibilityChange,
|
||||
);
|
||||
});
|
||||
|
||||
// Initialize anonymous ID
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
|
||||
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||
// Never use the localStorage anonymous ID for authenticated users
|
||||
if (user) {
|
||||
anonymousId = user.id;
|
||||
} else {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
}
|
||||
|
||||
if ((window as any).umami) {
|
||||
// Use user id if logged in, otherwise use anonymous id
|
||||
(window as any).umami.identify(user ? user.id : anonymousId);
|
||||
(window as any).umami.identify(anonymousId);
|
||||
}
|
||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
||||
@@ -203,7 +275,9 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
isDev = window.location.host === "localhost:5173";
|
||||
isDev =
|
||||
window.location.host === "localhost:5173" ||
|
||||
window.location.host === "test.bibdle.com";
|
||||
});
|
||||
|
||||
// Load saved guesses
|
||||
@@ -278,7 +352,7 @@
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/submit-completion?anonymousId=${user ? user.id : anonymousId}&date=${dailyVerse.date}`,
|
||||
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
@@ -308,7 +382,7 @@
|
||||
async function submitStats() {
|
||||
try {
|
||||
const payload = {
|
||||
anonymousId: user ? user.id : anonymousId,
|
||||
anonymousId: anonymousId, // Already set correctly in $effect above
|
||||
date: dailyVerse.date,
|
||||
guessCount: guesses.length,
|
||||
};
|
||||
@@ -347,6 +421,33 @@
|
||||
submitStats();
|
||||
});
|
||||
|
||||
// Delay showing win screen until GuessesTable animation completes
|
||||
$effect(() => {
|
||||
if (!isWon) {
|
||||
showWinScreen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user already won today (page reload case)
|
||||
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
const alreadyWonToday =
|
||||
browser && localStorage.getItem(winTrackedKey) === "true";
|
||||
|
||||
if (alreadyWonToday) {
|
||||
// User already won and is refreshing - show immediately
|
||||
showWinScreen = true;
|
||||
} else {
|
||||
// User just won this session - delay for animation
|
||||
// Animation timing: last column starts at 1500ms, animation takes 600ms
|
||||
const animationDelay = 1800;
|
||||
const timeoutId = setTimeout(() => {
|
||||
showWinScreen = true;
|
||||
}, animationDelay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser || !isWon) return;
|
||||
const key = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
@@ -381,12 +482,26 @@
|
||||
new Date(`${dailyVerse.date}T00:00:00`),
|
||||
);
|
||||
const siteUrl = window.location.origin;
|
||||
return [
|
||||
`📖 Bibdle | ${formattedDate} 📖`,
|
||||
|
||||
// Use scroll emoji for logged-in users, book emoji for anonymous
|
||||
const bookEmoji = user ? "📜" : "📖";
|
||||
|
||||
const lines = [
|
||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
||||
];
|
||||
|
||||
// Add streak for logged-in users (requires streak field in user data)
|
||||
if (user && (user as any).streak !== undefined) {
|
||||
lines.push(`🔥 ${(user as any).streak} day streak`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
||||
siteUrl,
|
||||
].join("\n");
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function share() {
|
||||
@@ -476,33 +591,35 @@
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||
</div>
|
||||
{:else}
|
||||
<WinScreen
|
||||
{grade}
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
{copyToClipboard}
|
||||
bind:copied
|
||||
{statsSubmitted}
|
||||
guessCount={guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={() => {
|
||||
chapterGuessCompleted = true;
|
||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match =
|
||||
dailyVerse.reference.match(/\s(\d+):/);
|
||||
const correctChapter = match
|
||||
? parseInt(match[1], 10)
|
||||
: 1;
|
||||
chapterCorrect =
|
||||
data.selectedChapter === correctChapter;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else if showWinScreen}
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<WinScreen
|
||||
{grade}
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
{copyToClipboard}
|
||||
bind:copied
|
||||
{statsSubmitted}
|
||||
guessCount={guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={() => {
|
||||
chapterGuessCompleted = true;
|
||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match =
|
||||
dailyVerse.reference.match(/\s(\d+):/);
|
||||
const correctChapter = match
|
||||
? parseInt(match[1], 10)
|
||||
: 1;
|
||||
chapterCorrect =
|
||||
data.selectedChapter === correctChapter;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="animate-fade-in-up animate-delay-600">
|
||||
@@ -515,44 +632,77 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<a
|
||||
href="/stats?{user ? `userId=${user.id}` : `anonymousId=${anonymousId}`}"
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📊 View Stats
|
||||
</a>
|
||||
|
||||
{#if user}
|
||||
<form method="POST" action="/auth/logout" use:enhance class="w-full md:w-auto">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🚪 Sign Out
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => authModalOpen = true}
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||
{#if isDev}
|
||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<a
|
||||
href="/stats?{user
|
||||
? `userId=${user.id}`
|
||||
: `anonymousId=${anonymousId}`}&tz={encodeURIComponent(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)}"
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🔐 Sign In
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isDev}
|
||||
<div class="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded border">
|
||||
📊 View Stats
|
||||
</a>
|
||||
|
||||
{#if user}
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/logout"
|
||||
use:enhance
|
||||
class="w-full md:w-auto"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🚪 Sign Out
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (authModalOpen = true)}
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🔐 Sign In
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded border"
|
||||
>
|
||||
<div><strong>Debug Info:</strong></div>
|
||||
<div>User: {user ? `${user.email} (ID: ${user.id})` : 'Not signed in'}</div>
|
||||
<div>Session: {session ? `Expires ${session.expiresAt.toLocaleDateString()}` : 'No session'}</div>
|
||||
<div>Anonymous ID: {anonymousId || 'Not set'}</div>
|
||||
<div>
|
||||
User: {user
|
||||
? `${user.email} (ID: ${user.id})`
|
||||
: "Not signed in"}
|
||||
</div>
|
||||
<div>
|
||||
Session: {session
|
||||
? `Expires ${session.expiresAt.toLocaleDateString()}`
|
||||
: "No session"}
|
||||
</div>
|
||||
<div>Anonymous ID: {anonymousId || "Not set"}</div>
|
||||
<div>
|
||||
Client Local Time: {new Date().toLocaleString("en-US", {
|
||||
timeZone:
|
||||
Intl.DateTimeFormat().resolvedOptions()
|
||||
.timeZone,
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
Client Local Date: {new Date().toLocaleDateString(
|
||||
"en-CA",
|
||||
)}
|
||||
</div>
|
||||
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||
</div>
|
||||
<DevButtons />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
21
src/routes/api/daily-verse/+server.ts
Normal file
21
src/routes/api/daily-verse/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const { date } = body;
|
||||
|
||||
// Use the date provided by the client (already calculated in their timezone)
|
||||
const dateStr = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
const dailyVerse = await getVerseForDate(dateStr);
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
return json({
|
||||
dailyVerse,
|
||||
correctBookId: dailyVerse.bookId,
|
||||
correctBook,
|
||||
});
|
||||
};
|
||||
27
src/routes/auth/apple/+page.server.ts
Normal file
27
src/routes/auth/apple/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
|
||||
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 = encodeBase64url(stateBytes);
|
||||
|
||||
// Store state + anonymousId in a short-lived cookie
|
||||
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||
cookies.set('apple_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none',
|
||||
maxAge: 600
|
||||
});
|
||||
|
||||
redirect(302, getAppleAuthUrl(state));
|
||||
}
|
||||
};
|
||||
123
src/routes/auth/apple/callback/+server.ts
Normal file
123
src/routes/auth/apple/callback/+server.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { exchangeAppleCode, decodeAppleIdToken } from '$lib/server/apple-auth';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
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 POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const formData = await request.formData();
|
||||
const code = formData.get('code')?.toString();
|
||||
const state = formData.get('state')?.toString();
|
||||
// Apple sends user info as JSON string on FIRST authorization only
|
||||
const userInfoStr = formData.get('user')?.toString();
|
||||
|
||||
// Validate CSRF state
|
||||
const storedRaw = cookies.get('apple_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('apple_oauth_state', { path: '/' });
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
|
||||
const claims = decodeAppleIdToken(tokens.id_token);
|
||||
const appleId = claims.sub;
|
||||
|
||||
// Parse user info (only present on first authorization)
|
||||
let appleFirstName: string | undefined;
|
||||
let appleLastName: string | undefined;
|
||||
if (userInfoStr) {
|
||||
try {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
appleFirstName = userInfo.name?.firstName;
|
||||
appleLastName = userInfo.name?.lastName;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
}
|
||||
|
||||
// --- User resolution ---
|
||||
let userId: string;
|
||||
|
||||
// 1. Check if a user with this appleId already exists (returning user)
|
||||
const existingAppleUser = await auth.getUserByAppleId(appleId);
|
||||
|
||||
if (existingAppleUser) {
|
||||
userId = existingAppleUser.id;
|
||||
await auth.migrateAnonymousStats(stored.anonymousId, userId);
|
||||
} else if (claims.email) {
|
||||
// 2. Check if email matches an existing email/password user
|
||||
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||
if (existingEmailUser) {
|
||||
// Link Apple account to existing user
|
||||
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||
userId = existingEmailUser.id;
|
||||
await auth.migrateAnonymousStats(stored.anonymousId, userId);
|
||||
} else {
|
||||
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||
userId = stored.anonymousId || crypto.randomUUID();
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: claims.email,
|
||||
passwordHash: null,
|
||||
appleId,
|
||||
firstName: appleFirstName || null,
|
||||
lastName: appleLastName || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Handle race condition: if appleId was inserted between our check and insert
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByAppleId(appleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No email from Apple — create account with appleId only
|
||||
userId = stored.anonymousId || crypto.randomUUID();
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
appleId,
|
||||
firstName: appleFirstName || null,
|
||||
lastName: appleLastName || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByAppleId(appleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
} 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, '/');
|
||||
};
|
||||
8
src/routes/auth/apple/test/+page.server.ts
Normal file
8
src/routes/auth/apple/test/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
29
src/routes/auth/apple/test/+page.svelte
Normal file
29
src/routes/auth/apple/test/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import AuthModal from '$lib/components/AuthModal.svelte';
|
||||
|
||||
let isOpen = $state(true);
|
||||
const user = $derived(page.data.user);
|
||||
const anonymousId = crypto.randomUUID();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
{#if user}
|
||||
<div class="text-white text-center space-y-4">
|
||||
<p class="text-lg">Signed in as <strong>{user.email ?? 'no email'}</strong></p>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button class="px-4 py-2 bg-red-600 rounded-md hover:bg-red-700 transition-colors">
|
||||
Sign Out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => isOpen = true}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Open Auth Modal
|
||||
</button>
|
||||
<AuthModal bind:isOpen {anonymousId} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,9 +1,6 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import * as auth from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
@@ -40,61 +37,7 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
// Migrate anonymous stats if different anonymous ID
|
||||
if (anonymousId && anonymousId !== user.id) {
|
||||
try {
|
||||
// Update all daily completions from the local anonymous ID to the user's ID
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: user.id })
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||
|
||||
console.log(`Migrated stats from ${anonymousId} to ${user.id}`);
|
||||
|
||||
// Deduplicate any entries for the same date after migration
|
||||
const allUserCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, user.id));
|
||||
|
||||
// Group by date to find duplicates
|
||||
const dateGroups = new Map<string, typeof allUserCompletions>();
|
||||
for (const completion of allUserCompletions) {
|
||||
const date = completion.date;
|
||||
if (!dateGroups.has(date)) {
|
||||
dateGroups.set(date, []);
|
||||
}
|
||||
dateGroups.get(date)!.push(completion);
|
||||
}
|
||||
|
||||
// Process dates with duplicates
|
||||
const duplicateIds: string[] = [];
|
||||
for (const [date, completions] of dateGroups) {
|
||||
if (completions.length > 1) {
|
||||
// Sort by completedAt timestamp (earliest first)
|
||||
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||
|
||||
// Keep the first (earliest), mark the rest for deletion
|
||||
const toDelete = completions.slice(1);
|
||||
duplicateIds.push(...toDelete.map(c => c.id));
|
||||
|
||||
console.log(`Found ${completions.length} duplicates for date ${date}, keeping earliest, deleting ${toDelete.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete duplicate entries
|
||||
if (duplicateIds.length > 0) {
|
||||
await db
|
||||
.delete(dailyCompletions)
|
||||
.where(inArray(dailyCompletions.id, duplicateIds));
|
||||
|
||||
console.log(`Deleted ${duplicateIds.length} duplicate completion entries`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error migrating anonymous stats:', error);
|
||||
// Don't fail the signin if stats migration fails
|
||||
}
|
||||
}
|
||||
await auth.migrateAnonymousStats(anonymousId, user.id);
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
|
||||
@@ -15,9 +15,9 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
requiresAuth: true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const userId = locals.user.id;
|
||||
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
stats: null,
|
||||
@@ -27,6 +27,10 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Get user's current date from timezone query param
|
||||
const timezone = url.searchParams.get('tz') || 'UTC';
|
||||
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
|
||||
|
||||
try {
|
||||
// Get all completions for this user
|
||||
const completions = await db
|
||||
@@ -85,26 +89,29 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const sortedDates = completions
|
||||
.map((c: DailyCompletion) => c.date)
|
||||
.sort();
|
||||
|
||||
|
||||
let currentStreak = 0;
|
||||
let bestStreak = 0;
|
||||
let tempStreak = 1;
|
||||
|
||||
|
||||
if (sortedDates.length > 0) {
|
||||
// Check if current streak is active (includes today or yesterday)
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
// Use the user's local date passed from the client
|
||||
const today = userToday;
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
|
||||
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
||||
currentStreak = 1;
|
||||
|
||||
|
||||
// Count backwards from the most recent date
|
||||
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||
const currentDate = new Date(sortedDates[i + 1]);
|
||||
const prevDate = new Date(sortedDates[i]);
|
||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
|
||||
if (daysDiff === 1) {
|
||||
currentStreak++;
|
||||
} else {
|
||||
@@ -112,14 +119,14 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Calculate best streak
|
||||
bestStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
|
||||
if (daysDiff === 1) {
|
||||
tempStreak++;
|
||||
} else {
|
||||
@@ -246,4 +253,4 @@ function getGradeFromGuesses(guessCount: number): string {
|
||||
if (guessCount >= 7 && guessCount <= 10) return "B";
|
||||
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
||||
return "C";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { enhance } from '$app/forms';
|
||||
import { enhance } from "$app/forms";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
@@ -11,7 +11,7 @@
|
||||
formatDate,
|
||||
getStreakMessage,
|
||||
getPerformanceMessage,
|
||||
type UserStats
|
||||
type UserStats,
|
||||
} from "$lib/utils/stats";
|
||||
|
||||
interface PageData {
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
|
||||
function getBookName(bookId: string): string {
|
||||
return bibleBooks.find(b => b.id === bookId)?.name || bookId;
|
||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||
}
|
||||
|
||||
$inspect(data);
|
||||
@@ -55,15 +55,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>Stats | Bibdle</title>
|
||||
<meta name="description" content="View your Bibdle game statistics and performance" />
|
||||
<meta
|
||||
name="description"
|
||||
content="View your Bibdle game statistics and performance"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8">
|
||||
<div
|
||||
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">Your Stats</h1>
|
||||
<p class="text-sm md:text-base text-gray-300 mb-4">Track your Bibdle performance over time</p>
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||
Your Stats
|
||||
</h1>
|
||||
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||
Track your Bibdle performance over time
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
@@ -74,17 +83,25 @@
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div
|
||||
class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="mt-4 text-gray-300">Loading your stats...</p>
|
||||
</div>
|
||||
{:else if data.requiresAuth}
|
||||
<div class="text-center py-12">
|
||||
<div class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm">
|
||||
<h2 class="text-2xl font-bold text-blue-200 mb-4">Authentication Required</h2>
|
||||
<p class="text-blue-300 mb-6">You must be logged in to see your stats.</p>
|
||||
<div
|
||||
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<h2 class="text-2xl font-bold text-blue-200 mb-4">
|
||||
Authentication Required
|
||||
</h2>
|
||||
<p class="text-blue-300 mb-6">
|
||||
You must be logged in to see your stats.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={() => authModalOpen = true}
|
||||
onclick={() => (authModalOpen = true)}
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
🔐 Sign In / Sign Up
|
||||
@@ -100,7 +117,9 @@
|
||||
</div>
|
||||
{:else if data.error}
|
||||
<div class="text-center py-12">
|
||||
<div class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm">
|
||||
<div
|
||||
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<p class="text-red-300">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
@@ -113,8 +132,12 @@
|
||||
{:else if !data.stats}
|
||||
<div class="text-center py-12">
|
||||
<Container class="p-8 max-w-md mx-auto">
|
||||
<div class="text-yellow-400 mb-4 text-lg">No stats available yet.</div>
|
||||
<p class="text-gray-300 mb-6">Start playing to build your stats!</p>
|
||||
<div class="text-yellow-400 mb-4 text-lg">
|
||||
No stats available yet.
|
||||
</div>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Start playing to build your stats!
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
||||
@@ -132,8 +155,16 @@
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">🔥</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-orange-400 mb-1">{stats.currentStreak}</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Current Streak</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||
>
|
||||
{stats.currentStreak}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Current Streak
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -141,8 +172,16 @@
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">⭐</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-purple-400 mb-1">{stats.bestStreak}</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Best Streak</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-purple-400 mb-1"
|
||||
>
|
||||
{stats.bestStreak}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Best Streak
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -150,8 +189,16 @@
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">🎯</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-blue-400 mb-1">{stats.avgGuesses}</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Avg Guesses</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-blue-400 mb-1"
|
||||
>
|
||||
{stats.avgGuesses}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Avg Guesses
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -159,24 +206,46 @@
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">✅</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-green-400 mb-1">{stats.totalSolves}</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Total Solves</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-green-400 mb-1"
|
||||
>
|
||||
{stats.totalSolves}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Total Solves
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{#if stats.totalSolves > 0}
|
||||
<!-- Book Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6">
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6"
|
||||
>
|
||||
<!-- Worst Day -->
|
||||
{#if stats.worstDay}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">😅</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Worst Day</div>
|
||||
<div class="text-xl md:text-2xl font-bold text-red-400 truncate">{stats.worstDay.guessCount} guesses</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">{formatDate(stats.worstDay.date)}</div>
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Worst Day
|
||||
</div>
|
||||
<div
|
||||
class="text-xl md:text-2xl font-bold text-red-400 truncate"
|
||||
>
|
||||
{stats.worstDay.guessCount} guesses
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{formatDate(stats.worstDay.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -188,9 +257,22 @@
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">🏆</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Best Book</div>
|
||||
<div class="text-lg md:text-xl font-bold text-amber-400 truncate">{getBookName(stats.bestBook.bookId)}</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">{stats.bestBook.avgGuesses} avg guesses ({stats.bestBook.count}x)</div>
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Best Book
|
||||
</div>
|
||||
<div
|
||||
class="text-lg md:text-xl font-bold text-amber-400 truncate"
|
||||
>
|
||||
{getBookName(stats.bestBook.bookId)}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{stats.bestBook.avgGuesses} avg guesses ({stats
|
||||
.bestBook.count}x)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -202,9 +284,24 @@
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">📖</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Most Seen Book</div>
|
||||
<div class="text-lg md:text-xl font-bold text-indigo-400 truncate">{getBookName(stats.mostSeenBook.bookId)}</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">{stats.mostSeenBook.count} time{stats.mostSeenBook.count === 1 ? '' : 's'}</div>
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Most Seen Book
|
||||
</div>
|
||||
<div
|
||||
class="text-lg md:text-xl font-bold text-indigo-400 truncate"
|
||||
>
|
||||
{getBookName(stats.mostSeenBook.bookId)}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{stats.mostSeenBook.count} time{stats
|
||||
.mostSeenBook.count === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -215,11 +312,20 @@
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">📚</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Unique Books</div>
|
||||
<div class="text-xl md:text-2xl font-bold text-teal-400">
|
||||
{stats.totalBooksSeenOT + stats.totalBooksSeenNT}
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Unique Books
|
||||
</div>
|
||||
<div
|
||||
class="text-xl md:text-2xl font-bold text-teal-400"
|
||||
>
|
||||
{stats.totalBooksSeenOT +
|
||||
stats.totalBooksSeenNT}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">
|
||||
OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -227,18 +333,33 @@
|
||||
|
||||
<!-- Grade Distribution -->
|
||||
<Container class="p-5 md:p-6 mb-6">
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">Grade Distribution</h2>
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
|
||||
Grade Distribution
|
||||
</h2>
|
||||
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
|
||||
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
|
||||
{@const percentage = getGradePercentage(count, stats.totalSolves)}
|
||||
{@const percentage = getGradePercentage(
|
||||
count,
|
||||
stats.totalSolves,
|
||||
)}
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<span class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(grade)}">
|
||||
<span
|
||||
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
|
||||
grade,
|
||||
)}"
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-lg md:text-2xl font-bold text-gray-100">{count}</div>
|
||||
<div class="text-xs text-gray-400">{percentage}%</div>
|
||||
<div
|
||||
class="text-lg md:text-2xl font-bold text-gray-100"
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -247,16 +368,37 @@
|
||||
<!-- Recent Performance -->
|
||||
{#if stats.recentCompletions.length > 0}
|
||||
<Container class="p-5 md:p-6">
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">Recent Performance</h2>
|
||||
<h2
|
||||
class="text-lg md:text-xl font-bold text-gray-100 mb-4"
|
||||
>
|
||||
Recent Performance
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{#each stats.recentCompletions as completion (completion.date)}
|
||||
<div class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0">
|
||||
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<span class="text-sm md:text-base font-medium text-gray-200">{formatDate(completion.date)}</span>
|
||||
<span
|
||||
class="text-sm md:text-base font-medium text-gray-200"
|
||||
>{formatDate(completion.date)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 md:gap-3">
|
||||
<span class="text-xs md:text-sm text-gray-300">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
|
||||
<span class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(completion.grade)}">
|
||||
<div
|
||||
class="flex items-center gap-2 md:gap-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs md:text-sm text-gray-300"
|
||||
>{completion.guessCount} guess{completion.guessCount ===
|
||||
1
|
||||
? ""
|
||||
: "es"}</span
|
||||
>
|
||||
<span
|
||||
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
|
||||
completion.grade,
|
||||
)}"
|
||||
>
|
||||
{completion.grade}
|
||||
</span>
|
||||
</div>
|
||||
@@ -270,4 +412,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} anonymousId="" />
|
||||
<AuthModal bind:isOpen={authModalOpen} anonymousId="" />
|
||||
|
||||
@@ -7,7 +7,14 @@ const config = {
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: { adapter: adapter() }
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
csrf: {
|
||||
// Disabled because Apple Sign In uses cross-origin form_post.
|
||||
// The Apple callback route has its own CSRF protection via state + cookie.
|
||||
checkOrigin: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
498
tests/timezone-handling.test.ts
Normal file
498
tests/timezone-handling.test.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
||||
import { testDb as db } from '$lib/server/db/test';
|
||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
describe('Timezone-aware daily verse system', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up test data before each test
|
||||
await db.delete(dailyVerses);
|
||||
await db.delete(dailyCompletions);
|
||||
});
|
||||
|
||||
describe('Daily verse retrieval', () => {
|
||||
test('users in different timezones can see different verses at the same UTC moment', async () => {
|
||||
// Simulate: It's 2024-01-15 23:00 UTC
|
||||
// - Tokyo (UTC+9): 2024-01-16 08:00
|
||||
// - New York (UTC-5): 2024-01-15 18:00
|
||||
|
||||
const tokyoDate = '2024-01-16';
|
||||
const newYorkDate = '2024-01-15';
|
||||
|
||||
// Create verses for both dates
|
||||
const tokyoVerse = {
|
||||
id: crypto.randomUUID(),
|
||||
date: tokyoDate,
|
||||
bookId: 'GEN',
|
||||
verseText: 'Tokyo verse',
|
||||
reference: 'Genesis 1:1',
|
||||
};
|
||||
|
||||
const newYorkVerse = {
|
||||
id: crypto.randomUUID(),
|
||||
date: newYorkDate,
|
||||
bookId: 'EXO',
|
||||
verseText: 'New York verse',
|
||||
reference: 'Exodus 1:1',
|
||||
};
|
||||
|
||||
await db.insert(dailyVerses).values([tokyoVerse, newYorkVerse]);
|
||||
|
||||
// Verify Tokyo user gets Jan 16 verse
|
||||
const tokyoResult = await db
|
||||
.select()
|
||||
.from(dailyVerses)
|
||||
.where(eq(dailyVerses.date, tokyoDate))
|
||||
.limit(1);
|
||||
|
||||
expect(tokyoResult).toHaveLength(1);
|
||||
expect(tokyoResult[0].bookId).toBe('GEN');
|
||||
expect(tokyoResult[0].verseText).toBe('Tokyo verse');
|
||||
|
||||
// Verify New York user gets Jan 15 verse
|
||||
const newYorkResult = await db
|
||||
.select()
|
||||
.from(dailyVerses)
|
||||
.where(eq(dailyVerses.date, newYorkDate))
|
||||
.limit(1);
|
||||
|
||||
expect(newYorkResult).toHaveLength(1);
|
||||
expect(newYorkResult[0].bookId).toBe('EXO');
|
||||
expect(newYorkResult[0].verseText).toBe('New York verse');
|
||||
});
|
||||
|
||||
test('verse dates are stored in YYYY-MM-DD format', async () => {
|
||||
const verse = {
|
||||
id: crypto.randomUUID(),
|
||||
date: '2024-01-15',
|
||||
bookId: 'GEN',
|
||||
verseText: 'Test verse',
|
||||
reference: 'Genesis 1:1',
|
||||
};
|
||||
|
||||
await db.insert(dailyVerses).values(verse);
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(dailyVerses)
|
||||
.where(eq(dailyVerses.id, verse.id))
|
||||
.limit(1);
|
||||
|
||||
expect(result[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Completion tracking', () => {
|
||||
test('completions are stored with user local date', async () => {
|
||||
const userId = 'test-user-1';
|
||||
const localDate = '2024-01-16'; // User's local date
|
||||
|
||||
const completion = {
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: localDate,
|
||||
guessCount: 3,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
|
||||
await db.insert(dailyCompletions).values(completion);
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId))
|
||||
.limit(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].date).toBe(localDate);
|
||||
});
|
||||
|
||||
test('users in different timezones can complete different date verses simultaneously', async () => {
|
||||
const tokyoUser = 'tokyo-user';
|
||||
const newYorkUser = 'newyork-user';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: tokyoUser,
|
||||
date: '2024-01-16', // Tokyo: Jan 16
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: newYorkUser,
|
||||
date: '2024-01-15', // New York: Jan 15
|
||||
guessCount: 4,
|
||||
completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const tokyoResult = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, tokyoUser))
|
||||
.limit(1);
|
||||
|
||||
const newYorkResult = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, newYorkUser))
|
||||
.limit(1);
|
||||
|
||||
expect(tokyoResult[0].date).toBe('2024-01-16');
|
||||
expect(newYorkResult[0].date).toBe('2024-01-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Streak calculation', () => {
|
||||
test('consecutive days count as a streak', async () => {
|
||||
const userId = 'streak-user';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-13',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-15',
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
|
||||
// Verify consecutive dates
|
||||
expect(sortedDates).toEqual(['2024-01-13', '2024-01-14', '2024-01-15']);
|
||||
|
||||
// Calculate streak
|
||||
let streak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor(
|
||||
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
streak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(streak).toBe(3);
|
||||
});
|
||||
|
||||
test('current streak is active if last completion was today', async () => {
|
||||
const userId = 'current-streak-user';
|
||||
const userToday = '2024-01-16';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-15',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: userToday,
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-16T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
|
||||
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||
|
||||
expect(isStreakActive).toBe(true);
|
||||
expect(lastPlayedDate).toBe(userToday);
|
||||
});
|
||||
|
||||
test('current streak is active if last completion was yesterday', async () => {
|
||||
const userId = 'yesterday-streak-user';
|
||||
const userToday = '2024-01-16';
|
||||
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-13',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: yesterday,
|
||||
guessCount: 1,
|
||||
completedAt: new Date(yesterday + 'T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||
|
||||
expect(isStreakActive).toBe(true);
|
||||
expect(lastPlayedDate).toBe(yesterday);
|
||||
});
|
||||
|
||||
test('current streak is not active if last completion was 2+ days ago', async () => {
|
||||
const userId = 'broken-streak-user';
|
||||
const userToday = '2024-01-16';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-13',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
|
||||
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||
|
||||
expect(isStreakActive).toBe(false);
|
||||
expect(lastPlayedDate).toBe('2024-01-14'); // 2 days ago
|
||||
});
|
||||
|
||||
test('gap in dates breaks the streak', async () => {
|
||||
const userId = 'gap-user';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-10',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-10T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-11',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-11T12:00:00Z'),
|
||||
},
|
||||
// Gap here (no 01-12)
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-13',
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
|
||||
// Calculate best streak (should be 2, not 4)
|
||||
let bestStreak = 1;
|
||||
let tempStreak = 1;
|
||||
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor(
|
||||
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
tempStreak++;
|
||||
} else {
|
||||
bestStreak = Math.max(bestStreak, tempStreak);
|
||||
tempStreak = 1;
|
||||
}
|
||||
}
|
||||
bestStreak = Math.max(bestStreak, tempStreak);
|
||||
|
||||
expect(bestStreak).toBe(2); // Longest streak is Jan 13-14 or Jan 10-11
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date validation', () => {
|
||||
test('date must be in YYYY-MM-DD format', () => {
|
||||
const validDates = ['2024-01-15', '2023-12-31', '2024-02-29'];
|
||||
|
||||
validDates.forEach((date) => {
|
||||
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid date formats are rejected', () => {
|
||||
const invalidDates = [
|
||||
'2024/01/15', // Wrong separator
|
||||
'01-15-2024', // Wrong order
|
||||
'2024-1-15', // Missing leading zero
|
||||
'2024-01-15T12:00:00Z', // Includes time
|
||||
];
|
||||
|
||||
invalidDates.forEach((date) => {
|
||||
if (date.includes('T')) {
|
||||
expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
} else {
|
||||
expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('crossing year boundary maintains streak', async () => {
|
||||
const userId = 'year-boundary-user';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2023-12-30',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2023-12-30T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2023-12-31',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2023-12-31T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-01',
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-01T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
|
||||
let streak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor(
|
||||
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
streak++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(streak).toBe(3);
|
||||
});
|
||||
|
||||
// Note: Duplicate prevention is handled by the API endpoint, not at the DB level in these tests
|
||||
// See /api/submit-completion for the unique constraint enforcement
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user