From e390def18a440770d63fc6fdd2eedaa1c8aabe6f Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Sat, 13 Dec 2025 20:26:08 -0700 Subject: [PATCH 01/31] simplify grid state --- .gitignore | 1 + app/ui/game-view.js | 30 ++----- app/ui/hanzi-grid.js | 112 ++++++++++++++++---------- package-lock.json | 187 ++++++++++--------------------------------- package.json | 2 +- 5 files changed, 124 insertions(+), 208 deletions(-) diff --git a/.gitignore b/.gitignore index 510efdd..ab08e87 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ node_modules/ # TypeScript *.tsbuildinfo +.env*.local diff --git a/app/ui/game-view.js b/app/ui/game-view.js index 9d4cf14..8edc039 100644 --- a/app/ui/game-view.js +++ b/app/ui/game-view.js @@ -5,20 +5,13 @@ 'use client'; import { useRef, useEffect, useReducer, useState } from "react"; import HowToBox from './how-to-box'; -import HanziGrid, { initialGridState, gridReducer } from "./hanzi-grid"; +import HanziGrid, { initialGridState, gridReducer, gameIsFinished } from "./hanzi-grid"; import { useStopwatch } from "react-timer-hook"; -import PlayerList from "app/ui/player-list"; -import { getTopScores, submitDailyScore } from "../lib/db/db"; -import { currentDateSeed } from "app/lib/utils"; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography } from '@mui/material'; import { shareOnMobile } from "react-mobile-share"; import WordList from "./word-list"; import { TimerFace } from "app/ui/timer"; -const gameIsFinished = (gameState) => { - return gameState.completed || gameState.strikes == 3; -} - const makeShareableResultString = (gameState, milliseconds, dateSeed) => { const totalSeconds = Math.floor(milliseconds / 1000); const mins = Math.floor(totalSeconds / 60); @@ -138,7 +131,7 @@ export default function GameView({ words, shuffledChars, dateSeed, hskLevel }) { const [showHowTo, setShowHowTo] = useState(true); const [showResumeModal, setShowResumeModal] = useState(false); const [lastSaveTime, setLastSaveTime] = useState(Date.now()); - const [playedFailAnimation, setPlayedFailAnimation] = useState(false); + const [gameBegun, setGameBegun] = useState(false); // Initialize stopwatch with saved time if resuming const stopWatch = useStopwatch({ @@ -158,7 +151,6 @@ export default function GameView({ words, shuffledChars, dateSeed, hskLevel }) { stopWatch.reset(new Date(Date.now() + savedGame.milliseconds), false); setShowHowTo(false); setShowResumeModal(true); - setPlayedFailAnimation(savedGame.game.strikes === 3); } else { setShowHowTo(true); setShowResumeModal(false); @@ -173,19 +165,7 @@ export default function GameView({ words, shuffledChars, dateSeed, hskLevel }) { } }, [stopWatch, currentGameState]); - function failAnimation() { - let tiles = currentGameState.tileStates.map((t, i) => i); - dispatch({ type: 'shake', tiles }); - setTimeout(() => { - dispatch({ type: 'clear-shake', tiles }); - }, 500); - } - useEffect(() => { - if (currentGameState.strikes === 3 && !playedFailAnimation) { - failAnimation(); - setPlayedFailAnimation(true); - } // save game state when it changes if (gameIsFinished(currentGameState)) stopWatch.pause(); // only save if the game was actually played @@ -196,7 +176,10 @@ export default function GameView({ words, shuffledChars, dateSeed, hskLevel }) { function resumeGame() { setShowResumeModal(false); setShowHowTo(false); - if (!gameIsFinished(currentGameState)) stopWatch.start(); + if (!gameIsFinished(currentGameState)) { + stopWatch.start(); + setGameBegun(true); + } } return ( @@ -228,6 +211,7 @@ export default function GameView({ words, shuffledChars, dateSeed, hskLevel }) {
diff --git a/app/ui/hanzi-grid.js b/app/ui/hanzi-grid.js index 264d768..d23e252 100644 --- a/app/ui/hanzi-grid.js +++ b/app/ui/hanzi-grid.js @@ -2,38 +2,41 @@ import HanziTile from "./hanzi-tile"; import { isValidWord } from "../lib/dictionary"; import { produce } from "immer"; +import { useEffect, useState } from "react"; // possibly add functionality to generate more colors if needed (for bigger game boards) const matchColors = ['border-green-300', 'border-red-600', 'border-teal-300', 'border-orange-300', 'border-pink-300', 'border-red-300', 'border-indigo-300', 'border-amber-300']; export const initialGridState = (characters) => ({ - tileStates: characters.map(c => ({char: c, match: null, color: null, shaking: false})), + tileStates: characters.map(c => ({char: c, match: null, color: null})), remainingColors: [...matchColors], - selectedTile: null, - completed: false, strikes: 0, }); +export const gameIsCompleted = (gameState) => { + return gameState.tileStates.every(t => t.match !== null); +} + +export const gameIsFinished = (gameState) => { + return gameIsCompleted(gameState) || gameState.strikes == 3; +} + export function gridReducer(state, action) { switch(action.type) { + // TODO do we need this case 'reset': { return action.state; } case 'match': { const [index1, index2] = action.tiles; const color = state.remainingColors[0]; - const newState = produce(state, draft => { + return produce(state, draft => { draft.tileStates[index1].match = index2; draft.tileStates[index1].color = color; draft.tileStates[index2].match = index1; draft.tileStates[index2].color = color; draft.remainingColors = draft.remainingColors.slice(1); - draft.selectedTile = null; }); - // check for game completion - if (newState.tileStates.every(t => t.match !== null)) - return {...newState, completed: true }; - else return newState; } case 'unmatch': { const tile1 = action.tile; @@ -52,45 +55,71 @@ export function gridReducer(state, action) { return {...state, strikes: state.strikes + 1}; } - case 'shake': { - return produce(state, draft => { - action.tiles.forEach(t => { - draft.tileStates[t].shaking = true; - }); - }); - } - case 'clear-shake': { - return produce(state, draft => { - action.tiles.forEach(t => { - draft.tileStates[t].shaking = false; - }) - }); - } + // case 'shake': { + // return produce(state, draft => { + // action.tiles.forEach(t => { + // draft.tileStates[t].shaking = true; + // }); + // }); + // } + // case 'clear-shake': { + // return produce(state, draft => { + // action.tiles.forEach(t => { + // draft.tileStates[t].shaking = false; + // }) + // }); + // } - case 'select': { - return {...state, selectedTile: action.tile }; - } - case 'deselect': { - return {...state, selectedTile: null }; - } + // case 'select': { + // return {...state, selectedTile: action.tile }; + // } + // case 'deselect': { + // return {...state, selectedTile: null }; + // } default: { throw new Error(`Unhandled action type: ${action.type}`); } } } -export default function HanziGrid({ state, dispatch }) { - const { tileStates, selectedTile, remainingColors, completed, strikes } = state; +export default function HanziGrid({ state, dispatch, gameBegun}) { + const { tileStates, strikes } = state; + + const [selectedTile, setSelectedTile] = useState(null); + const [shakingTiles, setShakingTiles] = useState([]); + const [playedFailAnimation, setPlayedFailAnimation] = useState(false); + const characters = tileStates.map(({char}) => char); + function shakeTiles(tiles) { + setShakingTiles(shakingTiles => shakingTiles.concat(tiles)); + setTimeout(() => { + setShakingTiles(shakingTiles => shakingTiles.filter(t => !tiles.includes(t))); + }, 500); + } + + function failAnimation() { + let tiles = tileStates.map((t, i) => i) + shakeTiles(tiles); + } + + useEffect(() => { + if (strikes === 3 && gameBegun && !playedFailAnimation) { + console.log("Game over animation triggered!"); + failAnimation(); + setPlayedFailAnimation(true); + } + }, [strikes, gameBegun, playedFailAnimation]); + function handleTileClick(index) { - if (completed || strikes == 3) return; // no action if game is completed + if (gameIsCompleted(state)) return; // no action if game is completed if (tileStates[index].match !== null) { dispatch({ type: 'unmatch', tile: index }); } else if (selectedTile === index) { - dispatch({ type: 'deselect' }); + // dispatch({ type: 'deselect' }); + setSelectedTile(null); } else if (selectedTile !== null) { // check if selected tiles form a word @@ -98,19 +127,20 @@ export default function HanziGrid({ state, dispatch }) { if (isValidWord(word)) { console.log(`${word} is valid!`); dispatch({ type: 'match', tiles: [selectedTile, index] }); + setSelectedTile(null); } else { console.log(`${word} is NOT valid!`); // Trigger a shake + flash animation on both tiles, then clear and deselect const tiles = [selectedTile, index]; - dispatch({ type: 'shake', tiles }); - dispatch({ type: 'deselect' }); + // dispatch({ type: 'shake', tiles }); + shakeTiles(tiles); + // dispatch({ type: 'deselect' }); + setSelectedTile(null); dispatch({ type: 'strike'}); - setTimeout(() => { - dispatch({ type: 'clear-shake', tiles }); - }, 500); } } else { - dispatch({ type: 'select', tile: index }); + // dispatch({ type: 'select', tile: index }); + setSelectedTile(index); } } @@ -123,10 +153,10 @@ export default function HanziGrid({ state, dispatch }) { key={char + index} matchColor={tileStates[index].color} selected={index == selectedTile} - shaking={tileStates[index].shaking} + shaking={shakingTiles.includes(index)} character={char} handleClick={() => handleTileClick(index)} - inactive={completed || strikes === 3} + inactive={gameIsFinished(state)} index={index} />) } diff --git a/package-lock.json b/package-lock.json index 8a278b9..f8214ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "zimi", "dependencies": { "@auth/neon-adapter": "^1.11.1", "@emotion/react": "^11.14.0", @@ -15,7 +16,7 @@ "immer": "^10.2.0", "motion": "^12.23.25", "net": "^1.0.2", - "next-auth": "^4.24.13", + "next-auth": "^4.24.7", "nodemailer": "^7.0.10", "postgres": "^3.4.7", "react": "^19.2.1", @@ -79,89 +80,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "node_modules/@auth/core": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", - "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", - "optional": true, - "peer": true, - "dependencies": { - "@panva/hkdf": "^1.1.1", - "@types/cookie": "0.6.0", - "cookie": "0.6.0", - "jose": "^5.1.3", - "oauth4webapi": "^2.10.4", - "preact": "10.11.3", - "preact-render-to-string": "5.2.3" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^7" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, - "node_modules/@auth/core/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@auth/core/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/@auth/core/node_modules/preact": { - "version": "10.11.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", - "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/@auth/core/node_modules/preact-render-to-string": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", - "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", - "optional": true, - "peer": true, - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, - "node_modules/@auth/core/node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", - "optional": true, - "peer": true - }, "node_modules/@auth/neon-adapter": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@auth/neon-adapter/-/neon-adapter-1.11.1.tgz", @@ -855,9 +773,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz", - "integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -2210,14 +2128,14 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/@next/env": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", - "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==" + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", + "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz", - "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz", + "integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==", "cpu": [ "arm64" ], @@ -2230,9 +2148,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz", - "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz", + "integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==", "cpu": [ "x64" ], @@ -2245,9 +2163,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz", - "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz", + "integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==", "cpu": [ "arm64" ], @@ -2260,9 +2178,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz", - "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz", + "integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==", "cpu": [ "arm64" ], @@ -2275,9 +2193,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz", - "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz", + "integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==", "cpu": [ "x64" ], @@ -2290,9 +2208,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz", - "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz", + "integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==", "cpu": [ "x64" ], @@ -2305,9 +2223,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz", - "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz", + "integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==", "cpu": [ "arm64" ], @@ -2320,9 +2238,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz", - "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz", + "integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==", "cpu": [ "x64" ], @@ -2856,13 +2774,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "optional": true, - "peer": true - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -6123,11 +6034,11 @@ "integrity": "sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==" }, "node_modules/next": { - "version": "16.0.7", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", - "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz", + "integrity": "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==", "dependencies": { - "@next/env": "16.0.7", + "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -6140,14 +6051,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.7", - "@next/swc-darwin-x64": "16.0.7", - "@next/swc-linux-arm64-gnu": "16.0.7", - "@next/swc-linux-arm64-musl": "16.0.7", - "@next/swc-linux-x64-gnu": "16.0.7", - "@next/swc-linux-x64-musl": "16.0.7", - "@next/swc-win32-arm64-msvc": "16.0.7", - "@next/swc-win32-x64-msvc": "16.0.7", + "@next/swc-darwin-arm64": "16.0.10", + "@next/swc-darwin-x64": "16.0.10", + "@next/swc-linux-arm64-gnu": "16.0.10", + "@next/swc-linux-arm64-musl": "16.0.10", + "@next/swc-linux-x64-gnu": "16.0.10", + "@next/swc-linux-x64-musl": "16.0.10", + "@next/swc-win32-arm64-msvc": "16.0.10", + "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { @@ -6274,16 +6185,6 @@ "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" }, - "node_modules/oauth4webapi": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", - "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index fc813a0..0b2232b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "immer": "^10.2.0", "motion": "^12.23.25", "net": "^1.0.2", - "next-auth": "^4.24.13", + "next-auth": "^4.24.7", "nodemailer": "^7.0.10", "postgres": "^3.4.7", "react": "^19.2.1", From 7dfba94fb06d39eea4f5eba940003dfba75ccd8a Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Sat, 13 Dec 2025 21:23:09 -0700 Subject: [PATCH 02/31] GameSession component; handling saves on unload --- app/page.js | 4 +- app/ui/game-session.js | 192 +++++++++++++++++++++++++++++++++++++++++ app/ui/game-view.js | 184 +++------------------------------------ 3 files changed, 204 insertions(+), 176 deletions(-) create mode 100644 app/ui/game-session.js diff --git a/app/page.js b/app/page.js index bad8ebf..9d47916 100644 --- a/app/page.js +++ b/app/page.js @@ -1,4 +1,4 @@ -import GameView from "app/ui/game-view"; +import GameSession from "app/ui/game-session"; import ErrorPage from "app/ui/error-page"; import { getRandomWords, isValidWord } from "app/lib/dictionary"; import { currentDateStr, mkDateStr, sample, getDailyDifficulty } from "app/lib/utils"; @@ -56,7 +56,7 @@ export default async function Page(props) { return (
- +
); } diff --git a/app/ui/game-session.js b/app/ui/game-session.js new file mode 100644 index 0000000..f741e23 --- /dev/null +++ b/app/ui/game-session.js @@ -0,0 +1,192 @@ +'use client'; + +import GameView from "./game-view"; +import { useRef, useEffect, useReducer, useState } from "react"; +import { useStopwatch } from "react-timer-hook"; +import { initialGridState, gridReducer, gameIsFinished } from "./hanzi-grid"; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography } from '@mui/material'; +import HowToBox from 'app/ui/how-to-box'; +import { shareOnMobile } from "react-mobile-share"; +import WordList from "./word-list"; + + + +const makeShareableResultString = (gameState, milliseconds, dateSeed) => { + const totalSeconds = Math.floor(milliseconds / 1000); + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + const ms = milliseconds % 1000; + const timeStr = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}:${String(ms).padStart(3, '0')}` + + const tileToEmoji = (tile) => tile.match !== null ? '🟩' : '🟥'; + const date = new Date(dateSeed); + + const grid = gameState.tileStates.map((tile, index) => { + const isEndOfRow = (index + 1) % 4 === 0; + return tileToEmoji(tile) + (isEndOfRow ? '\n' : ''); + }).join(''); + + return `My Daily Zimi\n${date.toDateString()}\n${grid}\n${'❌'.repeat(gameState.strikes)} ${gameState.strikes === 3 ? '😭' : timeStr}\n` +} + +/** + * Save a snapshot of the current game state to localStorage + * @param {*} gameState + * @param {*} milliseconds + * @param {*} dateSeed + * @param {*} words - array of words for this game + */ +function saveLocalState(gameState, milliseconds, dateSeed, words) { + console.log('Saving game state to localStorage...', gameState, milliseconds, dateSeed); + const objectToStore = { game: gameState, milliseconds, date: dateSeed, words }; + try { + localStorage.setItem("zimi-save", JSON.stringify(objectToStore)); + } catch (e) { + console.error('Failed to save game state to localStorage:', e); + } +} + +/** + * + * @param {string} dateSeed retrieve last saved game state for this date + * @param {Array} currentWords - the word list for the current game + * @returns { game: grid state, milliseconds: number } | null + */ +function retrieveLocalState(dateStr, currentWords) { + try { + const savedData = JSON.parse(localStorage.getItem("zimi-save")); + console.log('Retrieved raw saved data:', savedData); + + if (!savedData || savedData.date !== dateStr) { + console.log('No saved game state for', dateStr); + return null; + } + + const wordListMatch = JSON.stringify(savedData.words) === JSON.stringify(currentWords); + if (!wordListMatch) { + console.log('Saved word list does not match current word list. Saved:', savedData.words, 'Current:', currentWords); + return null; + } + + return savedData + } catch (e) { + console.error('Failed to retrieve game state:', e); + return null; + } +} + +export default function GameSession({ words, shuffledChars, dateSeed, hskLevel }) { + const [ currentGameState, dispatch ] = useReducer(gridReducer, initialGridState(shuffledChars)); + + const [showHowTo, setShowHowTo] = useState(true); + const [showResumeModal, setShowResumeModal] = useState(false); + const [gameBegun, setGameBegun] = useState(false); + + // Initialize stopwatch with saved time if resuming + const stopWatch = useStopwatch({ + autoStart: false, + interval: 20, + }); + + function getMilliseconds() { + return stopWatch.totalSeconds * 1000 + stopWatch.milliseconds; + } + + // upon mounting, check for saved game state in localStorage + useEffect(() => { + const savedGame = retrieveLocalState(dateSeed, words); + if (savedGame) { + dispatch({ type: 'reset', state: savedGame.game }); + stopWatch.reset(new Date(Date.now() + savedGame.milliseconds), false); + setShowHowTo(false); + setShowResumeModal(true); + } else { + setShowHowTo(true); + setShowResumeModal(false); + } + }, [dateSeed, words]); + + useEffect(() => { + if (gameIsFinished(currentGameState)) stopWatch.pause(); + // only save if the game was actually played + if (gameBegun) saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); + }, [currentGameState, dateSeed, words]); + + + // set up callback to run beforeunload to save game state (save the user's time if they leave mid-game) + useEffect(() => { + const handleBeforeUnload = (e) => { + if (gameBegun && !gameIsFinished(currentGameState)) { + saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + + // setting the dependency only to the stopWatch.totalSeconds to avoid excessive re-registrations + // if the stopWatch itself was a dependency, it would recompute the eventListener on every tick + }, [currentGameState, gameBegun, dateSeed, words, stopWatch.totalSeconds]); + + function resumeGame() { + setShowResumeModal(false); + setShowHowTo(false); + if (!gameIsFinished(currentGameState)) { + stopWatch.start(); + setGameBegun(true); + } + } + + return ( +
+ {showHowTo && } + + + Daily Zimi + + { gameIsFinished(currentGameState) ? + "You have a completed game from today. Come back tomorrow for a new zimi!" : + "You have an in-progress game from today. Resume where you left off?" + } + {hskLevel && ( + + Today's puzzle is HSK Level {hskLevel} + + )} + + + + + + +
+ + { gameIsFinished(currentGameState) && ( + + ) } +
+ { gameIsFinished(currentGameState) && } +
+ ) + +} \ No newline at end of file diff --git a/app/ui/game-view.js b/app/ui/game-view.js index 8edc039..fd6a9cb 100644 --- a/app/ui/game-view.js +++ b/app/ui/game-view.js @@ -12,70 +12,6 @@ import { shareOnMobile } from "react-mobile-share"; import WordList from "./word-list"; import { TimerFace } from "app/ui/timer"; -const makeShareableResultString = (gameState, milliseconds, dateSeed) => { - const totalSeconds = Math.floor(milliseconds / 1000); - const mins = Math.floor(totalSeconds / 60); - const secs = totalSeconds % 60; - const ms = milliseconds % 1000; - const timeStr = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}:${String(ms).padStart(3, '0')}` - - const tileToEmoji = (tile) => tile.match !== null ? '🟩' : '🟥'; - const date = new Date(dateSeed); - - const grid = gameState.tileStates.map((tile, index) => { - const isEndOfRow = (index + 1) % 4 === 0; - return tileToEmoji(tile) + (isEndOfRow ? '\n' : ''); - }).join(''); - - return `My Daily Zimi\n${date.toDateString()}\n${grid}\n${'❌'.repeat(gameState.strikes)} ${gameState.strikes === 3 ? '😭' : timeStr}\n` -} - -/** - * Save a snapshot of the current game state to localStorage - * @param {*} gameState - * @param {*} milliseconds - * @param {*} dateSeed - * @param {*} words - array of words for this game - */ -function saveLocalState(gameState, milliseconds, dateSeed, words) { - console.log('Saving game state to localStorage...', gameState, milliseconds, dateSeed); - const objectToStore = { game: gameState, milliseconds, date: dateSeed, words }; - try { - localStorage.setItem("zimi-save", JSON.stringify(objectToStore)); - } catch (e) { - console.error('Failed to save game state to localStorage:', e); - } -} - -/** - * - * @param {string} dateSeed retrieve last saved game state for this date - * @param {Array} currentWords - the word list for the current game - * @returns { game: grid state, milliseconds: number } | null - */ -function retrieveLocalState(dateStr, currentWords) { - try { - const savedData = JSON.parse(localStorage.getItem("zimi-save")); - console.log('Retrieved raw saved data:', savedData); - - if (!savedData || savedData.date !== dateStr) { - console.log('No saved game state for', dateStr); - return null; - } - - const wordListMatch = JSON.stringify(savedData.words) === JSON.stringify(currentWords); - if (!wordListMatch) { - console.log('Saved word list does not match current word list. Saved:', savedData.words, 'Current:', currentWords); - return null; - } - - return savedData - } catch (e) { - console.error('Failed to retrieve game state:', e); - return null; - } -} - function StrikesIndicator({ strikes }) { return ( @@ -125,119 +61,19 @@ function TimerDisplay({ stopWatch }) { ); } -export default function GameView({ words, shuffledChars, dateSeed, hskLevel }) { - const [ currentGameState, dispatch ] = useReducer(gridReducer, initialGridState(shuffledChars)); - - const [showHowTo, setShowHowTo] = useState(true); - const [showResumeModal, setShowResumeModal] = useState(false); - const [lastSaveTime, setLastSaveTime] = useState(Date.now()); - const [gameBegun, setGameBegun] = useState(false); - - // Initialize stopwatch with saved time if resuming - const stopWatch = useStopwatch({ - autoStart: false, - interval: 20, - }); - - function getMilliseconds() { - return stopWatch.totalSeconds * 1000 + stopWatch.milliseconds; - } - - // upon mounting, check for saved game state in localStorage - useEffect(() => { - const savedGame = retrieveLocalState(dateSeed, words); - if (savedGame) { - dispatch({ type: 'reset', state: savedGame.game }); - stopWatch.reset(new Date(Date.now() + savedGame.milliseconds), false); - setShowHowTo(false); - setShowResumeModal(true); - } else { - setShowHowTo(true); - setShowResumeModal(false); - } - }, [dateSeed, words]); - - // Continuously save stopwatch value while timer is running every second - useEffect(() => { - if (Date.now() - lastSaveTime > 1000 && stopWatch.isRunning) { - saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); - setLastSaveTime(Date.now()); - } - }, [stopWatch, currentGameState]); - - useEffect(() => { - // save game state when it changes - if (gameIsFinished(currentGameState)) stopWatch.pause(); - // only save if the game was actually played - if (getMilliseconds() > 0) saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); - }, [currentGameState.tileStates, currentGameState.strikes, dateSeed, words]); - - - function resumeGame() { - setShowResumeModal(false); - setShowHowTo(false); - if (!gameIsFinished(currentGameState)) { - stopWatch.start(); - setGameBegun(true); - } - } +export default function GameView({ gameState, dispatch, timer, gameBegun }) { return ( -
- {showHowTo && } - - - Daily Zimi - - { gameIsFinished(currentGameState) ? - "You have a completed game from today. Come back tomorrow for a new zimi!" : - "You have an in-progress game from today. Resume where you left off?" - } - {hskLevel && ( - - Today's puzzle is HSK Level {hskLevel} - - )} - - - - - - -
-
- -
- - -
- { gameIsFinished(currentGameState) && ( - - ) } - +
+ +
+ +
- {/* player.milliseconds} /> */}
- { gameIsFinished(currentGameState) && } -
) } From bbf8abd36a18421abcf3222b369a1911a067046d Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Sat, 13 Dec 2025 21:58:26 -0700 Subject: [PATCH 03/31] refactor modal components --- app/ui/game-session.js | 35 +++++++--------- app/ui/hanzi-grid.js | 27 +----------- app/ui/how-to-box.js | 95 +++++++++++++++++------------------------- app/ui/my-dialog.js | 51 +++++++++++++++++++++++ 4 files changed, 106 insertions(+), 102 deletions(-) create mode 100644 app/ui/my-dialog.js diff --git a/app/ui/game-session.js b/app/ui/game-session.js index f741e23..e0f2606 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -4,8 +4,9 @@ import GameView from "./game-view"; import { useRef, useEffect, useReducer, useState } from "react"; import { useStopwatch } from "react-timer-hook"; import { initialGridState, gridReducer, gameIsFinished } from "./hanzi-grid"; -import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography } from '@mui/material'; +import { Button, Typography } from '@mui/material'; import HowToBox from 'app/ui/how-to-box'; +import MyDialog from 'app/ui/my-dialog'; import { shareOnMobile } from "react-mobile-share"; import WordList from "./word-list"; @@ -141,25 +142,19 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel }
{showHowTo && } - - Daily Zimi - - { gameIsFinished(currentGameState) ? - "You have a completed game from today. Come back tomorrow for a new zimi!" : - "You have an in-progress game from today. Resume where you left off?" - } - {hskLevel && ( - - Today's puzzle is HSK Level {hskLevel} - - )} - - - - - +
{ export function gridReducer(state, action) { switch(action.type) { - // TODO do we need this + // TODO do we need this? case 'reset': { return action.state; } @@ -55,27 +55,6 @@ export function gridReducer(state, action) { return {...state, strikes: state.strikes + 1}; } - // case 'shake': { - // return produce(state, draft => { - // action.tiles.forEach(t => { - // draft.tileStates[t].shaking = true; - // }); - // }); - // } - // case 'clear-shake': { - // return produce(state, draft => { - // action.tiles.forEach(t => { - // draft.tileStates[t].shaking = false; - // }) - // }); - // } - - // case 'select': { - // return {...state, selectedTile: action.tile }; - // } - // case 'deselect': { - // return {...state, selectedTile: null }; - // } default: { throw new Error(`Unhandled action type: ${action.type}`); } @@ -118,7 +97,6 @@ export default function HanziGrid({ state, dispatch, gameBegun}) { dispatch({ type: 'unmatch', tile: index }); } else if (selectedTile === index) { - // dispatch({ type: 'deselect' }); setSelectedTile(null); } else if (selectedTile !== null) { @@ -132,14 +110,11 @@ export default function HanziGrid({ state, dispatch, gameBegun}) { console.log(`${word} is NOT valid!`); // Trigger a shake + flash animation on both tiles, then clear and deselect const tiles = [selectedTile, index]; - // dispatch({ type: 'shake', tiles }); shakeTiles(tiles); - // dispatch({ type: 'deselect' }); setSelectedTile(null); dispatch({ type: 'strike'}); } } else { - // dispatch({ type: 'select', tile: index }); setSelectedTile(index); } } diff --git a/app/ui/how-to-box.js b/app/ui/how-to-box.js index 5b62e60..b71495c 100644 --- a/app/ui/how-to-box.js +++ b/app/ui/how-to-box.js @@ -1,70 +1,53 @@ "use client"; import React from 'react'; import HanziTile from './hanzi-tile'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; import Button from '@mui/material/Button'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/material/styles'; +import MyDialog from './my-dialog'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; export default function HowToBox({ open, onClose, hskLevel }) { - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - return ( - - - How to Play - {hskLevel && ( -
- Today's Puzzle: HSK Level {hskLevel} -
- )} -
- -
-
- 1. - Click two characters to form a word -
+ open={open} + onClose={onClose} + title="How to Play" + subTitle={hskLevel ? `Today's Puzzle: HSK Level ${hskLevel}` : undefined} + children={ + + + + 1. + Click two characters to form a word + + -
-
-
- 2. - If the two characters form a valid Chinese word, they match! -
+ + + + + 2. + If the two characters form a valid Chinese word, they match! + + -
-
-
- 3. - Making a wrong match gives you a strike. 3 strikes and you lose -
-
- 4. - Match all the pairs, but keep in mind: some characters could form more than one word! Click matched tiles again to unpair them -
-
-
- - - -
+ + + + 3. + Making a wrong match gives you a strike. 3 strikes and you lose + + + 4. + Match all the pairs, but keep in mind: some characters could form more than one word! Click matched tiles again to unpair them + + + } + buttonContent="Start" + /> ); } diff --git a/app/ui/my-dialog.js b/app/ui/my-dialog.js new file mode 100644 index 0000000..3a68c6c --- /dev/null +++ b/app/ui/my-dialog.js @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography +} from '@mui/material'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme } from '@mui/material/styles'; + +export default function MyDialog({ + open, + onClose, + title, + subTitle, + children, + buttonContent, +}) { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + return ( + + { title && + {title} + { subTitle &&
{subTitle}
} +
} + + {children && + {children} + } + + + +
+ ); +} From 65c382f877a531e1c115087e2e8ecef9a1b63d6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:48:12 +0000 Subject: [PATCH 04/31] Initial plan From 373a8cd003247dc6ecd2269761b0f32d26cdcd78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:51:21 +0000 Subject: [PATCH 05/31] Initial plan for streak tracking and user login features Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- package-lock.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index f8214ca..c40905f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "zimi", "dependencies": { "@auth/neon-adapter": "^1.11.1", "@emotion/react": "^11.14.0", From 5636d56dcbf8e4aa77d07e8980d4e54666461f00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:54:08 +0000 Subject: [PATCH 06/31] Add streak tracking, user menu, and login prompts Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- app/api/submit-score/route.js | 26 ++++++++ app/layout.js | 2 +- app/lib/db/db.js | 114 ++++++++++++++++++++++++++++++++++ app/ui/game-session.js | 58 ++++++++++++++++- app/ui/login-prompt-modal.js | 80 ++++++++++++++++++++++++ app/ui/streak-popup.js | 44 +++++++++++++ types/app.d.ts | 2 + 7 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 app/api/submit-score/route.js create mode 100644 app/ui/login-prompt-modal.js create mode 100644 app/ui/streak-popup.js diff --git a/app/api/submit-score/route.js b/app/api/submit-score/route.js new file mode 100644 index 0000000..2619573 --- /dev/null +++ b/app/api/submit-score/route.js @@ -0,0 +1,26 @@ +import { submitDailyScore, updateStreak } from 'app/lib/db/db'; +import { NextResponse } from 'next/server'; + +export async function POST(request) { + try { + const { milliseconds } = await request.json(); + + // Submit the daily score + await submitDailyScore(milliseconds); + + // Update the streak (completed if milliseconds is not null) + const completed = milliseconds !== null; + const streakData = await updateStreak(completed); + + return NextResponse.json({ + success: true, + streak: streakData + }); + } catch (error) { + console.error('Error submitting score:', error); + return NextResponse.json( + { success: false, error: error.message }, + { status: error.message === 'User not authenticated' ? 401 : 500 } + ); + } +} diff --git a/app/layout.js b/app/layout.js index 8c3e654..a3079fe 100644 --- a/app/layout.js +++ b/app/layout.js @@ -31,7 +31,7 @@ export default function RootLayout({ children }) { - {/* */} +
diff --git a/app/lib/db/db.js b/app/lib/db/db.js index 5ba0861..2519e5d 100644 --- a/app/lib/db/db.js +++ b/app/lib/db/db.js @@ -43,6 +43,120 @@ export async function submitDailyScore(milliseconds) { return result } +/** + * Get the user's current streak information + * @returns {Promise<{current_streak_length: number, longest_streak_length: number, current_streak_last_date: string} | null>} + */ +export async function getStreak() { + const session = await getServerSession(authOptions); + + if (session == null) { + return null; + } + + const result = await sql` + SELECT current_streak_length, longest_streak_length, current_streak_last_date + FROM streaks + WHERE user_id = (select id from users where email = ${session.user.email}) + `; + + return result.length > 0 ? result[0] : null; +} + +/** + * Update the user's streak after completing today's puzzle + * @param {boolean} completed - whether the user completed the puzzle (true) or failed (false) + * @returns {Promise<{current_streak_length: number, longest_streak_length: number}>} + */ +export async function updateStreak(completed) { + const session = await getServerSession(authOptions); + + if (session == null) { + throw new Error('User not authenticated'); + } + + const userId = await sql`select id from users where email = ${session.user.email}`; + + if (userId.length === 0) { + throw new Error('User not found'); + } + + const userIdValue = userId[0].id; + + // Get current streak data + const currentStreak = await sql` + SELECT current_streak_length, longest_streak_length, current_streak_last_date + FROM streaks + WHERE user_id = ${userIdValue} + `; + + let newStreakLength = 1; + let longestStreak = 1; + + if (completed) { + if (currentStreak.length > 0) { + const lastDate = currentStreak[0].current_streak_last_date; + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = yesterday.toISOString().split('T')[0]; + + // Check if last completion was yesterday + if (lastDate === yesterdayStr) { + // Continue the streak + newStreakLength = currentStreak[0].current_streak_length + 1; + } else if (lastDate === new Date().toISOString().split('T')[0]) { + // Already completed today, don't update + return { + current_streak_length: currentStreak[0].current_streak_length, + longest_streak_length: currentStreak[0].longest_streak_length, + }; + } + // If last date is neither yesterday nor today, streak resets to 1 + + longestStreak = Math.max(newStreakLength, currentStreak[0].longest_streak_length); + } + + // Update or insert streak + const result = await sql` + INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) + VALUES (${userIdValue}, ${newStreakLength}, ${longestStreak}, CURRENT_DATE) + ON CONFLICT (user_id) + DO UPDATE SET + current_streak_length = ${newStreakLength}, + longest_streak_length = ${longestStreak}, + current_streak_last_date = CURRENT_DATE + RETURNING current_streak_length, longest_streak_length; + `; + + console.log(`${session.user.email} streak updated: ${newStreakLength} (longest: ${longestStreak})`); + return result[0]; + } else { + // Failed to complete - reset streak to 0 + if (currentStreak.length > 0) { + await sql` + UPDATE streaks + SET current_streak_length = 0, + current_streak_last_date = CURRENT_DATE + WHERE user_id = ${userIdValue} + `; + return { + current_streak_length: 0, + longest_streak_length: currentStreak[0].longest_streak_length, + }; + } else { + // No existing streak record, insert with 0 + await sql` + INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) + VALUES (${userIdValue}, 0, 0, CURRENT_DATE) + `; + return { + current_streak_length: 0, + longest_streak_length: 0, + }; + } + } +} + // async function seedUsers() { // await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; // await sql` diff --git a/app/ui/game-session.js b/app/ui/game-session.js index e0f2606..e598853 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -3,12 +3,15 @@ import GameView from "./game-view"; import { useRef, useEffect, useReducer, useState } from "react"; import { useStopwatch } from "react-timer-hook"; -import { initialGridState, gridReducer, gameIsFinished } from "./hanzi-grid"; +import { initialGridState, gridReducer, gameIsFinished, gameIsCompleted } from "./hanzi-grid"; import { Button, Typography } from '@mui/material'; import HowToBox from 'app/ui/how-to-box'; import MyDialog from 'app/ui/my-dialog'; import { shareOnMobile } from "react-mobile-share"; import WordList from "./word-list"; +import StreakPopup from "./streak-popup"; +import LoginPromptModal from "./login-prompt-modal"; +import { useSession } from "next-auth/react"; @@ -78,10 +81,15 @@ function retrieveLocalState(dateStr, currentWords) { export default function GameSession({ words, shuffledChars, dateSeed, hskLevel }) { const [ currentGameState, dispatch ] = useReducer(gridReducer, initialGridState(shuffledChars)); + const { data: session, status } = useSession(); const [showHowTo, setShowHowTo] = useState(true); const [showResumeModal, setShowResumeModal] = useState(false); const [gameBegun, setGameBegun] = useState(false); + const [showStreakPopup, setShowStreakPopup] = useState(false); + const [showLoginPrompt, setShowLoginPrompt] = useState(false); + const [streakData, setStreakData] = useState(null); + const [scoreSubmitted, setScoreSubmitted] = useState(false); // Initialize stopwatch with saved time if resuming const stopWatch = useStopwatch({ @@ -113,6 +121,40 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel } if (gameBegun) saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); }, [currentGameState, dateSeed, words]); + // Submit score when game is finished + useEffect(() => { + if (gameIsFinished(currentGameState) && gameBegun && !scoreSubmitted) { + setScoreSubmitted(true); + + if (status === 'authenticated') { + // User is logged in, submit score + const completed = gameIsCompleted(currentGameState); + const milliseconds = completed ? getMilliseconds() : null; + + fetch('/api/submit-score', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ milliseconds }), + }) + .then(res => res.json()) + .then(data => { + if (data.success && completed) { + // Show streak popup + setStreakData(data.streak); + setTimeout(() => setShowStreakPopup(true), 500); + } + }) + .catch(error => { + console.error('Error submitting score:', error); + }); + } else if (status === 'unauthenticated' && gameIsCompleted(currentGameState)) { + // User is not logged in and completed the game, show login prompt + setTimeout(() => setShowLoginPrompt(true), 1000); + } + } + }, [currentGameState, gameBegun, scoreSubmitted, status]); // set up callback to run beforeunload to save game state (save the user's time if they leave mid-game) useEffect(() => { @@ -156,6 +198,20 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel } buttonContent={ gameIsFinished(currentGameState) ? "Look at scores" : "Resume" } /> + {streakData && ( + setShowStreakPopup(false)} + streakLength={streakData.current_streak_length} + isNewStreak={streakData.current_streak_length === 1} + /> + )} + + setShowLoginPrompt(false)} + /> +
{ + signIn(); + }; + + return ( + + + Track Your Progress! 📊 + + + + + + 🔥 Start your streak + + + Sign in to track your daily scores, build streaks, and compete with others! + + + + ✨ Keep your streak alive by solving puzzles daily +
+ 🏆 Compete on the leaderboard +
+ 📈 Track your progress over time +
+
+
+
+ + + + + +
+ ); +} diff --git a/app/ui/streak-popup.js b/app/ui/streak-popup.js new file mode 100644 index 0000000..314df33 --- /dev/null +++ b/app/ui/streak-popup.js @@ -0,0 +1,44 @@ +'use client'; + +import React from 'react'; +import { Dialog, DialogContent, Typography, Box } from '@mui/material'; +import { motion } from 'motion/react'; + +export default function StreakPopup({ open, onClose, streakLength, isNewStreak }) { + return ( + + + + + 🔥 + + + {isNewStreak ? 'Streak Started!' : 'Streak Updated!'} + + + {streakLength} {streakLength === 1 ? 'Day' : 'Days'} + + + Keep it up! Come back tomorrow to maintain your streak. + + + + + ); +} diff --git a/types/app.d.ts b/types/app.d.ts index 84ae359..1e29e04 100644 --- a/types/app.d.ts +++ b/types/app.d.ts @@ -28,4 +28,6 @@ declare module 'app/ui/*' { declare module 'app/lib/db/db' { export function getTopScores(limit?: number): Promise>; export function submitDailyScore(milliseconds: number | null): Promise; + export function getStreak(): Promise<{current_streak_length: number, longest_streak_length: number, current_streak_last_date: string} | null>; + export function updateStreak(completed: boolean): Promise<{current_streak_length: number, longest_streak_length: number}>; } From b35dfed39ead2efb1b6f9c491ffe8a814a9b0f91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:56:19 +0000 Subject: [PATCH 07/31] Fix date comparison in streak tracking and add migration doc Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- STREAKS_MIGRATION.md | 32 ++++++++++++++++++++++++++++++++ app/lib/db/db.js | 10 +++++++--- app/ui/game-session.js | 2 ++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 STREAKS_MIGRATION.md diff --git a/STREAKS_MIGRATION.md b/STREAKS_MIGRATION.md new file mode 100644 index 0000000..0c91fa2 --- /dev/null +++ b/STREAKS_MIGRATION.md @@ -0,0 +1,32 @@ +# Database Migration for Streaks Table + +This document describes the database schema changes needed for the streak tracking feature. + +## Streaks Table Schema + +The following SQL should be executed to create the `streaks` table in the database: + +```sql +CREATE TABLE IF NOT EXISTS streaks ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + current_streak_length INTEGER NOT NULL DEFAULT 0, + longest_streak_length INTEGER NOT NULL DEFAULT 0, + current_streak_last_date DATE NOT NULL +); + +CREATE INDEX idx_streaks_user_id ON streaks(user_id); +``` + +## Table Description + +- `user_id`: Foreign key reference to the users table. Primary key for this table. +- `current_streak_length`: The user's current consecutive days streak. +- `longest_streak_length`: The longest streak the user has ever achieved. +- `current_streak_last_date`: The date of the user's last completed puzzle. + +## Notes + +- The streak is updated when a user completes a daily puzzle. +- If a user completes a puzzle the day after their last completion, the streak increments by 1. +- If a user misses a day, the current streak resets to 1 (or 0 if they fail). +- The longest streak is preserved across streak resets. diff --git a/app/lib/db/db.js b/app/lib/db/db.js index 2519e5d..5aa5b99 100644 --- a/app/lib/db/db.js +++ b/app/lib/db/db.js @@ -95,16 +95,20 @@ export async function updateStreak(completed) { if (completed) { if (currentStreak.length > 0) { - const lastDate = currentStreak[0].current_streak_last_date; + const lastDateObj = new Date(currentStreak[0].current_streak_last_date); + const lastDateStr = lastDateObj.toISOString().split('T')[0]; + const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const yesterdayStr = yesterday.toISOString().split('T')[0]; + const todayStr = new Date().toISOString().split('T')[0]; + // Check if last completion was yesterday - if (lastDate === yesterdayStr) { + if (lastDateStr === yesterdayStr) { // Continue the streak newStreakLength = currentStreak[0].current_streak_length + 1; - } else if (lastDate === new Date().toISOString().split('T')[0]) { + } else if (lastDateStr === todayStr) { // Already completed today, don't update return { current_streak_length: currentStreak[0].current_streak_length, diff --git a/app/ui/game-session.js b/app/ui/game-session.js index e598853..1a85f77 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -113,6 +113,8 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel } setShowHowTo(true); setShowResumeModal(false); } + // Reset score submitted flag when date changes + setScoreSubmitted(false); }, [dateSeed, words]); useEffect(() => { From 7d306a54fb6eb2406bf5e97e27535daf9b53bfca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:57:30 +0000 Subject: [PATCH 08/31] Fix date calculation to avoid month boundary issues Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- app/lib/db/db.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/lib/db/db.js b/app/lib/db/db.js index 5aa5b99..abd8615 100644 --- a/app/lib/db/db.js +++ b/app/lib/db/db.js @@ -98,8 +98,7 @@ export async function updateStreak(completed) { const lastDateObj = new Date(currentStreak[0].current_streak_last_date); const lastDateStr = lastDateObj.toISOString().split('T')[0]; - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); const yesterdayStr = yesterday.toISOString().split('T')[0]; const todayStr = new Date().toISOString().split('T')[0]; From 1c3b3dc5c44391d86bd554571e0f5d89eee00f4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:59:57 +0000 Subject: [PATCH 09/31] Add implementation summary documentation Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- IMPLEMENTATION_SUMMARY.md | 111 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 IMPLEMENTATION_SUMMARY.md diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..94bf906 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,111 @@ +# Implementation Summary: Daily Score & Streak Tracking + +This document summarizes the implementation of daily score and streak tracking features for the Zimi puzzle game. + +## Changes Made + +### 1. Database Functions (`app/lib/db/db.js`) +- **`getStreak()`**: Retrieves the current user's streak information +- **`updateStreak(completed)`**: Updates user's streak based on puzzle completion + - Increments streak if completed yesterday's puzzle + - Resets streak to 1 if missed days (or 0 if failed) + - Maintains longest streak record + - Prevents duplicate updates on the same day + +### 2. API Route (`app/api/submit-score/route.js`) +- **POST `/api/submit-score`**: Endpoint for submitting scores + - Accepts `{ milliseconds: number | null }` in request body + - Calls `submitDailyScore()` to record the score + - Calls `updateStreak()` to update the user's streak + - Returns streak data on success + - Returns 401 if user not authenticated + +### 3. UI Components + +#### `app/ui/streak-popup.js` +- Non-intrusive popup showing user's current streak after completion +- Animated with motion library +- Displays streak length and encouragement message + +#### `app/ui/login-prompt-modal.js` +- Modal shown to non-authenticated users who complete puzzles +- Encourages users to sign in to track progress +- Explains benefits of tracking streaks + +### 4. Game Session Integration (`app/ui/game-session.js`) +- Imports `useSession` from NextAuth to check authentication status +- Submits score automatically when game is finished +- Shows appropriate modal based on authentication status: + - Authenticated + completed: Shows streak popup + - Unauthenticated + completed: Shows login prompt + - Failed (3 strikes): Still submits (resets streak to 0) + +### 5. Layout Update (`app/layout.js`) +- Re-enabled `` component in header +- Users can now sign in/out from the UI + +### 6. TypeScript Declarations (`types/app.d.ts`) +- Added type definitions for new database functions + +### 7. Documentation (`STREAKS_MIGRATION.md`) +- SQL schema for `streaks` table +- Field descriptions +- Notes on streak logic + +## Database Schema + +The `streaks` table must be created in the database: + +```sql +CREATE TABLE IF NOT EXISTS streaks ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + current_streak_length INTEGER NOT NULL DEFAULT 0, + longest_streak_length INTEGER NOT NULL DEFAULT 0, + current_streak_last_date DATE NOT NULL +); +``` + +## User Flow + +### For Authenticated Users: +1. User completes daily puzzle +2. Score automatically submitted to backend +3. Streak calculated and updated +4. Streak popup appears showing current streak +5. User can dismiss popup and continue + +### For Non-Authenticated Users: +1. User completes daily puzzle +2. Login prompt modal appears +3. User can sign in to start tracking or dismiss +4. If dismissed, can still share results + +## Key Features + +✅ Automatic score submission on game completion +✅ Streak tracking with proper date handling +✅ Non-intrusive UI notifications +✅ Encourages user engagement through login prompts +✅ Handles edge cases (duplicate submissions, date boundaries, failed games) +✅ No security vulnerabilities detected +✅ Maintains existing game functionality + +## Testing Notes + +- All existing unit tests pass +- Code review completed and feedback addressed +- Security scan completed with no issues found +- Manual testing recommended for: + - Completing puzzles while authenticated + - Completing puzzles while not authenticated + - Multi-day streak building + - Streak reset on missed days + - Failed game handling + +## Dependencies + +No new dependencies added. Uses existing packages: +- next-auth (authentication) +- postgres (database) +- @mui/material (UI components) +- motion (animations) From 31b2f72c23dc9c2352e6300a0f86ca03f2667b3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 03:01:25 +0000 Subject: [PATCH 10/31] Add user experience documentation Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- USER_EXPERIENCE.md | 130 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 USER_EXPERIENCE.md diff --git a/USER_EXPERIENCE.md b/USER_EXPERIENCE.md new file mode 100644 index 0000000..9b2245e --- /dev/null +++ b/USER_EXPERIENCE.md @@ -0,0 +1,130 @@ +# User Experience Guide: Streak Tracking Feature + +This document describes how users will experience the new streak tracking feature. + +## For New/Anonymous Users + +### Before Signing In +1. User plays and completes the daily puzzle +2. A modal appears titled "Track Your Progress! 📊" +3. The modal explains the benefits: + - 🔥 Start your streak + - 🏆 Compete on the leaderboard + - 📈 Track your progress over time +4. User can either: + - Click "Sign In to Start Tracking" → Redirects to Google OAuth + - Click "Maybe Later" → Closes modal, can continue playing + +### User Menu (Header) +- User icon in the top-right corner of the page +- Click to open menu with "Sign in" option +- Available at all times, not just after game completion + +## For Authenticated Users + +### After Signing In +1. User's name appears next to the user icon in the header +2. User menu now shows "Sign Out" option instead of "Sign in" + +### First Daily Puzzle Completion +1. User completes the puzzle (matches all tiles correctly) +2. Score is automatically submitted to the backend +3. A streak popup appears with: + - 🔥 Fire emoji (animated scale-in) + - "Streak Started!" message + - "1 Day" in purple text + - "Keep it up! Come back tomorrow to maintain your streak." +4. User clicks anywhere to dismiss the popup +5. Can then share results as before + +### Subsequent Daily Completions + +#### Consecutive Days (Yesterday was completed) +1. User completes today's puzzle +2. Streak popup shows: + - "Streak Updated!" message + - Current streak count (e.g., "3 Days") + - Encouragement message + +#### After Missing a Day +1. User completes a puzzle after missing one or more days +2. Streak resets to 1 +3. Longest streak is preserved in the database +4. Popup shows "Streak Started!" with "1 Day" + +#### Failed Puzzle (3 Strikes) +1. User gets 3 strikes and fails the puzzle +2. Current streak resets to 0 +3. No popup is shown (game failed) +4. Score is recorded as null in the database + +### Streak Persistence +- Streaks are tracked per user in the database +- Current streak length is shown after each completion +- Longest streak ever achieved is preserved +- Last completion date is tracked to determine continuity + +## UI Components + +### Streak Popup +- **Style**: Clean dialog with purple border, centered content +- **Animation**: Scale-in effect for engagement +- **Dismissal**: Click anywhere outside or on the popup +- **Timing**: Appears 500ms after score submission completes + +### Login Prompt Modal +- **Style**: Full-width dialog with purple border +- **Features**: + - Clear benefit explanations + - Highlighted feature list in purple box + - Two clear action buttons +- **Timing**: Appears 1000ms after puzzle completion (for anonymous users) + +### User Menu +- **Location**: Top-right corner of header +- **Always visible**: Available on all pages +- **States**: + - Not signed in: Shows user icon, menu has "Sign in" option + - Signed in: Shows user name + icon, menu has "Sign Out" option + +## Technical Details + +### Score Submission +- Automatically triggered when game is finished +- One submission per day (duplicates prevented) +- Includes completion time for successful attempts +- Includes null for failed attempts (3 strikes) + +### Streak Calculation +- Checked against yesterday's date +- Yesterday's completion → Increment streak +- Same day completion → No change (duplicate) +- Older than yesterday → Reset to 1 +- Failed game → Reset to 0 + +### Privacy & Security +- Only authenticated users can track streaks +- Scores tied to user account via session +- No sensitive data exposed in frontend +- API validates authentication on every request + +## Error Handling + +### Network Errors +- Failed submissions log errors to console +- User can still share results +- Score can be manually submitted later if needed + +### Database Errors +- Handled gracefully with error responses +- User sees normal game completion flow +- Errors logged on backend for monitoring + +## Future Enhancements (Not in this PR) + +Potential additions that could build on this feature: +- Display longest streak in user profile +- Streak recovery (grace period for missed days) +- Streak milestones and achievements +- Social sharing of streak achievements +- Streak leaderboard alongside time leaderboard From d06375039e093ab8b2ffd16c48cf0d968e74fa29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 03:03:02 +0000 Subject: [PATCH 11/31] Add comprehensive quick reference guide Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- QUICK_REFERENCE.md | 211 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 QUICK_REFERENCE.md diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md new file mode 100644 index 0000000..d9d7c2d --- /dev/null +++ b/QUICK_REFERENCE.md @@ -0,0 +1,211 @@ +# Quick Reference: Streak Tracking Feature + +## 🚀 Quick Start + +### For Developers + +1. **Database Setup** (Required before deployment) + ```sql + CREATE TABLE IF NOT EXISTS streaks ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + current_streak_length INTEGER NOT NULL DEFAULT 0, + longest_streak_length INTEGER NOT NULL DEFAULT 0, + current_streak_last_date DATE NOT NULL + ); + CREATE INDEX idx_streaks_user_id ON streaks(user_id); + ``` + +2. **Environment Variables** (Ensure these exist) + - `DATABASE_URL` or `POSTGRES_URL` - PostgreSQL connection string + - `GOOGLE_CLIENT_ID` - Google OAuth client ID + - `GOOGLE_CLIENT_SECRET` - Google OAuth client secret + - `NEXTAUTH_URL` - Application URL + - `NEXTAUTH_SECRET` - NextAuth secret key + +3. **Testing Locally** + ```bash + npm install + npm run dev + ``` + Visit `http://localhost:3000` and try completing a puzzle + +## 📋 Feature Overview + +### What Was Built + +| Component | Purpose | File | +|-----------|---------|------| +| Database Functions | Track and update user streaks | `app/lib/db/db.js` | +| API Endpoint | Submit scores and update streaks | `app/api/submit-score/route.js` | +| Streak Popup | Show streak after completion | `app/ui/streak-popup.js` | +| Login Prompt | Encourage login for tracking | `app/ui/login-prompt-modal.js` | +| Game Integration | Auto-submit scores | `app/ui/game-session.js` | +| User Menu | Login/logout interface | `app/layout.js` | + +### How It Works + +``` +User completes puzzle + ↓ +Is user authenticated? + ↓ ↓ + YES NO + ↓ ↓ +Submit score Show login prompt + ↓ +Update streak + ↓ +Show streak popup +``` + +## 🧪 Testing Scenarios + +### Manual Test Cases + +1. **Anonymous User Completes Puzzle** + - Expected: Login prompt modal appears + - Expected: Can dismiss and continue + - Expected: Can click "Sign In" to authenticate + +2. **New User First Completion** + - Expected: Streak popup shows "Streak Started! 1 Day" + - Expected: Popup is dismissable + - Expected: Score appears on leaderboard + +3. **User Completes on Consecutive Days** + - Day 1: Complete puzzle → "1 Day" + - Day 2: Complete puzzle → "2 Days" + - Day 3: Complete puzzle → "3 Days" + - Expected: Streak increments each day + +4. **User Misses a Day** + - Day 1: Complete puzzle → "1 Day" + - Day 2: Skip + - Day 3: Complete puzzle → "1 Day" (reset) + - Expected: Streak resets but longest is preserved + +5. **User Fails Puzzle (3 Strikes)** + - Complete with 3 strikes + - Expected: No popup shown + - Expected: Streak resets to 0 + - Expected: Score shows as failed in leaderboard + +6. **Duplicate Completion Same Day** + - Complete puzzle once + - Try to complete again (refresh page, etc.) + - Expected: Streak doesn't change + - Expected: No duplicate submissions + +## 🐛 Common Issues + +### Streak Not Updating +- Check database has streaks table +- Verify user is authenticated +- Check console for API errors +- Ensure DATABASE_URL is set correctly + +### Login Not Working +- Verify Google OAuth credentials +- Check NEXTAUTH_URL matches your domain +- Ensure NEXTAUTH_SECRET is set +- Check NextAuth configuration + +### Popup Not Appearing +- Check browser console for errors +- Verify motion library is installed +- Check if game completion is detected +- Test with different browsers + +## 📚 Documentation Files + +- **STREAKS_MIGRATION.md** - Database schema and migration SQL +- **IMPLEMENTATION_SUMMARY.md** - Technical implementation details +- **USER_EXPERIENCE.md** - Complete user experience guide +- **QUICK_REFERENCE.md** - This file + +## 🔍 Code Locations + +### Backend +- **Streak Logic**: `app/lib/db/db.js` lines 47-158 +- **API Route**: `app/api/submit-score/route.js` +- **Auth Config**: `app/api/auth/[...nextauth]/route.js` + +### Frontend +- **Game Completion**: `app/ui/game-session.js` lines 124-157 +- **Streak Popup**: `app/ui/streak-popup.js` +- **Login Modal**: `app/ui/login-prompt-modal.js` +- **User Menu**: `app/ui/user-menu.js` + +### Types +- **Type Definitions**: `types/app.d.ts` lines 28-32 + +## 🎨 UI Components + +### Streak Popup +- Appears 500ms after score submission +- Animated scale-in effect +- Fire emoji 🔥 +- Shows current streak count +- Dismissable by clicking anywhere + +### Login Prompt Modal +- Appears 1000ms after puzzle completion (anonymous users) +- Lists benefits of signing in +- Two buttons: "Sign In" and "Maybe Later" +- Purple-themed to match app design + +### User Menu +- Always visible in top-right header +- Shows user name when authenticated +- Click to access sign in/out + +## 💡 Tips for Customization + +### Change Popup Timing +```javascript +// In app/ui/game-session.js +setTimeout(() => setShowStreakPopup(true), 500); // Change 500 to desired ms +setTimeout(() => setShowLoginPrompt(true), 1000); // Change 1000 to desired ms +``` + +### Modify Streak Colors +```javascript +// In app/ui/streak-popup.js +color: '#9333ea' // Change to any color +border: '3px solid #9333ea' // Change border color +``` + +### Adjust Streak Logic +```javascript +// In app/lib/db/db.js, line 96 +// Current: Resets streak to 1 if not consecutive +// To make it more forgiving, you could add grace periods +``` + +## 📊 Database Schema + +```sql +-- Main tables involved +users (id, name, email, password) +daily_scores (user_id, date, milliseconds) +streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) +``` + +## 🔐 Security + +- ✅ Authentication required for score submission +- ✅ Server-side validation of user sessions +- ✅ SQL injection prevention via parameterized queries +- ✅ No sensitive data exposed in frontend +- ✅ 0 vulnerabilities found in security scan + +## 🚢 Deployment Checklist + +- [ ] Create streaks table in production database +- [ ] Verify environment variables are set +- [ ] Test authentication flow works +- [ ] Verify score submission works +- [ ] Test streak calculation with real dates +- [ ] Monitor error logs for issues +- [ ] Test on mobile devices +- [ ] Verify popup animations work smoothly From 8256774ed932e9f4210ab15321fef883dba1a21d Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Thu, 18 Dec 2025 10:48:18 -0700 Subject: [PATCH 12/31] add debug search params for preventing localStorage interaction --- app/page.js | 16 +++++++++++++--- app/ui/game-session.js | 8 ++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/page.js b/app/page.js index 9d47916..b94fc19 100644 --- a/app/page.js +++ b/app/page.js @@ -7,8 +7,10 @@ import { currentDateStr, mkDateStr, sample, getDailyDifficulty } from "app/lib/u export default async function Page(props) { const searchParams = await props.searchParams; - const devMode = searchParams?.dev === 'true' - + const devMode = 'dev' in searchParams + const preventStorage = devMode && 'nostore' in searchParams + const preventRestore = devMode && 'norestore' in searchParams + // Use date from search params if provided, otherwise use current date let dateSeed = currentDateStr() if (devMode && searchParams?.date) { @@ -56,7 +58,15 @@ export default async function Page(props) { return (
- +
); } diff --git a/app/ui/game-session.js b/app/ui/game-session.js index 1a85f77..733de86 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -79,7 +79,7 @@ function retrieveLocalState(dateStr, currentWords) { } } -export default function GameSession({ words, shuffledChars, dateSeed, hskLevel }) { +export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, preventStorage, preventRestore }) { const [ currentGameState, dispatch ] = useReducer(gridReducer, initialGridState(shuffledChars)); const { data: session, status } = useSession(); @@ -103,7 +103,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel } // upon mounting, check for saved game state in localStorage useEffect(() => { - const savedGame = retrieveLocalState(dateSeed, words); + const savedGame = preventRestore ? null : retrieveLocalState(dateSeed, words); if (savedGame) { dispatch({ type: 'reset', state: savedGame.game }); stopWatch.reset(new Date(Date.now() + savedGame.milliseconds), false); @@ -120,7 +120,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel } useEffect(() => { if (gameIsFinished(currentGameState)) stopWatch.pause(); // only save if the game was actually played - if (gameBegun) saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); + if (gameBegun && !preventStorage) saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); }, [currentGameState, dateSeed, words]); // Submit score when game is finished @@ -161,7 +161,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel } // set up callback to run beforeunload to save game state (save the user's time if they leave mid-game) useEffect(() => { const handleBeforeUnload = (e) => { - if (gameBegun && !gameIsFinished(currentGameState)) { + if (gameBegun && !gameIsFinished(currentGameState) && !preventStorage) { saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); } }; From def408a9d732da0718feb6dd4c9b37dcc46c2f47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:04:48 +0000 Subject: [PATCH 13/31] Submit score after unauthenticated user logs in Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- app/ui/game-session.js | 66 +++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/app/ui/game-session.js b/app/ui/game-session.js index 733de86..4692246 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -90,6 +90,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [streakData, setStreakData] = useState(null); const [scoreSubmitted, setScoreSubmitted] = useState(false); + const [pendingScore, setPendingScore] = useState(null); // Store score for unauthenticated users // Initialize stopwatch with saved time if resuming const stopWatch = useStopwatch({ @@ -101,6 +102,28 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, return stopWatch.totalSeconds * 1000 + stopWatch.milliseconds; } + // Function to submit score to backend + const submitScore = (milliseconds) => { + fetch('/api/submit-score', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ milliseconds }), + }) + .then(res => res.json()) + .then(data => { + if (data.success && milliseconds !== null) { + // Show streak popup + setStreakData(data.streak); + setTimeout(() => setShowStreakPopup(true), 500); + } + }) + .catch(error => { + console.error('Error submitting score:', error); + }); + }; + // upon mounting, check for saved game state in localStorage useEffect(() => { const savedGame = preventRestore ? null : retrieveLocalState(dateSeed, words); @@ -127,37 +150,32 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, useEffect(() => { if (gameIsFinished(currentGameState) && gameBegun && !scoreSubmitted) { setScoreSubmitted(true); + const completed = gameIsCompleted(currentGameState); + const milliseconds = completed ? getMilliseconds() : null; if (status === 'authenticated') { - // User is logged in, submit score - const completed = gameIsCompleted(currentGameState); - const milliseconds = completed ? getMilliseconds() : null; - - fetch('/api/submit-score', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ milliseconds }), - }) - .then(res => res.json()) - .then(data => { - if (data.success && completed) { - // Show streak popup - setStreakData(data.streak); - setTimeout(() => setShowStreakPopup(true), 500); - } - }) - .catch(error => { - console.error('Error submitting score:', error); - }); - } else if (status === 'unauthenticated' && gameIsCompleted(currentGameState)) { - // User is not logged in and completed the game, show login prompt + // User is logged in, submit score immediately + submitScore(milliseconds); + } else if (status === 'unauthenticated' && completed) { + // User is not logged in and completed the game + // Store the score to submit after login + setPendingScore(milliseconds); + // Show login prompt setTimeout(() => setShowLoginPrompt(true), 1000); } } }, [currentGameState, gameBegun, scoreSubmitted, status]); + // Submit pending score when user authenticates + useEffect(() => { + if (status === 'authenticated' && pendingScore !== null) { + console.log('User authenticated, submitting pending score:', pendingScore); + submitScore(pendingScore); + setPendingScore(null); // Clear pending score after submission + setShowLoginPrompt(false); // Close login prompt if still open + } + }, [status, pendingScore]); + // set up callback to run beforeunload to save game state (save the user's time if they leave mid-game) useEffect(() => { const handleBeforeUnload = (e) => { From 43507c6f5c412a2e30ccd2b21d61c3688ae7194b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:06:21 +0000 Subject: [PATCH 14/31] Improve error handling for pending score submission Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- app/ui/game-session.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/ui/game-session.js b/app/ui/game-session.js index 4692246..d343f04 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -104,7 +104,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, // Function to submit score to backend const submitScore = (milliseconds) => { - fetch('/api/submit-score', { + return fetch('/api/submit-score', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -118,9 +118,11 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, setStreakData(data.streak); setTimeout(() => setShowStreakPopup(true), 500); } + return data; }) .catch(error => { - console.error('Error submitting score:', error); + console.error('Error submitting score:', error.message || error); + throw error; }); }; @@ -169,10 +171,15 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, // Submit pending score when user authenticates useEffect(() => { if (status === 'authenticated' && pendingScore !== null) { - console.log('User authenticated, submitting pending score:', pendingScore); - submitScore(pendingScore); - setPendingScore(null); // Clear pending score after submission - setShowLoginPrompt(false); // Close login prompt if still open + submitScore(pendingScore) + .then(() => { + setPendingScore(null); // Clear pending score only after successful submission + setShowLoginPrompt(false); // Close login prompt if still open + }) + .catch(error => { + console.error('Failed to submit pending score after authentication:', error); + // Keep pending score for potential retry + }); } }, [status, pendingScore]); From c0ffe2e19add1be9d3cc330e9364a3f86bd12fb6 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Thu, 18 Dec 2025 11:23:05 -0700 Subject: [PATCH 15/31] lift out getMilliseconds --- app/ui/game-session.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/ui/game-session.js b/app/ui/game-session.js index d343f04..2c429da 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -79,6 +79,10 @@ function retrieveLocalState(dateStr, currentWords) { } } +function timerTotalMilliseconds(stopWatch) { + return stopWatch.totalSeconds * 1000 + stopWatch.milliseconds; +} + export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, preventStorage, preventRestore }) { const [ currentGameState, dispatch ] = useReducer(gridReducer, initialGridState(shuffledChars)); const { data: session, status } = useSession(); @@ -98,10 +102,6 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, interval: 20, }); - function getMilliseconds() { - return stopWatch.totalSeconds * 1000 + stopWatch.milliseconds; - } - // Function to submit score to backend const submitScore = (milliseconds) => { return fetch('/api/submit-score', { @@ -145,7 +145,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, useEffect(() => { if (gameIsFinished(currentGameState)) stopWatch.pause(); // only save if the game was actually played - if (gameBegun && !preventStorage) saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); + if (gameBegun && !preventStorage) saveLocalState(currentGameState, timerTotalMilliseconds(stopWatch), dateSeed, words); }, [currentGameState, dateSeed, words]); // Submit score when game is finished @@ -153,7 +153,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, if (gameIsFinished(currentGameState) && gameBegun && !scoreSubmitted) { setScoreSubmitted(true); const completed = gameIsCompleted(currentGameState); - const milliseconds = completed ? getMilliseconds() : null; + const milliseconds = completed ? timerTotalMilliseconds(stopWatch) : null; if (status === 'authenticated') { // User is logged in, submit score immediately @@ -187,7 +187,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, useEffect(() => { const handleBeforeUnload = (e) => { if (gameBegun && !gameIsFinished(currentGameState) && !preventStorage) { - saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); + saveLocalState(currentGameState, timerTotalMilliseconds(stopWatch), dateSeed, words); } }; @@ -254,7 +254,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, onClick={() => { shareOnMobile({ title: 'My Daily Zimi', - text: makeShareableResultString(currentGameState, getMilliseconds(), dateSeed), + text: makeShareableResultString(currentGameState, timerTotalMilliseconds(stopWatch), dateSeed), url: "https://zimi-ten.vercel.app/" }, console.error) }} From 16b0fcc812dc80294f3872537874ef2d3e7661b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:28:39 +0000 Subject: [PATCH 16/31] Use localStorage instead of React state for pending score submission Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- app/ui/game-session.js | 50 ++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/app/ui/game-session.js b/app/ui/game-session.js index d343f04..850d4c0 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -39,10 +39,11 @@ const makeShareableResultString = (gameState, milliseconds, dateSeed) => { * @param {*} milliseconds * @param {*} dateSeed * @param {*} words - array of words for this game + * @param {*} scoreSubmitted - whether the score has been submitted */ -function saveLocalState(gameState, milliseconds, dateSeed, words) { +function saveLocalState(gameState, milliseconds, dateSeed, words, scoreSubmitted = false) { console.log('Saving game state to localStorage...', gameState, milliseconds, dateSeed); - const objectToStore = { game: gameState, milliseconds, date: dateSeed, words }; + const objectToStore = { game: gameState, milliseconds, date: dateSeed, words, scoreSubmitted }; try { localStorage.setItem("zimi-save", JSON.stringify(objectToStore)); } catch (e) { @@ -54,7 +55,7 @@ function saveLocalState(gameState, milliseconds, dateSeed, words) { * * @param {string} dateSeed retrieve last saved game state for this date * @param {Array} currentWords - the word list for the current game - * @returns { game: grid state, milliseconds: number } | null + * @returns { game: grid state, milliseconds: number, scoreSubmitted: boolean } | null */ function retrieveLocalState(dateStr, currentWords) { try { @@ -90,7 +91,6 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [streakData, setStreakData] = useState(null); const [scoreSubmitted, setScoreSubmitted] = useState(false); - const [pendingScore, setPendingScore] = useState(null); // Store score for unauthenticated users // Initialize stopwatch with saved time if resuming const stopWatch = useStopwatch({ @@ -113,10 +113,18 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, }) .then(res => res.json()) .then(data => { - if (data.success && milliseconds !== null) { - // Show streak popup - setStreakData(data.streak); - setTimeout(() => setShowStreakPopup(true), 500); + if (data.success) { + // Mark score as submitted in localStorage + const savedGame = retrieveLocalState(dateSeed, words); + if (savedGame && !preventStorage) { + saveLocalState(savedGame.game, savedGame.milliseconds, dateSeed, words, true); + } + + if (milliseconds !== null) { + // Show streak popup + setStreakData(data.streak); + setTimeout(() => setShowStreakPopup(true), 500); + } } return data; }) @@ -134,13 +142,19 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, stopWatch.reset(new Date(Date.now() + savedGame.milliseconds), false); setShowHowTo(false); setShowResumeModal(true); + + // If user is authenticated and game is completed but score not submitted, submit it + if (status === 'authenticated' && gameIsCompleted(savedGame.game) && !savedGame.scoreSubmitted) { + console.log('Found unsubmitted completed game, submitting score...'); + submitScore(savedGame.milliseconds); + } } else { setShowHowTo(true); setShowResumeModal(false); } // Reset score submitted flag when date changes setScoreSubmitted(false); - }, [dateSeed, words]); + }, [dateSeed, words, status]); useEffect(() => { if (gameIsFinished(currentGameState)) stopWatch.pause(); @@ -160,29 +174,13 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, submitScore(milliseconds); } else if (status === 'unauthenticated' && completed) { // User is not logged in and completed the game - // Store the score to submit after login - setPendingScore(milliseconds); + // Score is already saved to localStorage by another useEffect // Show login prompt setTimeout(() => setShowLoginPrompt(true), 1000); } } }, [currentGameState, gameBegun, scoreSubmitted, status]); - // Submit pending score when user authenticates - useEffect(() => { - if (status === 'authenticated' && pendingScore !== null) { - submitScore(pendingScore) - .then(() => { - setPendingScore(null); // Clear pending score only after successful submission - setShowLoginPrompt(false); // Close login prompt if still open - }) - .catch(error => { - console.error('Failed to submit pending score after authentication:', error); - // Keep pending score for potential retry - }); - } - }, [status, pendingScore]); - // set up callback to run beforeunload to save game state (save the user's time if they leave mid-game) useEffect(() => { const handleBeforeUnload = (e) => { From 6696cec14aa032fd48f7ae60e51fe2d6f9a65714 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:30:06 +0000 Subject: [PATCH 17/31] Add explicit scoreSubmitted parameter to all saveLocalState calls Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- app/ui/game-session.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/ui/game-session.js b/app/ui/game-session.js index 850d4c0..1881ed8 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -159,7 +159,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, useEffect(() => { if (gameIsFinished(currentGameState)) stopWatch.pause(); // only save if the game was actually played - if (gameBegun && !preventStorage) saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); + if (gameBegun && !preventStorage) saveLocalState(currentGameState, getMilliseconds(), dateSeed, words, scoreSubmitted); }, [currentGameState, dateSeed, words]); // Submit score when game is finished @@ -185,7 +185,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, useEffect(() => { const handleBeforeUnload = (e) => { if (gameBegun && !gameIsFinished(currentGameState) && !preventStorage) { - saveLocalState(currentGameState, getMilliseconds(), dateSeed, words); + saveLocalState(currentGameState, getMilliseconds(), dateSeed, words, false); } }; From 80a999a37b87bf83cc43d31a1031804a9592ee12 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Thu, 18 Dec 2025 15:53:29 -0700 Subject: [PATCH 18/31] simplified submit scores and update streak logic. smh copilot --- app/api/submit-score/route.js | 8 +- app/layout.js | 73 +++++++--- app/lib/db/db.js | 251 +++++++++------------------------- app/lib/db/seed-test-db.js | 96 +++++++++++++ app/providers.js | 2 +- app/ui/game-session.js | 101 +++++++++----- app/ui/timer.js | 10 +- app/ui/user-menu.js | 3 +- 8 files changed, 289 insertions(+), 255 deletions(-) create mode 100644 app/lib/db/seed-test-db.js diff --git a/app/api/submit-score/route.js b/app/api/submit-score/route.js index 2619573..31073dc 100644 --- a/app/api/submit-score/route.js +++ b/app/api/submit-score/route.js @@ -3,14 +3,14 @@ import { NextResponse } from 'next/server'; export async function POST(request) { try { - const { milliseconds } = await request.json(); - + const { milliseconds, date } = await request.json(); + console.log('Received score submission in POST:', milliseconds); // Submit the daily score - await submitDailyScore(milliseconds); + await submitDailyScore(milliseconds, date); // Update the streak (completed if milliseconds is not null) const completed = milliseconds !== null; - const streakData = await updateStreak(completed); + const streakData = await updateStreak(completed, date); return NextResponse.json({ success: true, diff --git a/app/layout.js b/app/layout.js index a3079fe..edfaefa 100644 --- a/app/layout.js +++ b/app/layout.js @@ -5,10 +5,39 @@ import UserMenu from 'app/ui/user-menu'; import HelpButton from 'app/ui/help-button'; import { DailyTimer } from 'app/ui/timer'; import DatePicker from 'app/ui/date-picker'; -import { mahjongFeltPurple, mahjongTileFace } from 'app/ui/styles'; +import { mahjongTileFace } from 'app/ui/styles'; +import { getStreakInfo } from 'app/lib/db/db'; +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; -const styleClass = { +const appBarStyle = { backgroundColor: mahjongTileFace, + boxShadow: 3, + borderBottom: '4px solid #a855f7', +} + +async function StreakBanner() { + const streakInfo = await getStreakInfo(); + + if (!streakInfo || streakInfo.streak === 0) { + return null; + } + + const fireCount = Math.min(streakInfo.streak, 10); + const fires = Array(fireCount).fill('🔥'); + + return ( +
+
+ {fires.map((_, index) => ( + 🔥 + ))} +
+ {streakInfo.streak} +
+ ); } export default function RootLayout({ children }) { @@ -19,30 +48,32 @@ export default function RootLayout({ children }) { {/* */} -
- -
-
-
-
-

ZiMi 字谜!

-
-
- - - - -
-
-
-
+ +
+ + + + + ZiMi 字谜! + + + + + + + + + + + + {/* Main content */}
{children}
- -
+
+ ) diff --git a/app/lib/db/db.js b/app/lib/db/db.js index abd8615..04087fa 100644 --- a/app/lib/db/db.js +++ b/app/lib/db/db.js @@ -3,6 +3,7 @@ import { authOptions } from 'app/api/auth/[...nextauth]/route'; import bcrypt from 'bcrypt'; import { getServerSession } from 'next-auth'; import postgres from 'postgres'; +import { currentDateStr, mkDateStr } from '../utils'; const sql = postgres(process.env.POSTGRES_URL, { ssl: 'require' }); @@ -19,35 +20,40 @@ export async function getTopScores(limit = 10) { /** * Submit how long the user took to finish today's game. If the given time is null, - * it indicates the user did got three strikes and failed to complete the game. - * @param {number} milliseconds + * it indicates the user got three strikes and failed to complete the game. + * @param {number | null} milliseconds - time taken to complete the game in milliseconds, null if user failed + * @param {string} date - date string in YYYY-MM-DD format * @returns */ -export async function submitDailyScore(milliseconds) { +export async function submitDailyScore(milliseconds, date) { const session = await getServerSession(authOptions); if (session == null) { - throw new Error('User not authenticated'); + throw new Error('Unauthenticated user tried to submit score'); } + console.log(`Submitting daily score for ${session.user.email}: ${milliseconds} ms on ${date}`); + const result = await sql` INSERT INTO daily_scores (user_id, date, milliseconds) - VALUES ((select id from users where email = ${session.user.email}), CURRENT_DATE, ${milliseconds}) + VALUES ((select id from users where email = ${session.user.email}), ${date}, ${milliseconds}) ON CONFLICT (user_id, date) DO NOTHING RETURNING *; `; + console.log(`Daily score submission result for ${session.user.email}:`, result); + if (milliseconds !== null) - console.log(`${session.user.email} submitted a score of ${milliseconds} ms on ${new Date().toISOString().split('T')[0]}`); - else console.log(`${session.user.email} failed to complete today's game on ${new Date().toISOString().split('T')[0]}`); - return result + console.log(`${session.user.email} submitted a score of ${milliseconds} ms on ${date}`); + else console.log(`${session.user.email} failed to complete today's game on ${date}`); + return result.length === 1 } /** * Get the user's current streak information - * @returns {Promise<{current_streak_length: number, longest_streak_length: number, current_streak_last_date: string} | null>} + * @returns {Promise<{currentStreak: number, longestStreak: number} | null>} */ -export async function getStreak() { +export async function getStreakInfo() { const session = await getServerSession(authOptions); if (session == null) { @@ -60,199 +66,68 @@ export async function getStreak() { WHERE user_id = (select id from users where email = ${session.user.email}) `; - return result.length > 0 ? result[0] : null; + if (result.length !== 1) { + throw new Error('Error fetching streak for user ' + session.user.email); + } else { + return { + streak: result[0].current_streak_last_date === currentDateStr() ? 0 : result[0].current_streak_length, + longestStreak: result[0].longest_streak_length + } + } + } /** * Update the user's streak after completing today's puzzle * @param {boolean} completed - whether the user completed the puzzle (true) or failed (false) + * @param {string} date - date string in YYYY-MM-DD format * @returns {Promise<{current_streak_length: number, longest_streak_length: number}>} */ -export async function updateStreak(completed) { +export async function updateStreak(completed, date) { const session = await getServerSession(authOptions); if (session == null) { - throw new Error('User not authenticated'); - } - - const userId = await sql`select id from users where email = ${session.user.email}`; - - if (userId.length === 0) { - throw new Error('User not found'); + throw new Error('Unauthenticated user tried to update streak'); } - const userIdValue = userId[0].id; + // make the string for yesterday's date + const [year, month, day] = date.split('-').map(Number); + const dateObj = new Date(Date.UTC(year, month - 1, day)); + const yesterdayObj = new Date(dateObj); + yesterdayObj.setUTCDate(yesterdayObj.getUTCDate() - 1); + const yesterdayStr = mkDateStr(yesterdayObj); - // Get current streak data - const currentStreak = await sql` - SELECT current_streak_length, longest_streak_length, current_streak_last_date - FROM streaks - WHERE user_id = ${userIdValue} + // behold my SQL wizardry + // jk AI helped me write this + // it updates the user's streak based on whether they completed today's puzzle + const result = await sql` + INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) + VALUES ( + (SELECT id FROM users WHERE email = ${session.user.email}), + CASE WHEN ${completed} THEN 1 ELSE 0 END, + CASE WHEN ${completed} THEN 1 ELSE 0 END, + CASE WHEN ${completed} THEN ${date}::date ELSE NULL END + ) + ON CONFLICT (user_id) DO UPDATE SET + current_streak_length = CASE + WHEN ${completed} AND streaks.current_streak_last_date = ${yesterdayStr}::date THEN streaks.current_streak_length + 1 + WHEN ${completed} THEN 1 + ELSE 0 + END, + longest_streak_length = GREATEST( + streaks.longest_streak_length, + CASE + WHEN ${completed} AND streaks.current_streak_last_date = ${yesterdayStr}::date THEN streaks.current_streak_length + 1 + WHEN ${completed} THEN 1 + ELSE 0 + END + ), + current_streak_last_date = CASE WHEN ${completed} THEN ${date}::date ELSE streaks.current_streak_last_date END + RETURNING current_streak_length, longest_streak_length; `; - let newStreakLength = 1; - let longestStreak = 1; + console.log(`Updated streak for ${session.user.email}:`, result[0]); - if (completed) { - if (currentStreak.length > 0) { - const lastDateObj = new Date(currentStreak[0].current_streak_last_date); - const lastDateStr = lastDateObj.toISOString().split('T')[0]; - - const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); - const yesterdayStr = yesterday.toISOString().split('T')[0]; - - const todayStr = new Date().toISOString().split('T')[0]; - - // Check if last completion was yesterday - if (lastDateStr === yesterdayStr) { - // Continue the streak - newStreakLength = currentStreak[0].current_streak_length + 1; - } else if (lastDateStr === todayStr) { - // Already completed today, don't update - return { - current_streak_length: currentStreak[0].current_streak_length, - longest_streak_length: currentStreak[0].longest_streak_length, - }; - } - // If last date is neither yesterday nor today, streak resets to 1 - - longestStreak = Math.max(newStreakLength, currentStreak[0].longest_streak_length); - } - - // Update or insert streak - const result = await sql` - INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) - VALUES (${userIdValue}, ${newStreakLength}, ${longestStreak}, CURRENT_DATE) - ON CONFLICT (user_id) - DO UPDATE SET - current_streak_length = ${newStreakLength}, - longest_streak_length = ${longestStreak}, - current_streak_last_date = CURRENT_DATE - RETURNING current_streak_length, longest_streak_length; - `; - - console.log(`${session.user.email} streak updated: ${newStreakLength} (longest: ${longestStreak})`); - return result[0]; - } else { - // Failed to complete - reset streak to 0 - if (currentStreak.length > 0) { - await sql` - UPDATE streaks - SET current_streak_length = 0, - current_streak_last_date = CURRENT_DATE - WHERE user_id = ${userIdValue} - `; - return { - current_streak_length: 0, - longest_streak_length: currentStreak[0].longest_streak_length, - }; - } else { - // No existing streak record, insert with 0 - await sql` - INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) - VALUES (${userIdValue}, 0, 0, CURRENT_DATE) - `; - return { - current_streak_length: 0, - longest_streak_length: 0, - }; - } - } + return result[0]; } -// async function seedUsers() { -// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; -// await sql` -// CREATE TABLE IF NOT EXISTS users ( -// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, -// name VARCHAR(255) NOT NULL, -// email TEXT NOT NULL UNIQUE, -// password TEXT NOT NULL -// ); -// `; - -// const insertedUsers = await Promise.all( -// users.map(async (user) => { -// const hashedPassword = await bcrypt.hash(user.password, 10); -// return sql` -// INSERT INTO users (id, name, email, password) -// VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword}) -// ON CONFLICT (id) DO NOTHING; -// `; -// }), -// ); - -// return insertedUsers; -// } - -// async function seedInvoices() { -// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; - -// await sql` -// CREATE TABLE IF NOT EXISTS invoices ( -// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, -// customer_id UUID NOT NULL, -// amount INT NOT NULL, -// status VARCHAR(255) NOT NULL, -// date DATE NOT NULL -// ); -// `; - -// const insertedInvoices = await Promise.all( -// invoices.map( -// (invoice) => sql` -// INSERT INTO invoices (customer_id, amount, status, date) -// VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date}) -// ON CONFLICT (id) DO NOTHING; -// `, -// ), -// ); - -// return insertedInvoices; -// } - -// async function seedCustomers() { -// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; - -// await sql` -// CREATE TABLE IF NOT EXISTS customers ( -// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, -// name VARCHAR(255) NOT NULL, -// email VARCHAR(255) NOT NULL, -// image_url VARCHAR(255) NOT NULL -// ); -// `; - -// const insertedCustomers = await Promise.all( -// customers.map( -// (customer) => sql` -// INSERT INTO customers (id, name, email, image_url) -// VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url}) -// ON CONFLICT (id) DO NOTHING; -// `, -// ), -// ); - -// return insertedCustomers; -// } - -// async function seedRevenue() { -// await sql` -// CREATE TABLE IF NOT EXISTS revenue ( -// month VARCHAR(4) NOT NULL UNIQUE, -// revenue INT NOT NULL -// ); -// `; - -// const insertedRevenue = await Promise.all( -// revenue.map( -// (rev) => sql` -// INSERT INTO revenue (month, revenue) -// VALUES (${rev.month}, ${rev.revenue}) -// ON CONFLICT (month) DO NOTHING; -// `, -// ), -// ); - -// return insertedRevenue; -// } \ No newline at end of file diff --git a/app/lib/db/seed-test-db.js b/app/lib/db/seed-test-db.js new file mode 100644 index 0000000..2925fca --- /dev/null +++ b/app/lib/db/seed-test-db.js @@ -0,0 +1,96 @@ +// async function seedUsers() { +// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; +// await sql` +// CREATE TABLE IF NOT EXISTS users ( +// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, +// name VARCHAR(255) NOT NULL, +// email TEXT NOT NULL UNIQUE, +// password TEXT NOT NULL +// ); +// `; + +// const insertedUsers = await Promise.all( +// users.map(async (user) => { +// const hashedPassword = await bcrypt.hash(user.password, 10); +// return sql` +// INSERT INTO users (id, name, email, password) +// VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword}) +// ON CONFLICT (id) DO NOTHING; +// `; +// }), +// ); + +// return insertedUsers; +// } + +// async function seedInvoices() { +// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; + +// await sql` +// CREATE TABLE IF NOT EXISTS invoices ( +// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, +// customer_id UUID NOT NULL, +// amount INT NOT NULL, +// status VARCHAR(255) NOT NULL, +// date DATE NOT NULL +// ); +// `; + +// const insertedInvoices = await Promise.all( +// invoices.map( +// (invoice) => sql` +// INSERT INTO invoices (customer_id, amount, status, date) +// VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date}) +// ON CONFLICT (id) DO NOTHING; +// `, +// ), +// ); + +// return insertedInvoices; +// } + +// async function seedCustomers() { +// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; + +// await sql` +// CREATE TABLE IF NOT EXISTS customers ( +// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, +// name VARCHAR(255) NOT NULL, +// email VARCHAR(255) NOT NULL, +// image_url VARCHAR(255) NOT NULL +// ); +// `; + +// const insertedCustomers = await Promise.all( +// customers.map( +// (customer) => sql` +// INSERT INTO customers (id, name, email, image_url) +// VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url}) +// ON CONFLICT (id) DO NOTHING; +// `, +// ), +// ); + +// return insertedCustomers; +// } + +// async function seedRevenue() { +// await sql` +// CREATE TABLE IF NOT EXISTS revenue ( +// month VARCHAR(4) NOT NULL UNIQUE, +// revenue INT NOT NULL +// ); +// `; + +// const insertedRevenue = await Promise.all( +// revenue.map( +// (rev) => sql` +// INSERT INTO revenue (month, revenue) +// VALUES (${rev.month}, ${rev.revenue}) +// ON CONFLICT (month) DO NOTHING; +// `, +// ), +// ); + +// return insertedRevenue; +// } \ No newline at end of file diff --git a/app/providers.js b/app/providers.js index 5c738aa..303a2c3 100644 --- a/app/providers.js +++ b/app/providers.js @@ -2,7 +2,7 @@ import { SessionProvider } from "next-auth/react" // import { CacheProvider } from '@emotion/react'; import { ThemeProvider } from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; +// import CssBaseline from '@mui/material/CssBaseline'; import theme from './theme'; //const clientSideEmotionCache = createEmotionCache(); diff --git a/app/ui/game-session.js b/app/ui/game-session.js index 5b84959..a3aa4c3 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -39,11 +39,10 @@ const makeShareableResultString = (gameState, milliseconds, dateSeed) => { * @param {*} milliseconds * @param {*} dateSeed * @param {*} words - array of words for this game - * @param {*} scoreSubmitted - whether the score has been submitted */ -function saveLocalState(gameState, milliseconds, dateSeed, words, scoreSubmitted = false) { +function saveLocalState(gameState, milliseconds, dateSeed, words) { console.log('Saving game state to localStorage...', gameState, milliseconds, dateSeed); - const objectToStore = { game: gameState, milliseconds, date: dateSeed, words, scoreSubmitted }; + const objectToStore = { game: gameState, milliseconds, date: dateSeed, words }; try { localStorage.setItem("zimi-save", JSON.stringify(objectToStore)); } catch (e) { @@ -51,11 +50,37 @@ function saveLocalState(gameState, milliseconds, dateSeed, words, scoreSubmitted } } +/** + * Flag in localStorage that score has been submitted for this date + * @param {string} dateSeed + */ +function rememberScoreSubmitted(dateSeed) { + try { + localStorage.setItem("submitted", dateSeed); + } catch (e) { + console.error('Failed to remember score submission:', e); + } +} + +/** + * Check if score has already been submitted for this date + * @param {string} dateSeed + * @returns {boolean} + */ +function hasSubmittedScore(dateSeed) { + try { + return localStorage.getItem("submitted") === dateSeed; + } catch (e) { + console.error('Failed to check if score has been submitted:', e); + return false; + } +} + /** * * @param {string} dateSeed retrieve last saved game state for this date * @param {Array} currentWords - the word list for the current game - * @returns { game: grid state, milliseconds: number, scoreSubmitted: boolean } | null + * @returns { game: grid state, milliseconds: number } | null */ function retrieveLocalState(dateStr, currentWords) { try { @@ -94,7 +119,7 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, const [showStreakPopup, setShowStreakPopup] = useState(false); const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [streakData, setStreakData] = useState(null); - const [scoreSubmitted, setScoreSubmitted] = useState(false); + // const [scoreSubmitted, setScoreSubmitted] = useState(false); // Initialize stopwatch with saved time if resuming const stopWatch = useStopwatch({ @@ -109,17 +134,16 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ milliseconds }), + body: JSON.stringify({ milliseconds, date: dateSeed }), }) .then(res => res.json()) .then(data => { if (data.success) { // Mark score as submitted in localStorage - const savedGame = retrieveLocalState(dateSeed, words); - if (savedGame && !preventStorage) { - saveLocalState(savedGame.game, savedGame.milliseconds, dateSeed, words, true); - } - + console.log('Score submitted successfully:', data); + rememberScoreSubmitted(dateSeed); + + if (milliseconds !== null) { // Show streak popup setStreakData(data.streak); @@ -142,19 +166,24 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, stopWatch.reset(new Date(Date.now() + savedGame.milliseconds), false); setShowHowTo(false); setShowResumeModal(true); - - // If user is authenticated and game is completed but score not submitted, submit it - if (status === 'authenticated' && gameIsCompleted(savedGame.game) && !savedGame.scoreSubmitted) { - console.log('Found unsubmitted completed game, submitting score...'); - submitScore(savedGame.milliseconds); - } } else { setShowHowTo(true); setShowResumeModal(false); } - // Reset score submitted flag when date changes - setScoreSubmitted(false); - }, [dateSeed, words, status]); + }, [dateSeed, words]); + + useEffect(() => { + // If user is authenticated and game is completed but score not submitted, submit it + // (this can happen if user completed game while unauthenticated, logged in through OAuth, then returned to this page) + if (status === 'authenticated' && gameIsFinished(currentGameState) && !hasSubmittedScore(dateSeed)) { + console.log('Found unsubmitted completed game, submitting score...'); + submitScore(gameIsCompleted(currentGameState) ? timerTotalMilliseconds(stopWatch) : null); + } else if (status === 'unauthenticated' && gameIsCompleted(currentGameState)) { + // User is not logged in and has completed the game + // Show login prompt + setTimeout(() => setShowLoginPrompt(true), 1000); + } + }, [dateSeed, status, currentGameState]); useEffect(() => { if (gameIsFinished(currentGameState)) stopWatch.pause(); @@ -163,23 +192,23 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, }, [currentGameState, dateSeed, words]); // Submit score when game is finished - useEffect(() => { - if (gameIsFinished(currentGameState) && gameBegun && !scoreSubmitted) { - setScoreSubmitted(true); - const completed = gameIsCompleted(currentGameState); - const milliseconds = completed ? timerTotalMilliseconds(stopWatch) : null; + // useEffect(() => { + // if (gameIsFinished(currentGameState) && gameBegun && !hasSubmittedScore(dateSeed)) { + // // setScoreSubmitted(true); + // const completed = gameIsCompleted(currentGameState); + // const milliseconds = completed ? timerTotalMilliseconds(stopWatch) : null; - if (status === 'authenticated') { - // User is logged in, submit score immediately - submitScore(milliseconds); - } else if (status === 'unauthenticated' && completed) { - // User is not logged in and completed the game - // Score is already saved to localStorage by another useEffect - // Show login prompt - setTimeout(() => setShowLoginPrompt(true), 1000); - } - } - }, [currentGameState, gameBegun, scoreSubmitted, status]); + // if (status === 'authenticated') { + // // User is logged in, submit score immediately + // submitScore(milliseconds); + // } else if (status === 'unauthenticated' && completed) { + // // User is not logged in and completed the game + // // Score is already saved to localStorage by another useEffect + // // Show login prompt + // setTimeout(() => setShowLoginPrompt(true), 1000); + // } + // } + // }, [currentGameState, gameBegun, status]); // set up callback to run beforeunload to save game state (save the user's time if they leave mid-game) useEffect(() => { diff --git a/app/ui/timer.js b/app/ui/timer.js index ca77923..8af1772 100644 --- a/app/ui/timer.js +++ b/app/ui/timer.js @@ -75,15 +75,17 @@ export function DailyTimer({ onExpire }) { Next Daily diff --git a/app/ui/user-menu.js b/app/ui/user-menu.js index 3bd38a7..a877531 100644 --- a/app/ui/user-menu.js +++ b/app/ui/user-menu.js @@ -8,6 +8,7 @@ import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Divider from '@mui/material/Divider'; import ListItemText from '@mui/material/ListItemText'; +import { Typography } from '@mui/material'; function SignInOutMenuItem({status}) { if (status === "authenticated") { @@ -36,7 +37,6 @@ export default function UserMenu() { return (
- {session && (

{session.user.name}

)} + {session && ({session.user.name})}
From 8fbb8e06627e8793407dfe7bfd2df83a64763cc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:45:32 +0000 Subject: [PATCH 19/31] Add comprehensive e2e tests with database integration for streak tracking Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- app/ui/login-prompt-modal.js | 2 + app/ui/streak-popup.js | 3 +- playwright.config.js | 4 +- tests/db-test-setup.ts | 208 ++++++++++++++++++++++ tests/global-setup.ts | 18 ++ tests/streak-tracking.spec.ts | 323 ++++++++++++++++++++++++++++++++++ 6 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 tests/db-test-setup.ts create mode 100644 tests/global-setup.ts create mode 100644 tests/streak-tracking.spec.ts diff --git a/app/ui/login-prompt-modal.js b/app/ui/login-prompt-modal.js index f68ce60..2ac6d04 100644 --- a/app/ui/login-prompt-modal.js +++ b/app/ui/login-prompt-modal.js @@ -23,6 +23,7 @@ export default function LoginPromptModal({ open, onClose }) { onClose={onClose} maxWidth="sm" fullWidth + data-testid="login-prompt-modal" PaperProps={{ className: 'bg-white rounded-lg border-4 border-purple-500 p-4' }} @@ -62,6 +63,7 @@ export default function LoginPromptModal({ open, onClose }) { variant="contained" color="primary" fullWidth + data-testid="sign-in-button" sx={{ maxWidth: 300 }} > Sign In to Start Tracking diff --git a/app/ui/streak-popup.js b/app/ui/streak-popup.js index 314df33..d8df25e 100644 --- a/app/ui/streak-popup.js +++ b/app/ui/streak-popup.js @@ -11,6 +11,7 @@ export default function StreakPopup({ open, onClose, streakLength, isNewStreak } onClose={onClose} maxWidth="xs" fullWidth + data-testid="streak-popup" PaperProps={{ sx: { borderRadius: 2, @@ -31,7 +32,7 @@ export default function StreakPopup({ open, onClose, streakLength, isNewStreak } {isNewStreak ? 'Streak Started!' : 'Streak Updated!'} - + {streakLength} {streakLength === 1 ? 'Day' : 'Days'} diff --git a/playwright.config.js b/playwright.config.js index cf12b44..960cc3c 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -25,10 +25,12 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', + /* Global setup to seed test database */ + globalSetup: './tests/global-setup.ts', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('')`. */ - // baseURL: 'http://localhost:3000', + baseURL: process.env.BASE_URL || 'http://localhost:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', diff --git a/tests/db-test-setup.ts b/tests/db-test-setup.ts new file mode 100644 index 0000000..078b2d3 --- /dev/null +++ b/tests/db-test-setup.ts @@ -0,0 +1,208 @@ +/** + * Database test setup script + * Seeds a test database with users, streaks, and daily scores for testing + */ + +import postgres from 'postgres'; + +// Test database connection - use default import +const sql = postgres(process.env.TEST_DATABASE_URL || process.env.POSTGRES_URL || '', { ssl: 'require' }); + +export interface TestUser { + id: number; + name: string; + email: string; + emailVerified: Date | null; + image: string | null; +} + +export const testUsers: TestUser[] = [ + { + id: 1, + name: 'Test User One', + email: 'test1@example.com', + emailVerified: new Date('2024-01-01'), + image: null, + }, + { + id: 2, + name: 'Test User Two', + email: 'test2@example.com', + emailVerified: new Date('2024-01-01'), + image: null, + }, + { + id: 3, + name: 'Test User Three', + email: 'test3@example.com', + emailVerified: null, + image: null, + }, +]; + +/** + * Clear all test data from tables + */ +export async function clearTestData() { + await sql`DELETE FROM daily_scores WHERE user_id IN (1, 2, 3)`; + await sql`DELETE FROM streaks WHERE user_id IN (1, 2, 3)`; + await sql`DELETE FROM users WHERE id IN (1, 2, 3)`; +} + +/** + * Seed test users into the database + */ +export async function seedTestUsers() { + for (const user of testUsers) { + await sql` + INSERT INTO users (id, name, email, "emailVerified", image) + VALUES (${user.id}, ${user.name}, ${user.email}, ${user.emailVerified}, ${user.image}) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + email = EXCLUDED.email, + "emailVerified" = EXCLUDED."emailVerified", + image = EXCLUDED.image + `; + } + console.log('✓ Seeded test users'); +} + +/** + * Seed test streaks into the database + */ +export async function seedTestStreaks() { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const twoDaysAgo = new Date(today); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + + // User 1: Has a 3-day streak (completed yesterday) + await sql` + INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) + VALUES (1, 3, 5, ${yesterday.toISOString().split('T')[0]}) + ON CONFLICT (user_id) DO UPDATE SET + current_streak_length = EXCLUDED.current_streak_length, + longest_streak_length = EXCLUDED.longest_streak_length, + current_streak_last_date = EXCLUDED.current_streak_last_date + `; + + // User 2: Has a 1-day streak (completed two days ago, so streak will reset) + await sql` + INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) + VALUES (2, 1, 10, ${twoDaysAgo.toISOString().split('T')[0]}) + ON CONFLICT (user_id) DO UPDATE SET + current_streak_length = EXCLUDED.current_streak_length, + longest_streak_length = EXCLUDED.longest_streak_length, + current_streak_last_date = EXCLUDED.current_streak_last_date + `; + + console.log('✓ Seeded test streaks'); +} + +/** + * Seed test daily scores into the database + */ +export async function seedTestDailyScores() { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const twoDaysAgo = new Date(today); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + const threeDaysAgo = new Date(today); + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + + // User 1: Completed puzzles for the last 3 days + await sql` + INSERT INTO daily_scores (user_id, date, milliseconds) + VALUES + (1, ${threeDaysAgo.toISOString().split('T')[0]}, 45000), + (1, ${twoDaysAgo.toISOString().split('T')[0]}, 42000), + (1, ${yesterday.toISOString().split('T')[0]}, 38000) + ON CONFLICT (user_id, date) DO NOTHING + `; + + // User 2: Completed puzzle two days ago only + await sql` + INSERT INTO daily_scores (user_id, date, milliseconds) + VALUES (2, ${twoDaysAgo.toISOString().split('T')[0]}, 50000) + ON CONFLICT (user_id, date) DO NOTHING + `; + + console.log('✓ Seeded test daily scores'); +} + +/** + * Setup all test data + */ +export async function setupTestDatabase() { + try { + console.log('Setting up test database...'); + await clearTestData(); + await seedTestUsers(); + await seedTestStreaks(); + await seedTestDailyScores(); + console.log('✓ Test database setup complete'); + } catch (error) { + console.error('Error setting up test database:', error); + throw error; + } +} + +/** + * Verify that a score was submitted for a user on a specific date + */ +export async function verifyScoreSubmitted(userId: number, date: string): Promise { + const result = await sql` + SELECT * FROM daily_scores + WHERE user_id = ${userId} AND date = ${date} + `; + return result.length === 1; +} + +/** + * Verify that a score has the expected value + */ +export async function verifyScoreValue(userId: number, date: string, milliseconds: number | null): Promise { + const result = await sql` + SELECT * FROM daily_scores + WHERE user_id = ${userId} AND date = ${date} AND milliseconds IS NOT DISTINCT FROM ${milliseconds} + `; + return result.length === 1; +} + +/** + * Get a user's current streak + */ +export async function getUserStreak(userId: number) { + const result = await sql` + SELECT * FROM streaks WHERE user_id = ${userId} + `; + return result.length > 0 ? result[0] : null; +} + +/** + * Verify that a user's streak matches expected values + */ +export async function verifyStreak( + userId: number, + expectedCurrentStreak: number, + expectedLongestStreak: number, + expectedLastDate?: string +): Promise { + const streak = await getUserStreak(userId); + if (!streak) return false; + + const currentMatches = streak.current_streak_length === expectedCurrentStreak; + const longestMatches = streak.longest_streak_length === expectedLongestStreak; + const dateMatches = expectedLastDate ? streak.current_streak_last_date.toISOString().split('T')[0] === expectedLastDate : true; + + return currentMatches && longestMatches && dateMatches; +} + +/** + * Close database connection + */ +export async function closeDatabaseConnection() { + await sql.end(); +} diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 0000000..185a1a4 --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,18 @@ +/** + * Global setup for Playwright tests + * Runs once before all tests to prepare the test database + */ + +import { setupTestDatabase } from './db-test-setup'; + +export default async function globalSetup() { + console.log('\n🚀 Running global test setup...\n'); + + try { + await setupTestDatabase(); + console.log('\n✅ Global setup complete\n'); + } catch (error) { + console.error('\n❌ Global setup failed:', error); + throw error; + } +} diff --git a/tests/streak-tracking.spec.ts b/tests/streak-tracking.spec.ts new file mode 100644 index 0000000..370855a --- /dev/null +++ b/tests/streak-tracking.spec.ts @@ -0,0 +1,323 @@ +/** + * End-to-end tests for streak tracking with database integration + * Tests the complete flow of completing puzzles, tracking streaks, and submitting scores + */ + +import { test, expect, Page } from '@playwright/test'; +import { closeHowToDialog, getTileByCharacter } from './helpers'; +import { + testUsers, + verifyScoreSubmitted, + verifyScoreValue, + verifyStreak, + getUserStreak, + setupTestDatabase +} from './db-test-setup'; + +// Helper function to mock NextAuth session +async function mockAuthSession(page: Page, userId: number) { + const user = testUsers.find(u => u.id === userId); + if (!user) throw new Error(`User ${userId} not found`); + + await page.route('**/api/auth/session', route => { + route.fulfill({ + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user: { + name: user.name, + email: user.email, + image: user.image, + }, + expires: '2099-12-31T23:59:59.999Z', + }), + }); + }); +} + +// Helper function to complete a simple 2-character puzzle +async function completePuzzle(page: Page, word: string) { + await closeHowToDialog(page); + + const char1 = getTileByCharacter(page, word[0]); + const char2 = getTileByCharacter(page, word[1]); + + await char1.click(); + await char2.click(); + + // Wait for tiles to be matched + await expect(char1).toHaveAttribute('data-match-color', /.+/); +} + +// Get today's date in YYYY-MM-DD format +function getTodayDateString(): string { + return new Date().toISOString().split('T')[0]; +} + +test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => { + test.beforeEach(async ({ page }) => { + // Reset test database before each test + await setupTestDatabase(); + + // Mock authentication for User 1 (has 3-day streak, completed yesterday) + await mockAuthSession(page, 1); + }); + + test('should submit score and update streak when authenticated user completes puzzle', async ({ page }) => { + const today = getTodayDateString(); + + // Navigate to game with simple 2-character word + await page.goto('http://localhost:3000/?dev=true&words=你好&preventRestore=true'); + + // Complete the puzzle + await completePuzzle(page, '你好'); + + // Wait for streak popup to appear + await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); + + // Check that streak length is displayed correctly (should be 4 now: 3 + 1) + const streakText = await page.getByTestId('streak-length').textContent(); + expect(streakText).toContain('4'); + + // Verify database was updated correctly + // Give it a moment for async operations to complete + await page.waitForTimeout(1000); + + // Check that score was submitted + const scoreSubmitted = await verifyScoreSubmitted(1, today); + expect(scoreSubmitted).toBe(true); + + // Check that streak was updated correctly (3 -> 4) + const streakValid = await verifyStreak(1, 4, 5, today); + expect(streakValid).toBe(true); + }); + + test('should not overwrite existing score if user tries to submit twice', async ({ page }) => { + const today = getTodayDateString(); + + // First completion + await page.goto('http://localhost:3000/?dev=true&words=你好&preventRestore=true'); + await completePuzzle(page, '你好'); + await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); + await page.waitForTimeout(1000); + + // Get the first score + const streak1 = await getUserStreak(1); + const initialStreak = streak1?.current_streak_length; + + // Close streak popup + await page.getByTestId('streak-popup').click(); + await page.waitForTimeout(500); + + // Try to complete again (simulate clearing cookies and playing again) + await page.goto('http://localhost:3000/?dev=true&words=测试&preventRestore=true&preventStorage=true'); + await completePuzzle(page, '测试'); + + // Streak popup should appear again + await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); + await page.waitForTimeout(1000); + + // But streak should not increase (still same as before) + const streak2 = await getUserStreak(1); + expect(streak2?.current_streak_length).toBe(initialStreak); + + // Should still only have one score for today + const scoreSubmitted = await verifyScoreSubmitted(1, today); + expect(scoreSubmitted).toBe(true); + }); + + test('should reset streak if user misses a day', async ({ page }) => { + // User 2 has a streak from 2 days ago, so it should reset + await mockAuthSession(page, 2); + + const today = getTodayDateString(); + + await page.goto('http://localhost:3000/?dev=true&words=世界&preventRestore=true'); + await completePuzzle(page, '世界'); + + await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); + + // Streak should show 1 (reset because user missed yesterday) + const streakText = await page.getByTestId('streak-length').textContent(); + expect(streakText).toContain('1'); + + await page.waitForTimeout(1000); + + // Verify streak was reset to 1, but longest streak is preserved (10) + const streakValid = await verifyStreak(2, 1, 10, today); + expect(streakValid).toBe(true); + }); + + test('should record failed game (3 strikes) with null score', async ({ page }) => { + const today = getTodayDateString(); + + await page.goto('http://localhost:3000/?dev=true&words=你好&preventRestore=true'); + await closeHowToDialog(page); + + // Make 3 incorrect guesses to get 3 strikes + const tiles = await page.getByTestId(/^hanzi-tile-/).all(); + + // Click first tile, then different non-matching tiles + await tiles[0].click(); + await tiles[1].click(); // This should create a strike if not matching + + await page.waitForTimeout(500); + + await tiles[0].click(); + await tiles[1].click(); + + await page.waitForTimeout(500); + + await tiles[0].click(); + await tiles[1].click(); + + // Wait for game to be marked as finished + await page.waitForTimeout(2000); + + // No streak popup should appear (user failed) + await expect(page.getByTestId('streak-popup')).not.toBeVisible(); + + // Verify null score was recorded + const scoreRecorded = await verifyScoreValue(1, today, null); + expect(scoreRecorded).toBe(true); + + // Verify streak was reset to 0 + const streak = await getUserStreak(1); + expect(streak?.current_streak_length).toBe(0); + }); +}); + +test.describe('Streak Tracking - Unauthenticated User (Login After Completion)', () => { + test.beforeEach(async ({ page }) => { + await setupTestDatabase(); + }); + + test('should show login prompt when unauthenticated user completes puzzle', async ({ page }) => { + // Start without authentication + await page.route('**/api/auth/session', route => { + route.fulfill({ + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + }); + + await page.goto('http://localhost:3000/?dev=true&words=朋友&preventRestore=true'); + await completePuzzle(page, '朋友'); + + // Login prompt should appear + await expect(page.getByTestId('login-prompt-modal')).toBeVisible({ timeout: 5000 }); + + // Streak popup should NOT appear + await expect(page.getByTestId('streak-popup')).not.toBeVisible(); + }); + + test('should submit pending score after user logs in', async ({ page }) => { + const today = getTodayDateString(); + + // Start without authentication + let isAuthenticated = false; + + await page.route('**/api/auth/session', route => { + if (isAuthenticated) { + route.fulfill({ + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user: { + name: testUsers[2].name, + email: testUsers[2].email, + image: testUsers[2].image, + }, + expires: '2099-12-31T23:59:59.999Z', + }), + }); + } else { + route.fulfill({ + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + } + }); + + // Complete puzzle while unauthenticated + await page.goto('http://localhost:3000/?dev=true&words=学习&preventRestore=true'); + await completePuzzle(page, '学习'); + + // Login prompt should appear + await expect(page.getByTestId('login-prompt-modal')).toBeVisible({ timeout: 5000 }); + + // Simulate login by changing authentication state and reloading + isAuthenticated = true; + await page.reload(); + + // Wait for page to load and check for authentication + await page.waitForTimeout(2000); + + // After reload with auth, streak popup should appear + await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); + + // Streak should be 1 (new streak) + const streakText = await page.getByTestId('streak-length').textContent(); + expect(streakText).toContain('1'); + + await page.waitForTimeout(1000); + + // Verify score was submitted for User 3 + const scoreSubmitted = await verifyScoreSubmitted(3, today); + expect(scoreSubmitted).toBe(true); + + // Verify streak was created + const streakValid = await verifyStreak(3, 1, 1, today); + expect(streakValid).toBe(true); + }); +}); + +test.describe('Streak Display', () => { + test.beforeEach(async ({ page }) => { + await setupTestDatabase(); + await mockAuthSession(page, 1); + }); + + test('should display correct streak information in popup', async ({ page }) => { + await page.goto('http://localhost:3000/?dev=true&words=开心&preventRestore=true'); + await completePuzzle(page, '开心'); + + const streakPopup = page.getByTestId('streak-popup'); + await expect(streakPopup).toBeVisible({ timeout: 5000 }); + + // Check streak length is displayed + const streakLength = page.getByTestId('streak-length'); + await expect(streakLength).toBeVisible(); + await expect(streakLength).toContainText('4'); + + // Check "Days" or "Day" text is present + await expect(streakLength).toContainText('Days'); + }); + + test('should show "Streak Started!" for new streak', async ({ page }) => { + // User 3 has no streak yet + await mockAuthSession(page, 3); + + await page.goto('http://localhost:3000/?dev=true&words=快乐&preventRestore=true'); + await completePuzzle(page, '快乐'); + + const streakPopup = page.getByTestId('streak-popup'); + await expect(streakPopup).toBeVisible({ timeout: 5000 }); + + // Should show "Streak Started!" for first day + await expect(streakPopup).toContainText('Streak Started!'); + }); + + test('should show "Streak Updated!" for continuing streak', async ({ page }) => { + await page.goto('http://localhost:3000/?dev=true&words=努力&preventRestore=true'); + await completePuzzle(page, '努力'); + + const streakPopup = page.getByTestId('streak-popup'); + await expect(streakPopup).toBeVisible({ timeout: 5000 }); + + // Should show "Streak Updated!" when continuing + await expect(streakPopup).toContainText('Streak Updated!'); + }); +}); From cdbd1fa9658c4c6f01aa940ad2e5453a660e4c62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:49:48 +0000 Subject: [PATCH 20/31] Address code review feedback: improve error handling and test reliability Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- tests/db-test-setup.ts | 16 +++++++-- tests/streak-tracking.spec.ts | 62 +++++++++++++++++++++-------------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/tests/db-test-setup.ts b/tests/db-test-setup.ts index 078b2d3..63a0c9d 100644 --- a/tests/db-test-setup.ts +++ b/tests/db-test-setup.ts @@ -6,7 +6,11 @@ import postgres from 'postgres'; // Test database connection - use default import -const sql = postgres(process.env.TEST_DATABASE_URL || process.env.POSTGRES_URL || '', { ssl: 'require' }); +const dbUrl = process.env.TEST_DATABASE_URL || process.env.POSTGRES_URL; +if (!dbUrl) { + throw new Error('TEST_DATABASE_URL or POSTGRES_URL environment variable must be set for database tests'); +} +const sql = postgres(dbUrl, { ssl: 'require' }); export interface TestUser { id: number; @@ -195,7 +199,15 @@ export async function verifyStreak( const currentMatches = streak.current_streak_length === expectedCurrentStreak; const longestMatches = streak.longest_streak_length === expectedLongestStreak; - const dateMatches = expectedLastDate ? streak.current_streak_last_date.toISOString().split('T')[0] === expectedLastDate : true; + + // Handle date comparison - could be Date object or string from database + let dateMatches = true; + if (expectedLastDate) { + const lastDateStr = streak.current_streak_last_date instanceof Date + ? streak.current_streak_last_date.toISOString().split('T')[0] + : String(streak.current_streak_last_date).split('T')[0]; + dateMatches = lastDateStr === expectedLastDate; + } return currentMatches && longestMatches && dateMatches; } diff --git a/tests/streak-tracking.spec.ts b/tests/streak-tracking.spec.ts index 370855a..3cc5fc1 100644 --- a/tests/streak-tracking.spec.ts +++ b/tests/streak-tracking.spec.ts @@ -54,6 +54,18 @@ function getTodayDateString(): string { return new Date().toISOString().split('T')[0]; } +// Helper to wait for score to be recorded in database +async function waitForScoreRecorded(userId: number, date: string, maxWaitMs: number = 3000): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < maxWaitMs) { + if (await verifyScoreSubmitted(userId, date)) { + return true; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; +} + test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => { test.beforeEach(async ({ page }) => { // Reset test database before each test @@ -67,7 +79,7 @@ test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => const today = getTodayDateString(); // Navigate to game with simple 2-character word - await page.goto('http://localhost:3000/?dev=true&words=你好&preventRestore=true'); + await page.goto('/?dev=true&words=你好&preventRestore=true'); // Complete the puzzle await completePuzzle(page, '你好'); @@ -79,9 +91,8 @@ test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => const streakText = await page.getByTestId('streak-length').textContent(); expect(streakText).toContain('4'); - // Verify database was updated correctly - // Give it a moment for async operations to complete - await page.waitForTimeout(1000); + // Wait for score to be recorded in database + await waitForScoreRecorded(1, today); // Check that score was submitted const scoreSubmitted = await verifyScoreSubmitted(1, today); @@ -96,10 +107,10 @@ test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => const today = getTodayDateString(); // First completion - await page.goto('http://localhost:3000/?dev=true&words=你好&preventRestore=true'); + await page.goto('/?dev=true&words=你好&preventRestore=true'); await completePuzzle(page, '你好'); await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); - await page.waitForTimeout(1000); + await waitForScoreRecorded(1, today); // Get the first score const streak1 = await getUserStreak(1); @@ -107,15 +118,17 @@ test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => // Close streak popup await page.getByTestId('streak-popup').click(); - await page.waitForTimeout(500); + await expect(page.getByTestId('streak-popup')).not.toBeVisible(); // Try to complete again (simulate clearing cookies and playing again) - await page.goto('http://localhost:3000/?dev=true&words=测试&preventRestore=true&preventStorage=true'); + await page.goto('/?dev=true&words=测试&preventRestore=true&preventStorage=true'); await completePuzzle(page, '测试'); // Streak popup should appear again await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); - await page.waitForTimeout(1000); + + // Give API call a moment to complete + await new Promise(resolve => setTimeout(resolve, 500)); // But streak should not increase (still same as before) const streak2 = await getUserStreak(1); @@ -132,7 +145,7 @@ test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => const today = getTodayDateString(); - await page.goto('http://localhost:3000/?dev=true&words=世界&preventRestore=true'); + await page.goto('/?dev=true&words=世界&preventRestore=true'); await completePuzzle(page, '世界'); await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); @@ -141,7 +154,7 @@ test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => const streakText = await page.getByTestId('streak-length').textContent(); expect(streakText).toContain('1'); - await page.waitForTimeout(1000); + await waitForScoreRecorded(1, today); // Verify streak was reset to 1, but longest streak is preserved (10) const streakValid = await verifyStreak(2, 1, 10, today); @@ -151,7 +164,7 @@ test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => test('should record failed game (3 strikes) with null score', async ({ page }) => { const today = getTodayDateString(); - await page.goto('http://localhost:3000/?dev=true&words=你好&preventRestore=true'); + await page.goto('/?dev=true&words=你好&preventRestore=true'); await closeHowToDialog(page); // Make 3 incorrect guesses to get 3 strikes @@ -161,18 +174,19 @@ test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => await tiles[0].click(); await tiles[1].click(); // This should create a strike if not matching - await page.waitForTimeout(500); + // Wait for shake animation to complete + await page.waitForFunction(() => !document.querySelector('[data-shaking="true"]'), { timeout: 1000 }).catch(() => {}); await tiles[0].click(); await tiles[1].click(); - await page.waitForTimeout(500); + await page.waitForFunction(() => !document.querySelector('[data-shaking="true"]'), { timeout: 1000 }).catch(() => {}); await tiles[0].click(); await tiles[1].click(); - // Wait for game to be marked as finished - await page.waitForTimeout(2000); + // Wait for strikes indicator to show 3 strikes + await expect(page.getByTestId('strikes-indicator').locator('[data-strike-active="true"]')).toHaveCount(3, { timeout: 3000 }); // No streak popup should appear (user failed) await expect(page.getByTestId('streak-popup')).not.toBeVisible(); @@ -202,7 +216,7 @@ test.describe('Streak Tracking - Unauthenticated User (Login After Completion)', }); }); - await page.goto('http://localhost:3000/?dev=true&words=朋友&preventRestore=true'); + await page.goto('/?dev=true&words=朋友&preventRestore=true'); await completePuzzle(page, '朋友'); // Login prompt should appear @@ -242,7 +256,7 @@ test.describe('Streak Tracking - Unauthenticated User (Login After Completion)', }); // Complete puzzle while unauthenticated - await page.goto('http://localhost:3000/?dev=true&words=学习&preventRestore=true'); + await page.goto('/?dev=true&words=学习&preventRestore=true'); await completePuzzle(page, '学习'); // Login prompt should appear @@ -252,8 +266,8 @@ test.describe('Streak Tracking - Unauthenticated User (Login After Completion)', isAuthenticated = true; await page.reload(); - // Wait for page to load and check for authentication - await page.waitForTimeout(2000); + // Wait for page to load - grid should be visible + await expect(page.getByTestId('hanzi-grid')).toBeVisible({ timeout: 5000 }); // After reload with auth, streak popup should appear await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); @@ -262,7 +276,7 @@ test.describe('Streak Tracking - Unauthenticated User (Login After Completion)', const streakText = await page.getByTestId('streak-length').textContent(); expect(streakText).toContain('1'); - await page.waitForTimeout(1000); + await waitForScoreRecorded(1, today); // Verify score was submitted for User 3 const scoreSubmitted = await verifyScoreSubmitted(3, today); @@ -281,7 +295,7 @@ test.describe('Streak Display', () => { }); test('should display correct streak information in popup', async ({ page }) => { - await page.goto('http://localhost:3000/?dev=true&words=开心&preventRestore=true'); + await page.goto('/?dev=true&words=开心&preventRestore=true'); await completePuzzle(page, '开心'); const streakPopup = page.getByTestId('streak-popup'); @@ -300,7 +314,7 @@ test.describe('Streak Display', () => { // User 3 has no streak yet await mockAuthSession(page, 3); - await page.goto('http://localhost:3000/?dev=true&words=快乐&preventRestore=true'); + await page.goto('/?dev=true&words=快乐&preventRestore=true'); await completePuzzle(page, '快乐'); const streakPopup = page.getByTestId('streak-popup'); @@ -311,7 +325,7 @@ test.describe('Streak Display', () => { }); test('should show "Streak Updated!" for continuing streak', async ({ page }) => { - await page.goto('http://localhost:3000/?dev=true&words=努力&preventRestore=true'); + await page.goto('/?dev=true&words=努力&preventRestore=true'); await completePuzzle(page, '努力'); const streakPopup = page.getByTestId('streak-popup'); From 233fe6c2c0e94a565258df34cfd64f6ada60b5a0 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Thu, 18 Dec 2025 17:14:23 -0700 Subject: [PATCH 21/31] neon db branch workflow yaml --- .github/workflows/neon_workflow.yml | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/neon_workflow.yml diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml new file mode 100644 index 0000000..e0790b1 --- /dev/null +++ b/.github/workflows/neon_workflow.yml @@ -0,0 +1,99 @@ +name: Create/Delete Branch for Pull Request + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +jobs: + setup: + name: Setup + outputs: + branch: ${{ steps.branch_name.outputs.current_branch }} + runs-on: ubuntu-latest + steps: + - name: Get branch name + id: branch_name + uses: tj-actions/branch-names@v8 + + create_neon_branch: + name: Create Neon Branch + outputs: + db_url: ${{ steps.create_neon_branch_encode.outputs.db_url }} + db_url_with_pooler: ${{ steps.create_neon_branch_encode.outputs.db_url_with_pooler }} + needs: setup + if: | + github.event_name == 'pull_request' && ( + github.event.action == 'synchronize' + || github.event.action == 'opened' + || github.event.action == 'reopened') + runs-on: ubuntu-latest + steps: + - name: Get branch expiration date as an env variable (2 weeks from now) + id: get_expiration_date + run: echo "EXPIRES_AT=$(date -u --date '+14 days' +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV" + - name: Create Neon Branch + id: create_neon_branch + uses: neondatabase/create-branch-action@v6 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} + api_key: ${{ secrets.NEON_API_KEY }} + expires_at: ${{ env.EXPIRES_AT }} + +# The step above creates a new Neon branch. +# You may want to do something with the new branch, such as run migrations, run tests +# on it, or send the connection details to a hosting platform environment. +# The branch DATABASE_URL is available to you via: +# "${{ steps.create_neon_branch.outputs.db_url_with_pooler }}". +# It's important you don't log the DATABASE_URL as output as it contains a username and +# password for your database. +# For example, you can uncomment the lines below to run a database migration command: +# - name: Run Migrations +# run: npm run db:migrate +# env: +# # to use pooled connection +# DATABASE_URL: "${{ steps.create_neon_branch.outputs.db_url_with_pooler }}" +# # OR to use unpooled connection +# # DATABASE_URL: "${{ steps.create_neon_branch.outputs.db_url }}" + +# Following the step above, which runs database migrations, you may want to check +# for schema changes in your database. We recommend using the following action to +# post a comment to your pull request with the schema diff. For this action to work, +# you also need to give permissions to the workflow job to be able to post comments +# and read your repository contents. Add the following permissions to the workflow job: +# +# permissions: +# contents: read +# pull-requests: write +# +# You can also check out https://github.com/neondatabase/schema-diff-action for more +# information on how to use the schema diff action. +# You can uncomment the lines below to enable the schema diff action. +# - name: Post Schema Diff Comment to PR +# uses: neondatabase/schema-diff-action@v1 +# with: +# project_id: ${{ vars.NEON_PROJECT_ID }} +# compare_branch: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} +# api_key: ${{ secrets.NEON_API_KEY }} + + delete_neon_branch: + name: Delete Neon Branch + needs: setup + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Delete Neon Branch + uses: neondatabase/delete-branch-action@v3 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} + api_key: ${{ secrets.NEON_API_KEY }} + + \ No newline at end of file From f2b43119130a0f778d69008a082b6e43c8f18d72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:35:19 +0000 Subject: [PATCH 22/31] Integrate Neon database branching with Playwright e2e tests Co-authored-by: NicolasWinsten <56099103+NicolasWinsten@users.noreply.github.com> --- .github/workflows/neon_workflow.yml | 32 +++++++++++++++++++++++++++-- .github/workflows/test.yml | 14 +++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index e0790b1..96204f5 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -25,8 +25,8 @@ jobs: create_neon_branch: name: Create Neon Branch outputs: - db_url: ${{ steps.create_neon_branch_encode.outputs.db_url }} - db_url_with_pooler: ${{ steps.create_neon_branch_encode.outputs.db_url_with_pooler }} + db_url: ${{ steps.create_neon_branch.outputs.db_url }} + db_url_with_pooler: ${{ steps.create_neon_branch.outputs.db_url_with_pooler }} needs: setup if: | github.event_name == 'pull_request' && ( @@ -47,6 +47,34 @@ jobs: api_key: ${{ secrets.NEON_API_KEY }} expires_at: ${{ env.EXPIRES_AT }} + run_tests: + name: Run E2E Tests with Neon Branch + needs: create_neon_branch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright E2E tests + run: npm run test:e2e + env: + # Use the branched database URL from Neon workflow + TEST_DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + POSTGRES_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-neon-branch + path: playwright-report/ + retention-days: 30 + # The step above creates a new Neon branch. # You may want to do something with the new branch, such as run migrations, run tests # on it, or send the connection details to a hosting platform environment. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6504d9..7369688 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,8 +4,14 @@ on: branches: [ main, master ] pull_request: branches: [ main, master ] + workflow_run: + workflows: ["Create/Delete Branch for Pull Request"] + types: + - completed + env: NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + jobs: jest: name: Jest Unit Tests @@ -24,6 +30,8 @@ jobs: name: Playwright E2E Tests timeout-minutes: 60 runs-on: ubuntu-latest + # Run after Jest tests pass + needs: jest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -35,6 +43,12 @@ jobs: run: npx playwright install --with-deps - name: Run Playwright tests run: npm run test:e2e + env: + # For PR tests, these would be set by Neon branching workflow + # For main/master branch, use the default POSTGRES_URL secret + TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL || secrets.POSTGRES_URL }} + POSTGRES_URL: ${{ secrets.POSTGRES_URL }} + DATABASE_URL: ${{ secrets.DATABASE_URL || secrets.POSTGRES_URL }} - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: From fa7d6a5b95c7f89f2e2c6b41d901832475169b35 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Thu, 18 Dec 2025 17:59:11 -0700 Subject: [PATCH 23/31] add jest tests to neon workflow --- .github/workflows/neon_workflow.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index 96204f5..bdac4fb 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -21,6 +21,19 @@ jobs: - name: Get branch name id: branch_name uses: tj-actions/branch-names@v8 + + jest: + name: Jest Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Run Jest tests + run: npm test create_neon_branch: name: Create Neon Branch @@ -124,4 +137,3 @@ jobs: branch: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} api_key: ${{ secrets.NEON_API_KEY }} - \ No newline at end of file From f41aed85ac11730904f7a7acc9919b6ac6bee7bd Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Mon, 19 Jan 2026 15:33:25 -0700 Subject: [PATCH 24/31] streak tracking --- .github/workflows/neon_workflow.yml | 12 +- .github/workflows/test.yml | 2 +- IMPLEMENTATION_SUMMARY.md | 111 --------- QUICK_REFERENCE.md | 211 ----------------- STREAKS_MIGRATION.md | 32 --- USER_EXPERIENCE.md | 130 ----------- app/api/auth/[...nextauth]/route.js | 75 ++++++- app/api/submit-score/route.js | 26 ++- app/layout.js | 52 +++-- app/lib/db/db.js | 47 ++-- app/lib/db/seed-test-db.js | 96 -------- app/lib/utils.js | 22 +- app/page.js | 2 - app/signin/page.js | 58 ----- app/ui/date-picker.js | 3 +- app/ui/fonts.js | 12 +- app/ui/game-session.js | 25 +-- app/ui/how-to-box.js | 2 +- app/ui/login-prompt-modal.js | 11 +- app/ui/user-menu.js | 3 +- next.config.js | 10 + package-lock.json | 90 ++++++++ package.json | 1 + playwright.config.js | 33 +-- favicon.ico => public/favicon.ico | Bin tests/database-integration.spec.ts | 255 +++++++++++++++++++++ tests/db-test-setup.ts | 260 +++++++++++---------- tests/global-setup.ts | 18 +- tests/global-teardown.ts | 23 ++ tests/hanzi-grid.spec.ts | 25 +-- tests/helpers.ts | 73 +++++- tests/local-storage.spec.ts | 66 +++--- tests/streak-tracking.spec.ts | 337 ---------------------------- tsconfig.json | 3 +- 34 files changed, 867 insertions(+), 1259 deletions(-) delete mode 100644 IMPLEMENTATION_SUMMARY.md delete mode 100644 QUICK_REFERENCE.md delete mode 100644 STREAKS_MIGRATION.md delete mode 100644 USER_EXPERIENCE.md delete mode 100644 app/lib/db/seed-test-db.js delete mode 100644 app/signin/page.js rename favicon.ico => public/favicon.ico (100%) create mode 100644 tests/database-integration.spec.ts create mode 100644 tests/global-teardown.ts delete mode 100644 tests/streak-tracking.spec.ts diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index bdac4fb..9ec0252 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -74,13 +74,13 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright E2E tests - run: npm run test:e2e env: - # Use the branched database URL from Neon workflow - TEST_DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} - POSTGRES_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} - DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} - NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + # Use the branched database URL from Neon workflow + # TEST_DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + POSTGRES_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + run: npm run test:e2e - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7369688..9753784 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,7 +46,7 @@ jobs: env: # For PR tests, these would be set by Neon branching workflow # For main/master branch, use the default POSTGRES_URL secret - TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL || secrets.POSTGRES_URL }} + TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} POSTGRES_URL: ${{ secrets.POSTGRES_URL }} DATABASE_URL: ${{ secrets.DATABASE_URL || secrets.POSTGRES_URL }} - uses: actions/upload-artifact@v4 diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 94bf906..0000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,111 +0,0 @@ -# Implementation Summary: Daily Score & Streak Tracking - -This document summarizes the implementation of daily score and streak tracking features for the Zimi puzzle game. - -## Changes Made - -### 1. Database Functions (`app/lib/db/db.js`) -- **`getStreak()`**: Retrieves the current user's streak information -- **`updateStreak(completed)`**: Updates user's streak based on puzzle completion - - Increments streak if completed yesterday's puzzle - - Resets streak to 1 if missed days (or 0 if failed) - - Maintains longest streak record - - Prevents duplicate updates on the same day - -### 2. API Route (`app/api/submit-score/route.js`) -- **POST `/api/submit-score`**: Endpoint for submitting scores - - Accepts `{ milliseconds: number | null }` in request body - - Calls `submitDailyScore()` to record the score - - Calls `updateStreak()` to update the user's streak - - Returns streak data on success - - Returns 401 if user not authenticated - -### 3. UI Components - -#### `app/ui/streak-popup.js` -- Non-intrusive popup showing user's current streak after completion -- Animated with motion library -- Displays streak length and encouragement message - -#### `app/ui/login-prompt-modal.js` -- Modal shown to non-authenticated users who complete puzzles -- Encourages users to sign in to track progress -- Explains benefits of tracking streaks - -### 4. Game Session Integration (`app/ui/game-session.js`) -- Imports `useSession` from NextAuth to check authentication status -- Submits score automatically when game is finished -- Shows appropriate modal based on authentication status: - - Authenticated + completed: Shows streak popup - - Unauthenticated + completed: Shows login prompt - - Failed (3 strikes): Still submits (resets streak to 0) - -### 5. Layout Update (`app/layout.js`) -- Re-enabled `` component in header -- Users can now sign in/out from the UI - -### 6. TypeScript Declarations (`types/app.d.ts`) -- Added type definitions for new database functions - -### 7. Documentation (`STREAKS_MIGRATION.md`) -- SQL schema for `streaks` table -- Field descriptions -- Notes on streak logic - -## Database Schema - -The `streaks` table must be created in the database: - -```sql -CREATE TABLE IF NOT EXISTS streaks ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - current_streak_length INTEGER NOT NULL DEFAULT 0, - longest_streak_length INTEGER NOT NULL DEFAULT 0, - current_streak_last_date DATE NOT NULL -); -``` - -## User Flow - -### For Authenticated Users: -1. User completes daily puzzle -2. Score automatically submitted to backend -3. Streak calculated and updated -4. Streak popup appears showing current streak -5. User can dismiss popup and continue - -### For Non-Authenticated Users: -1. User completes daily puzzle -2. Login prompt modal appears -3. User can sign in to start tracking or dismiss -4. If dismissed, can still share results - -## Key Features - -✅ Automatic score submission on game completion -✅ Streak tracking with proper date handling -✅ Non-intrusive UI notifications -✅ Encourages user engagement through login prompts -✅ Handles edge cases (duplicate submissions, date boundaries, failed games) -✅ No security vulnerabilities detected -✅ Maintains existing game functionality - -## Testing Notes - -- All existing unit tests pass -- Code review completed and feedback addressed -- Security scan completed with no issues found -- Manual testing recommended for: - - Completing puzzles while authenticated - - Completing puzzles while not authenticated - - Multi-day streak building - - Streak reset on missed days - - Failed game handling - -## Dependencies - -No new dependencies added. Uses existing packages: -- next-auth (authentication) -- postgres (database) -- @mui/material (UI components) -- motion (animations) diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md deleted file mode 100644 index d9d7c2d..0000000 --- a/QUICK_REFERENCE.md +++ /dev/null @@ -1,211 +0,0 @@ -# Quick Reference: Streak Tracking Feature - -## 🚀 Quick Start - -### For Developers - -1. **Database Setup** (Required before deployment) - ```sql - CREATE TABLE IF NOT EXISTS streaks ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - current_streak_length INTEGER NOT NULL DEFAULT 0, - longest_streak_length INTEGER NOT NULL DEFAULT 0, - current_streak_last_date DATE NOT NULL - ); - CREATE INDEX idx_streaks_user_id ON streaks(user_id); - ``` - -2. **Environment Variables** (Ensure these exist) - - `DATABASE_URL` or `POSTGRES_URL` - PostgreSQL connection string - - `GOOGLE_CLIENT_ID` - Google OAuth client ID - - `GOOGLE_CLIENT_SECRET` - Google OAuth client secret - - `NEXTAUTH_URL` - Application URL - - `NEXTAUTH_SECRET` - NextAuth secret key - -3. **Testing Locally** - ```bash - npm install - npm run dev - ``` - Visit `http://localhost:3000` and try completing a puzzle - -## 📋 Feature Overview - -### What Was Built - -| Component | Purpose | File | -|-----------|---------|------| -| Database Functions | Track and update user streaks | `app/lib/db/db.js` | -| API Endpoint | Submit scores and update streaks | `app/api/submit-score/route.js` | -| Streak Popup | Show streak after completion | `app/ui/streak-popup.js` | -| Login Prompt | Encourage login for tracking | `app/ui/login-prompt-modal.js` | -| Game Integration | Auto-submit scores | `app/ui/game-session.js` | -| User Menu | Login/logout interface | `app/layout.js` | - -### How It Works - -``` -User completes puzzle - ↓ -Is user authenticated? - ↓ ↓ - YES NO - ↓ ↓ -Submit score Show login prompt - ↓ -Update streak - ↓ -Show streak popup -``` - -## 🧪 Testing Scenarios - -### Manual Test Cases - -1. **Anonymous User Completes Puzzle** - - Expected: Login prompt modal appears - - Expected: Can dismiss and continue - - Expected: Can click "Sign In" to authenticate - -2. **New User First Completion** - - Expected: Streak popup shows "Streak Started! 1 Day" - - Expected: Popup is dismissable - - Expected: Score appears on leaderboard - -3. **User Completes on Consecutive Days** - - Day 1: Complete puzzle → "1 Day" - - Day 2: Complete puzzle → "2 Days" - - Day 3: Complete puzzle → "3 Days" - - Expected: Streak increments each day - -4. **User Misses a Day** - - Day 1: Complete puzzle → "1 Day" - - Day 2: Skip - - Day 3: Complete puzzle → "1 Day" (reset) - - Expected: Streak resets but longest is preserved - -5. **User Fails Puzzle (3 Strikes)** - - Complete with 3 strikes - - Expected: No popup shown - - Expected: Streak resets to 0 - - Expected: Score shows as failed in leaderboard - -6. **Duplicate Completion Same Day** - - Complete puzzle once - - Try to complete again (refresh page, etc.) - - Expected: Streak doesn't change - - Expected: No duplicate submissions - -## 🐛 Common Issues - -### Streak Not Updating -- Check database has streaks table -- Verify user is authenticated -- Check console for API errors -- Ensure DATABASE_URL is set correctly - -### Login Not Working -- Verify Google OAuth credentials -- Check NEXTAUTH_URL matches your domain -- Ensure NEXTAUTH_SECRET is set -- Check NextAuth configuration - -### Popup Not Appearing -- Check browser console for errors -- Verify motion library is installed -- Check if game completion is detected -- Test with different browsers - -## 📚 Documentation Files - -- **STREAKS_MIGRATION.md** - Database schema and migration SQL -- **IMPLEMENTATION_SUMMARY.md** - Technical implementation details -- **USER_EXPERIENCE.md** - Complete user experience guide -- **QUICK_REFERENCE.md** - This file - -## 🔍 Code Locations - -### Backend -- **Streak Logic**: `app/lib/db/db.js` lines 47-158 -- **API Route**: `app/api/submit-score/route.js` -- **Auth Config**: `app/api/auth/[...nextauth]/route.js` - -### Frontend -- **Game Completion**: `app/ui/game-session.js` lines 124-157 -- **Streak Popup**: `app/ui/streak-popup.js` -- **Login Modal**: `app/ui/login-prompt-modal.js` -- **User Menu**: `app/ui/user-menu.js` - -### Types -- **Type Definitions**: `types/app.d.ts` lines 28-32 - -## 🎨 UI Components - -### Streak Popup -- Appears 500ms after score submission -- Animated scale-in effect -- Fire emoji 🔥 -- Shows current streak count -- Dismissable by clicking anywhere - -### Login Prompt Modal -- Appears 1000ms after puzzle completion (anonymous users) -- Lists benefits of signing in -- Two buttons: "Sign In" and "Maybe Later" -- Purple-themed to match app design - -### User Menu -- Always visible in top-right header -- Shows user name when authenticated -- Click to access sign in/out - -## 💡 Tips for Customization - -### Change Popup Timing -```javascript -// In app/ui/game-session.js -setTimeout(() => setShowStreakPopup(true), 500); // Change 500 to desired ms -setTimeout(() => setShowLoginPrompt(true), 1000); // Change 1000 to desired ms -``` - -### Modify Streak Colors -```javascript -// In app/ui/streak-popup.js -color: '#9333ea' // Change to any color -border: '3px solid #9333ea' // Change border color -``` - -### Adjust Streak Logic -```javascript -// In app/lib/db/db.js, line 96 -// Current: Resets streak to 1 if not consecutive -// To make it more forgiving, you could add grace periods -``` - -## 📊 Database Schema - -```sql --- Main tables involved -users (id, name, email, password) -daily_scores (user_id, date, milliseconds) -streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) -``` - -## 🔐 Security - -- ✅ Authentication required for score submission -- ✅ Server-side validation of user sessions -- ✅ SQL injection prevention via parameterized queries -- ✅ No sensitive data exposed in frontend -- ✅ 0 vulnerabilities found in security scan - -## 🚢 Deployment Checklist - -- [ ] Create streaks table in production database -- [ ] Verify environment variables are set -- [ ] Test authentication flow works -- [ ] Verify score submission works -- [ ] Test streak calculation with real dates -- [ ] Monitor error logs for issues -- [ ] Test on mobile devices -- [ ] Verify popup animations work smoothly diff --git a/STREAKS_MIGRATION.md b/STREAKS_MIGRATION.md deleted file mode 100644 index 0c91fa2..0000000 --- a/STREAKS_MIGRATION.md +++ /dev/null @@ -1,32 +0,0 @@ -# Database Migration for Streaks Table - -This document describes the database schema changes needed for the streak tracking feature. - -## Streaks Table Schema - -The following SQL should be executed to create the `streaks` table in the database: - -```sql -CREATE TABLE IF NOT EXISTS streaks ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - current_streak_length INTEGER NOT NULL DEFAULT 0, - longest_streak_length INTEGER NOT NULL DEFAULT 0, - current_streak_last_date DATE NOT NULL -); - -CREATE INDEX idx_streaks_user_id ON streaks(user_id); -``` - -## Table Description - -- `user_id`: Foreign key reference to the users table. Primary key for this table. -- `current_streak_length`: The user's current consecutive days streak. -- `longest_streak_length`: The longest streak the user has ever achieved. -- `current_streak_last_date`: The date of the user's last completed puzzle. - -## Notes - -- The streak is updated when a user completes a daily puzzle. -- If a user completes a puzzle the day after their last completion, the streak increments by 1. -- If a user misses a day, the current streak resets to 1 (or 0 if they fail). -- The longest streak is preserved across streak resets. diff --git a/USER_EXPERIENCE.md b/USER_EXPERIENCE.md deleted file mode 100644 index 9b2245e..0000000 --- a/USER_EXPERIENCE.md +++ /dev/null @@ -1,130 +0,0 @@ -# User Experience Guide: Streak Tracking Feature - -This document describes how users will experience the new streak tracking feature. - -## For New/Anonymous Users - -### Before Signing In -1. User plays and completes the daily puzzle -2. A modal appears titled "Track Your Progress! 📊" -3. The modal explains the benefits: - - 🔥 Start your streak - - 🏆 Compete on the leaderboard - - 📈 Track your progress over time -4. User can either: - - Click "Sign In to Start Tracking" → Redirects to Google OAuth - - Click "Maybe Later" → Closes modal, can continue playing - -### User Menu (Header) -- User icon in the top-right corner of the page -- Click to open menu with "Sign in" option -- Available at all times, not just after game completion - -## For Authenticated Users - -### After Signing In -1. User's name appears next to the user icon in the header -2. User menu now shows "Sign Out" option instead of "Sign in" - -### First Daily Puzzle Completion -1. User completes the puzzle (matches all tiles correctly) -2. Score is automatically submitted to the backend -3. A streak popup appears with: - - 🔥 Fire emoji (animated scale-in) - - "Streak Started!" message - - "1 Day" in purple text - - "Keep it up! Come back tomorrow to maintain your streak." -4. User clicks anywhere to dismiss the popup -5. Can then share results as before - -### Subsequent Daily Completions - -#### Consecutive Days (Yesterday was completed) -1. User completes today's puzzle -2. Streak popup shows: - - "Streak Updated!" message - - Current streak count (e.g., "3 Days") - - Encouragement message - -#### After Missing a Day -1. User completes a puzzle after missing one or more days -2. Streak resets to 1 -3. Longest streak is preserved in the database -4. Popup shows "Streak Started!" with "1 Day" - -#### Failed Puzzle (3 Strikes) -1. User gets 3 strikes and fails the puzzle -2. Current streak resets to 0 -3. No popup is shown (game failed) -4. Score is recorded as null in the database - -### Streak Persistence -- Streaks are tracked per user in the database -- Current streak length is shown after each completion -- Longest streak ever achieved is preserved -- Last completion date is tracked to determine continuity - -## UI Components - -### Streak Popup -- **Style**: Clean dialog with purple border, centered content -- **Animation**: Scale-in effect for engagement -- **Dismissal**: Click anywhere outside or on the popup -- **Timing**: Appears 500ms after score submission completes - -### Login Prompt Modal -- **Style**: Full-width dialog with purple border -- **Features**: - - Clear benefit explanations - - Highlighted feature list in purple box - - Two clear action buttons -- **Timing**: Appears 1000ms after puzzle completion (for anonymous users) - -### User Menu -- **Location**: Top-right corner of header -- **Always visible**: Available on all pages -- **States**: - - Not signed in: Shows user icon, menu has "Sign in" option - - Signed in: Shows user name + icon, menu has "Sign Out" option - -## Technical Details - -### Score Submission -- Automatically triggered when game is finished -- One submission per day (duplicates prevented) -- Includes completion time for successful attempts -- Includes null for failed attempts (3 strikes) - -### Streak Calculation -- Checked against yesterday's date -- Yesterday's completion → Increment streak -- Same day completion → No change (duplicate) -- Older than yesterday → Reset to 1 -- Failed game → Reset to 0 - -### Privacy & Security -- Only authenticated users can track streaks -- Scores tied to user account via session -- No sensitive data exposed in frontend -- API validates authentication on every request - -## Error Handling - -### Network Errors -- Failed submissions log errors to console -- User can still share results -- Score can be manually submitted later if needed - -### Database Errors -- Handled gracefully with error responses -- User sees normal game completion flow -- Errors logged on backend for monitoring - -## Future Enhancements (Not in this PR) - -Potential additions that could build on this feature: -- Display longest streak in user profile -- Streak recovery (grace period for missed days) -- Streak milestones and achievements -- Social sharing of streak achievements -- Streak leaderboard alongside time leaderboard diff --git a/app/api/auth/[...nextauth]/route.js b/app/api/auth/[...nextauth]/route.js index 3a6887e..9abc3c2 100644 --- a/app/api/auth/[...nextauth]/route.js +++ b/app/api/auth/[...nextauth]/route.js @@ -1,20 +1,79 @@ import NextAuth from "next-auth" import GoogleProvider from "next-auth/providers/google" +import CredentialsProvider from "next-auth/providers/credentials" import NeonAdapter from "@auth/neon-adapter" import { Pool } from "@neondatabase/serverless" const pool = new Pool({ connectionString: process.env.DATABASE_URL }) +const adapter = NeonAdapter(pool) + +// Build providers list +const providers = [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }), +] + +// Add test credentials provider only in non-production environments +if (process.env.NODE_ENV !== 'production') { + providers.push( + CredentialsProvider({ + id: 'test-credentials', + name: 'Test Login', + credentials: { + email: { label: 'Email', type: 'email' }, + name: { label: 'Name', type: 'text' }, + }, + async authorize(credentials) { + if (!credentials?.email) { + return null + } + + const email = credentials.email + const name = credentials.name || 'Test User' + + // Check if user already exists + let user = await adapter.getUserByEmail(email) + + if (!user) { + // Create user in database (like OAuth would do) + user = await adapter.createUser({ + email, + name, + emailVerified: new Date(), + image: null, + }) + } + + return user + }, + }) + ) +} export const authOptions = { - providers: [ - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - }), - ], - adapter: NeonAdapter(pool), - + providers, + adapter, + // Use JWT for credentials provider (development), database for production OAuth + session: { + strategy: process.env.NODE_ENV !== 'production' ? 'jwt' : 'database', + }, + callbacks: { + // Include user id in the session (for both JWT and database sessions) + async session({ session, token, user }) { + // JWT strategy (credentials provider in development) + if (token?.sub) { + session.user.id = token.sub + } + // Database strategy (OAuth in production) + else if (user?.id) { + session.user.id = user.id + } + return session + }, + }, } const handler = NextAuth(authOptions) diff --git a/app/api/submit-score/route.js b/app/api/submit-score/route.js index 31073dc..9a6fab1 100644 --- a/app/api/submit-score/route.js +++ b/app/api/submit-score/route.js @@ -1,20 +1,34 @@ import { submitDailyScore, updateStreak } from 'app/lib/db/db'; import { NextResponse } from 'next/server'; +import { revalidatePath } from 'next/cache'; export async function POST(request) { try { + console.log(request); const { milliseconds, date } = await request.json(); console.log('Received score submission in POST:', milliseconds); - // Submit the daily score - await submitDailyScore(milliseconds, date); - + const submissionResult = await submitDailyScore(milliseconds, date); + console.log('Submission result:', submissionResult); + // if no new row is returned then a conflict occurred + const dailyAlreadyHasSubmission = submissionResult === null; + // submission is made only if one new row is inserted + const submissionSuccess = submissionResult !== null; // Update the streak (completed if milliseconds is not null) const completed = milliseconds !== null; - const streakData = await updateStreak(completed, date); + console.log('Submission success:', submissionSuccess); + const newStreak = submissionSuccess ? await updateStreak(completed, date) : null; + const streakUpdateSuccess = newStreak !== null; + + if (streakUpdateSuccess) { + revalidatePath('/', 'layout'); + } + return NextResponse.json({ - success: true, - streak: streakData + success: submissionSuccess && streakUpdateSuccess, + submission: submissionResult, + newStreak: streakUpdateSuccess ? newStreak : null, + dailyAlreadyHasSubmission: dailyAlreadyHasSubmission }); } catch (error) { console.error('Error submitting score:', error); diff --git a/app/layout.js b/app/layout.js index edfaefa..080c9ed 100644 --- a/app/layout.js +++ b/app/layout.js @@ -11,6 +11,7 @@ import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; +import { streakIsCurrent } from 'app/lib/utils'; const appBarStyle = { backgroundColor: mahjongTileFace, @@ -20,24 +21,41 @@ const appBarStyle = { async function StreakBanner() { const streakInfo = await getStreakInfo(); - - if (!streakInfo || streakInfo.streak === 0) { - return null; - } - - const fireCount = Math.min(streakInfo.streak, 10); - const fires = Array(fireCount).fill('🔥'); - - return ( -
-
- {fires.map((_, index) => ( - 🔥 - ))} + // TODO also retrieve their daily score and color fires grey if they failed today + console.log(streakInfo); + + if (streakInfo?.lastDate && streakIsCurrent(streakInfo.lastDate)) { + const fireCount = Math.min(streakInfo.streak, 3); + const fires = new Array(fireCount).fill('🔥'); + + return ( +
+
+ {fires.map((_, index) => ( + + 🔥 + + ))} +
+ {streakInfo.streak}
- {streakInfo.streak} -
- ); + ); + } } export default function RootLayout({ children }) { diff --git a/app/lib/db/db.js b/app/lib/db/db.js index 04087fa..96b27d0 100644 --- a/app/lib/db/db.js +++ b/app/lib/db/db.js @@ -1,11 +1,10 @@ "use server"; import { authOptions } from 'app/api/auth/[...nextauth]/route'; -import bcrypt from 'bcrypt'; import { getServerSession } from 'next-auth'; import postgres from 'postgres'; -import { currentDateStr, mkDateStr } from '../utils'; +import { mkDateStr } from '../utils'; -const sql = postgres(process.env.POSTGRES_URL, { ssl: 'require' }); +const sql = postgres(process.env.DATABASE_URL, { ssl: 'require' }); export async function getTopScores(limit = 10) { const scores = await sql` @@ -23,7 +22,7 @@ export async function getTopScores(limit = 10) { * it indicates the user got three strikes and failed to complete the game. * @param {number | null} milliseconds - time taken to complete the game in milliseconds, null if user failed * @param {string} date - date string in YYYY-MM-DD format - * @returns + * @returns {object | null} the inserted row if submission was successful, null if user had already submitted for today */ export async function submitDailyScore(milliseconds, date) { const session = await getServerSession(authOptions); @@ -32,8 +31,6 @@ export async function submitDailyScore(milliseconds, date) { throw new Error('Unauthenticated user tried to submit score'); } - console.log(`Submitting daily score for ${session.user.email}: ${milliseconds} ms on ${date}`); - const result = await sql` INSERT INTO daily_scores (user_id, date, milliseconds) VALUES ((select id from users where email = ${session.user.email}), ${date}, ${milliseconds}) @@ -41,17 +38,23 @@ export async function submitDailyScore(milliseconds, date) { RETURNING *; `; - console.log(`Daily score submission result for ${session.user.email}:`, result); + console.log(`User ${session.user.email} submission result`, result); + return result.length > 0 ? result[0] : null; +} + - if (milliseconds !== null) - console.log(`${session.user.email} submitted a score of ${milliseconds} ms on ${date}`); - else console.log(`${session.user.email} failed to complete today's game on ${date}`); - return result.length === 1 +function streakRowToObj(row) { + return { + streak: row.current_streak_length, + longestStreak: row.longest_streak_length, + lastDate: row.current_streak_last_date ? mkDateStr(row.current_streak_last_date) : null + }; } +const emptyStreakObj = { streak: 0, longestStreak: 0, lastDate: null } /** * Get the user's current streak information - * @returns {Promise<{currentStreak: number, longestStreak: number} | null>} + * @returns {Promise<{streak: number, longestStreak: number, lastDate: string} | null>} */ export async function getStreakInfo() { const session = await getServerSession(authOptions); @@ -61,18 +64,18 @@ export async function getStreakInfo() { } const result = await sql` - SELECT current_streak_length, longest_streak_length, current_streak_last_date + SELECT current_streak_length, longest_streak_length, date(current_streak_last_date) as current_streak_last_date FROM streaks WHERE user_id = (select id from users where email = ${session.user.email}) `; - if (result.length !== 1) { + if (result.length > 1) { throw new Error('Error fetching streak for user ' + session.user.email); + } else if (result.length === 0) { + console.log("No streak data for user " + session.user.email); + return emptyStreakObj; } else { - return { - streak: result[0].current_streak_last_date === currentDateStr() ? 0 : result[0].current_streak_length, - longestStreak: result[0].longest_streak_length - } + return streakRowToObj(result[0]); } } @@ -81,7 +84,7 @@ export async function getStreakInfo() { * Update the user's streak after completing today's puzzle * @param {boolean} completed - whether the user completed the puzzle (true) or failed (false) * @param {string} date - date string in YYYY-MM-DD format - * @returns {Promise<{current_streak_length: number, longest_streak_length: number}>} + * @returns {Promise<{streak: number, longestStreak: number, lastDate: string} | null>} - true if streak was updated successfully */ export async function updateStreak(completed, date) { const session = await getServerSession(authOptions); @@ -122,12 +125,12 @@ export async function updateStreak(completed, date) { ELSE 0 END ), - current_streak_last_date = CASE WHEN ${completed} THEN ${date}::date ELSE streaks.current_streak_last_date END - RETURNING current_streak_length, longest_streak_length; + current_streak_last_date = CASE WHEN ${completed} THEN ${date}::date ELSE NULL END + RETURNING *; `; console.log(`Updated streak for ${session.user.email}:`, result[0]); - return result[0]; + return streakRowToObj(result[0]); } diff --git a/app/lib/db/seed-test-db.js b/app/lib/db/seed-test-db.js deleted file mode 100644 index 2925fca..0000000 --- a/app/lib/db/seed-test-db.js +++ /dev/null @@ -1,96 +0,0 @@ -// async function seedUsers() { -// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; -// await sql` -// CREATE TABLE IF NOT EXISTS users ( -// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, -// name VARCHAR(255) NOT NULL, -// email TEXT NOT NULL UNIQUE, -// password TEXT NOT NULL -// ); -// `; - -// const insertedUsers = await Promise.all( -// users.map(async (user) => { -// const hashedPassword = await bcrypt.hash(user.password, 10); -// return sql` -// INSERT INTO users (id, name, email, password) -// VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword}) -// ON CONFLICT (id) DO NOTHING; -// `; -// }), -// ); - -// return insertedUsers; -// } - -// async function seedInvoices() { -// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; - -// await sql` -// CREATE TABLE IF NOT EXISTS invoices ( -// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, -// customer_id UUID NOT NULL, -// amount INT NOT NULL, -// status VARCHAR(255) NOT NULL, -// date DATE NOT NULL -// ); -// `; - -// const insertedInvoices = await Promise.all( -// invoices.map( -// (invoice) => sql` -// INSERT INTO invoices (customer_id, amount, status, date) -// VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date}) -// ON CONFLICT (id) DO NOTHING; -// `, -// ), -// ); - -// return insertedInvoices; -// } - -// async function seedCustomers() { -// await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; - -// await sql` -// CREATE TABLE IF NOT EXISTS customers ( -// id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, -// name VARCHAR(255) NOT NULL, -// email VARCHAR(255) NOT NULL, -// image_url VARCHAR(255) NOT NULL -// ); -// `; - -// const insertedCustomers = await Promise.all( -// customers.map( -// (customer) => sql` -// INSERT INTO customers (id, name, email, image_url) -// VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url}) -// ON CONFLICT (id) DO NOTHING; -// `, -// ), -// ); - -// return insertedCustomers; -// } - -// async function seedRevenue() { -// await sql` -// CREATE TABLE IF NOT EXISTS revenue ( -// month VARCHAR(4) NOT NULL UNIQUE, -// revenue INT NOT NULL -// ); -// `; - -// const insertedRevenue = await Promise.all( -// revenue.map( -// (rev) => sql` -// INSERT INTO revenue (month, revenue) -// VALUES (${rev.month}, ${rev.revenue}) -// ON CONFLICT (month) DO NOTHING; -// `, -// ), -// ); - -// return insertedRevenue; -// } \ No newline at end of file diff --git a/app/lib/utils.js b/app/lib/utils.js index 7f8d81f..a8133ff 100644 --- a/app/lib/utils.js +++ b/app/lib/utils.js @@ -2,10 +2,13 @@ import seedrandom from "seedrandom" /** * Converts a Date object to a consistent date seed string - * @param {Date} date - the date to convert + * @param {Date | string} date - the date to convert * @returns {string} a seed string based on the date (UTC) */ function mkDateStr(date) { + if (typeof date === 'string') { + date = new Date(date) + } const year = date.getUTCFullYear(); const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // Month is 0-based const day = String(date.getUTCDate()).padStart(2, '0'); @@ -19,13 +22,26 @@ function currentDateStr() { return mkDateStr(new Date()) } +/** + * + * @param {} lastDateStr + * @returns + */ +function streakIsCurrent(lastDateStr) { + const lastDate = new Date(lastDateStr) + const yesterday = new Date() + yesterday.setUTCDate(yesterday.getUTCDate() - 1) + yesterday.setUTCHours(0,0,0,0) + return lastDate >= yesterday +} + /** * Calculate the daily HSK difficulty level (1-5) based on the date seed * @param {string} seed date seed string * @returns {number} HSK level between 1 and 5 */ function getDailyDifficulty(seed) { - const lvlFreqs = [1,2,2,3,3,3,3,4,4,5] // weighted frequencies + const lvlFreqs = [1,2,2,2,3,3,3,3,4,4,4,5] // weighted frequencies return sample(1, lvlFreqs, seed)[0] } @@ -46,4 +62,4 @@ function sample(num, array, seed) { return Array.from(indices).map(i => array[i]) } -export { currentDateStr, mkDateStr, sample, getDailyDifficulty } \ No newline at end of file +export { currentDateStr, mkDateStr, sample, getDailyDifficulty, streakIsCurrent } \ No newline at end of file diff --git a/app/page.js b/app/page.js index b94fc19..db4b178 100644 --- a/app/page.js +++ b/app/page.js @@ -28,7 +28,6 @@ export default async function Page(props) { // Use word list from search params if provided, otherwise get random words let todaysWords - let customWordList = false if (devMode && searchParams?.words) { // Parse comma-separated word list const customWords = searchParams.words @@ -39,7 +38,6 @@ export default async function Page(props) { const validWords = customWords.every(word => isValidWord(word) && word.length === 2) if (validWords) { todaysWords = customWords - customWordList = true console.log(`Using custom word list: ${todaysWords.join(', ')}`) } else { // Show error page for invalid word list diff --git a/app/signin/page.js b/app/signin/page.js deleted file mode 100644 index 89d0249..0000000 --- a/app/signin/page.js +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; -import Link from "next/link"; -import { NotoSerifChinese } from "../ui/fonts"; -import { signIn, useSession } from "next-auth/react" -import { useRouter } from "next/navigation" -import { useEffect } from "react" - -function LoginProviderButton({provider}) { - const { status } = useSession() - const router = useRouter() - - // Redirect to main page once authenticated - useEffect(() => { - if (status === "authenticated") { - router.push("/") - } - }, [status, router]) - - return ( - - ) -} - -export default function Page() { - return ( -
-
-

- Match Chinese words to complete today's daily puzzle -

- - {/* Sign in section */} -
-
- -
- -
-

or

- - Continue as guest - -
-
- - {/* Footer info */} -
-

Sign in with Google to save your daily scores and track your progress.

-
-
-
- ); -} diff --git a/app/ui/date-picker.js b/app/ui/date-picker.js index 3874911..01c1f9d 100644 --- a/app/ui/date-picker.js +++ b/app/ui/date-picker.js @@ -17,8 +17,7 @@ function DatePicker_() { const searchParams = useSearchParams(); // Only show date picker if dev mode is enabled - const devMode = searchParams?.get('dev') === 'true'; - + const devMode = searchParams?.has('dev') if (!devMode) { return null; } diff --git a/app/ui/fonts.js b/app/ui/fonts.js index b5ddf99..98d43c1 100644 --- a/app/ui/fonts.js +++ b/app/ui/fonts.js @@ -1,7 +1,15 @@ import { Noto_Serif_SC, Ma_Shan_Zheng } from 'next/font/google'; -const NotoSerifChinese = Noto_Serif_SC({ weight: ['200', '400', '700'] }); +const NotoSerifChinese = Noto_Serif_SC({ + weight: ['200', '400', '700'], + subsets: ['latin', 'chinese_simplified'], + display: 'swap', +}); -const MaShanZheng = Ma_Shan_Zheng({ weight: ['400'] }); +const MaShanZheng = Ma_Shan_Zheng({ + weight: ['400'], + subsets: ['latin', 'chinese_simplified'], + display: 'swap', +}); export { NotoSerifChinese, MaShanZheng }; diff --git a/app/ui/game-session.js b/app/ui/game-session.js index a3aa4c3..79a3bf5 100644 --- a/app/ui/game-session.js +++ b/app/ui/game-session.js @@ -111,7 +111,7 @@ function timerTotalMilliseconds(stopWatch) { export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, preventStorage, preventRestore }) { const [ currentGameState, dispatch ] = useReducer(gridReducer, initialGridState(shuffledChars)); - const { data: session, status } = useSession(); + const { status } = useSession(); const [showHowTo, setShowHowTo] = useState(true); const [showResumeModal, setShowResumeModal] = useState(false); @@ -119,7 +119,6 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, const [showStreakPopup, setShowStreakPopup] = useState(false); const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [streakData, setStreakData] = useState(null); - // const [scoreSubmitted, setScoreSubmitted] = useState(false); // Initialize stopwatch with saved time if resuming const stopWatch = useStopwatch({ @@ -138,15 +137,15 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, }) .then(res => res.json()) .then(data => { - if (data.success) { - // Mark score as submitted in localStorage - console.log('Score submitted successfully:', data); + console.log('Score submission response:', data); + if (data.success || data.dailyAlreadyHasSubmission) rememberScoreSubmitted(dateSeed); - - + if (data.dailyAlreadyHasSubmission) { + console.log('Score for today has already been submitted.'); + } else if (data.success) { if (milliseconds !== null) { // Show streak popup - setStreakData(data.streak); + setStreakData(data.newStreak); setTimeout(() => setShowStreakPopup(true), 500); } } @@ -175,8 +174,8 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, useEffect(() => { // If user is authenticated and game is completed but score not submitted, submit it // (this can happen if user completed game while unauthenticated, logged in through OAuth, then returned to this page) - if (status === 'authenticated' && gameIsFinished(currentGameState) && !hasSubmittedScore(dateSeed)) { - console.log('Found unsubmitted completed game, submitting score...'); + if ( status === 'authenticated' && gameIsFinished(currentGameState) && !hasSubmittedScore(dateSeed)) { + console.log('Client submitting score...'); submitScore(gameIsCompleted(currentGameState) ? timerTotalMilliseconds(stopWatch) : null); } else if (status === 'unauthenticated' && gameIsCompleted(currentGameState)) { // User is not logged in and has completed the game @@ -249,15 +248,15 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel, "You have a completed game from today. Come back tomorrow for a new zimi!" : "You have an in-progress game from today. Resume where you left off?" } - buttonContent={ gameIsFinished(currentGameState) ? "Look at scores" : "Resume" } + buttonContent={{ gameIsFinished(currentGameState) ? "Look at scores" : "Resume" }} /> {streakData && ( setShowStreakPopup(false)} - streakLength={streakData.current_streak_length} - isNewStreak={streakData.current_streak_length === 1} + streakLength={streakData.streak} + isNewStreak={streakData.streak === 1} /> )} diff --git a/app/ui/how-to-box.js b/app/ui/how-to-box.js index b71495c..6630f72 100644 --- a/app/ui/how-to-box.js +++ b/app/ui/how-to-box.js @@ -47,7 +47,7 @@ export default function HowToBox({ open, onClose, hskLevel }) { } - buttonContent="Start" + buttonContent={Start} /> ); } diff --git a/app/ui/login-prompt-modal.js b/app/ui/login-prompt-modal.js index 2ac6d04..f7c283d 100644 --- a/app/ui/login-prompt-modal.js +++ b/app/ui/login-prompt-modal.js @@ -29,14 +29,11 @@ export default function LoginPromptModal({ open, onClose }) { }} > - Track Your Progress! 📊 + Login to track your streak! - - 🔥 Start your streak - Sign in to track your daily scores, build streaks, and compete with others! @@ -47,11 +44,7 @@ export default function LoginPromptModal({ open, onClose }) { border: '2px solid #9333ea' }}> - ✨ Keep your streak alive by solving puzzles daily -
- 🏆 Compete on the leaderboard -
- 📈 Track your progress over time + Keep your streak alive by solving the puzzle each day
diff --git a/app/ui/user-menu.js b/app/ui/user-menu.js index a877531..75d2cbb 100644 --- a/app/ui/user-menu.js +++ b/app/ui/user-menu.js @@ -14,7 +14,7 @@ function SignInOutMenuItem({status}) { if (status === "authenticated") { return (Sign Out) } else if (status === "unauthenticated") { - return (Sign in); + return (Sign in); } else { return <> } @@ -43,6 +43,7 @@ export default function UserMenu() { aria-controls={open ? 'user-menu' : undefined} aria-haspopup="true" aria-expanded={open ? 'true' : undefined} + data-testid="user-menu-button" > diff --git a/next.config.js b/next.config.js index 0d60710..2b85a60 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,13 @@ module.exports = { reactStrictMode: true, + logging: { + fetches: { + fullUrl: false, + }, + }, + // Suppress Google Fonts download warnings + onDemandEntries: { + maxInactiveAge: 60 * 1000, + pagesBufferLength: 5, + }, } diff --git a/package-lock.json b/package-lock.json index c40905f..dd7d0fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/seedrandom": "^3.0.0", + "dotenv-cli": "^11.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "next": "^16.0.7", @@ -138,6 +139,7 @@ "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -178,6 +180,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -734,6 +737,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -756,6 +760,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -866,6 +871,7 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -906,6 +912,7 @@ "version": "11.14.1", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1892,6 +1899,7 @@ "version": "7.3.5", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.5", @@ -2105,6 +2113,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.2.tgz", "integrity": "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==", + "peer": true, "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" @@ -2286,6 +2295,7 @@ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "devOptional": true, + "peer": true, "dependencies": { "playwright": "1.56.1" }, @@ -2626,6 +2636,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2854,6 +2865,7 @@ "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2883,6 +2895,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2893,6 +2906,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3477,6 +3491,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3886,6 +3901,64 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-cli": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz", + "integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^17.1.0", + "dotenv-expand": "^12.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5480,6 +5553,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -5937,6 +6011,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -6036,6 +6120,7 @@ "version": "16.0.10", "resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz", "integrity": "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==", + "peer": true, "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", @@ -6148,6 +6233,7 @@ "version": "7.0.11", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -6595,6 +6681,7 @@ "version": "10.27.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -6686,6 +6773,7 @@ "version": "19.2.1", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6694,6 +6782,7 @@ "version": "19.2.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7343,6 +7432,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", diff --git a/package.json b/package.json index 0b2232b..9bf2799 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/seedrandom": "^3.0.0", + "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "next": "^16.0.7", diff --git a/playwright.config.js b/playwright.config.js index 960cc3c..27f6ce2 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -5,9 +5,9 @@ import { defineConfig, devices } from '@playwright/test'; * Read environment variables from file. * https://github.com/motdotla/dotenv */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '.env.test') }); /** * @see https://playwright.dev/docs/test-configuration @@ -25,12 +25,14 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', - /* Global setup to seed test database */ + /* Global setup to clear test database before tests */ globalSetup: './tests/global-setup.ts', + /* Global teardown to clear test database after tests and close connection */ + globalTeardown: './tests/global-teardown.ts', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('')`. */ - baseURL: process.env.BASE_URL || 'http://localhost:3000', + baseURL: process.env.BASE_URL || 'http://localhost:3001', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', @@ -43,15 +45,15 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, /* Test against mobile viewports. */ // { @@ -76,8 +78,9 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'npm run build && npm run start', - url: 'http://localhost:3000', + // command: 'npm run build && npm run start', + command: 'npm run dev -- -p 3001', + url: 'http://localhost:3001', reuseExistingServer: !process.env.CI, timeout: 120000, }, diff --git a/favicon.ico b/public/favicon.ico similarity index 100% rename from favicon.ico rename to public/favicon.ico diff --git a/tests/database-integration.spec.ts b/tests/database-integration.spec.ts new file mode 100644 index 0000000..078ddb5 --- /dev/null +++ b/tests/database-integration.spec.ts @@ -0,0 +1,255 @@ +/** + * Database integration tests + * Simulates playing games and verifies database updates (scores and streaks) + * + * Note: These tests verify that game completion triggers proper database updates + * through the real API endpoints. Database assertions are done via direct queries. + */ + +import { test, expect } from '@playwright/test'; +import { + insertStreakData, + verifyScoreSubmitted, + verifyScoreValue, + verifyStreak, + verifyUserExistsByEmail, +} from './db-test-setup'; +import { closeAllDialogs, closeHowToDialog, getTileByCharacter, loginTestUser } from './helpers'; +import { currentDateStr, mkDateStr } from '../app/lib/utils'; + +test.describe('Database Integration - Score and Streak Submission', () => { + test('scenario 1: new user completes puzzle and score is saved', async ({ page }) => { + const testUser = { + name: 'New Game Player', + email: `new-player-${Date.now()}@test.example.com`, + }; + + const today = currentDateStr(); + + // Step 1: Go to main page and login (creates user via NextAuth) + await page.goto('/'); + await closeHowToDialog(page); + await loginTestUser(page, testUser.email, testUser.name); + + // Step 2: Play a simple 2-word game + // Using 结婚 (2 tiles, 1 pair to match) + console.log('[TEST] Playing game with words: 结婚'); + await page.goto('/?dev=true&words=结婚'); + await closeHowToDialog(page); + + // Step 3: Match the tiles to complete the game + await getTileByCharacter(page, '结').click(); + await getTileByCharacter(page, '婚').click(); + + // Wait for the submission to complete + // Look for any success message or wait for the streak popup + await page.waitForTimeout(2000); + + // Step 4: Verify the user was created in the database + console.log('[TEST] Verifying user exists with email:', testUser.email); + const userExists = await verifyUserExistsByEmail(testUser.email); + console.log('[TEST] User exists:', userExists); + expect(userExists).toBe(true); + + // Step 5: Verify the score was submitted for today + console.log('[TEST] Verifying score submitted for date:', today); + const scoreSubmitted = await verifyScoreSubmitted(testUser.email, today); + console.log('[TEST] Score submitted:', scoreSubmitted); + expect(scoreSubmitted).toBe(true); + + // Step 6: Verify the streak was created with current and longest streak of 1 + const streakVerified = await verifyStreak(testUser.email, 1, 1, today); + expect(streakVerified).toBe(true); + }); + + test('scenario 2: existing user completes second puzzle and streak increments', async ({ page }) => { + const testUser = { + name: 'Streak Player', + email: `streak-player-${Date.now()}@test.example.com`, + }; + + // Calculate yesterday's date + const today = currentDateStr(); + const yesterday = new Date(today); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const yesterdayStr = mkDateStr(yesterday); + + // Step 1: Go to main page and login + await page.goto('/'); + await closeHowToDialog(page); + await loginTestUser(page, testUser.email, testUser.name); + + // Step 2: Play game on yesterday's date to establish initial streak + // This simulates the user completing yesterday's puzzle + await page.goto(`/?dev=true&words=别人&date=${yesterdayStr}`); + await closeHowToDialog(page); + + await getTileByCharacter(page, '别').click(); + await getTileByCharacter(page, '人').click(); + await page.waitForTimeout(2000); + + // Step 3: Verify yesterday's score was recorded + const yesterdayScore = await verifyScoreSubmitted(testUser.email, yesterdayStr); + expect(yesterdayScore).toBe(true); + + // Step 5: Play today's game to continue the streak + await page.goto('/?dev=true&words=结婚'); + await closeHowToDialog(page); + + await getTileByCharacter(page, '结').click(); + await getTileByCharacter(page, '婚').click(); + await page.waitForTimeout(2000); + + // Step 6: Verify today's score was submitted + const todayScore = await verifyScoreSubmitted(testUser.email, today); + expect(todayScore).toBe(true); + + // Step 7: Verify streak was incremented to 2 + // The streak should be 2 because user completed yesterday and today + const streakVerified = await verifyStreak(testUser.email, 2, 2, today); + expect(streakVerified).toBe(true); + }); + + test('scenario 3: user fails puzzle (3 strikes) and streak is reset', async ({ page }) => { + const testUser = { + name: 'Strike User', + email: `strike-player-${Date.now()}@test.example.com`, + }; + + const today = currentDateStr(); + const yesterday = new Date(today); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const yesterdayStr = mkDateStr(yesterday); + + // Step 1: Login + await page.goto('/'); + await closeHowToDialog(page); + await loginTestUser(page, testUser.email, testUser.name); + + // Step 1.5: Insert streak data to simulate existing streak of 3 + await insertStreakData(testUser.email, 3, 5, yesterdayStr); + + // Step 2: Play game with multiple pairs + // Using a 4-word puzzle (8 tiles, 4 pairs) so we have time to make 3 wrong matches + await page.goto('/?dev=true&words=结婚,别人,男生,马上'); + await closeHowToDialog(page); + + // Step 3: Make 3 intentional wrong matches to get 3 strikes and fail + // Strike 1: wrong pair + await getTileByCharacter(page, '结').click(); + await getTileByCharacter(page, '别').click(); + await page.waitForTimeout(700); // Wait for shake animation and deselection + + // Strike 2: wrong pair + await getTileByCharacter(page, '人').click(); + await getTileByCharacter(page, '男').click(); + await page.waitForTimeout(700); + + // Strike 3: wrong pair (this should end the game) + await getTileByCharacter(page, '生').click(); + await getTileByCharacter(page, '马').click(); + await page.waitForTimeout(2000); // Wait for game over state and submission + + // Step 4: Verify score was submitted with null milliseconds (indicating failure) + const scoreSubmitted = await verifyScoreSubmitted(testUser.email, today); + expect(scoreSubmitted).toBe(true); + + const scoreIsNull = await verifyScoreValue(testUser.email, today, null); + expect(scoreIsNull).toBe(true); + + // Step 5: Verify streak was reset to 0 due to failure + const streakVerified = await verifyStreak(testUser.email, 0, 5, null); + expect(streakVerified).toBe(true); + }); + + test('scenario 4: user completes puzzle and updates expired streak', async ({ page }) => { + const testUser = { + name: 'Expired Streak Player', + email: `expired-streak-${Date.now()}@test.example.com`, + }; + + // Calculate dates + const today = currentDateStr(); + const twoDaysAgo = new Date(today); + twoDaysAgo.setUTCDate(twoDaysAgo.getUTCDate() - 2); + const twoDaysAgoStr = mkDateStr(twoDaysAgo); + + // Step 1: Login + await page.goto('/'); + await closeHowToDialog(page); + await loginTestUser(page, testUser.email, testUser.name); + + // Step 2: Insert old streak data to simulate an expired streak + // User had a streak of 7 days, but last completed 2 days ago (streak is expired) + await insertStreakData(testUser.email, 7, 10, twoDaysAgoStr); + + // Step 3: Play today's game to resume with a new streak + await page.goto('/?dev=true&words=结婚'); + await closeHowToDialog(page); + + await getTileByCharacter(page, '结').click(); + await getTileByCharacter(page, '婚').click(); + await page.waitForTimeout(2000); + + // Step 4: Verify today's score was submitted + const scoreSubmitted = await verifyScoreSubmitted(testUser.email, today); + expect(scoreSubmitted).toBe(true); + + // Step 5: Verify streak was reset to 1 (expired streak resets) + // Current streak should be 1 (fresh start today) + // Longest streak should remain 10 (historical max) + // Last date should be today + const streakVerified = await verifyStreak(testUser.email, 1, 10, today); + expect(streakVerified).toBe(true); + }); + + test('scenario 5: user completes puzzle before login, then logs in and score/streak are saved', async ({ page }) => { + const testUser = { + name: 'Login After Play Player', + email: `login-after-${Date.now()}@test.example.com`, + }; + + const today = currentDateStr(); + const yesterday = new Date(today); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const yesterdayStr = mkDateStr(yesterday); + + // Step 1: Go to main page and login to create the user + await page.goto('/'); + await closeHowToDialog(page); + await loginTestUser(page, testUser.email, testUser.name); + + // Step 2: Seed the database with streak data (simulate previous play) + await insertStreakData(testUser.email, 2, 5, yesterdayStr); + + // Step 3: Log out by clearing cookies and reloading + await page.context().clearCookies(); + + // Step 4: Play game without being logged in (anonymous play) + // The game state will be stored locally + await page.goto('/?dev=true&words=结婚'); + await closeHowToDialog(page); + + await getTileByCharacter(page, '结').click(); + await getTileByCharacter(page, '婚').click(); + await page.waitForTimeout(2000); + + // Step 5: Log back in + // This should trigger the score submission and streak update + await closeAllDialogs(page); + await loginTestUser(page, testUser.email, testUser.name); + + await page.waitForTimeout(2000); // Wait for any submissions to complete + // Step 6: Verify the score was submitted for today + const scoreSubmitted = await verifyScoreSubmitted(testUser.email, today); + expect(scoreSubmitted).toBe(true); + + // Step 7: Verify the streak was incremented to 3 (continued from yesterday's streak of 2) + // Current streak should be 3 (yesterday was 2, today continues it) + // Longest streak should remain 5 (historical max) + // Last date should be today + const streakVerified = await verifyStreak(testUser.email, 3, 5, today); + expect(streakVerified).toBe(true); + }); + +}); diff --git a/tests/db-test-setup.ts b/tests/db-test-setup.ts index 63a0c9d..041b6b1 100644 --- a/tests/db-test-setup.ts +++ b/tests/db-test-setup.ts @@ -5,172 +5,180 @@ import postgres from 'postgres'; -// Test database connection - use default import -const dbUrl = process.env.TEST_DATABASE_URL || process.env.POSTGRES_URL; -if (!dbUrl) { - throw new Error('TEST_DATABASE_URL or POSTGRES_URL environment variable must be set for database tests'); -} +const dbUrl = process.env.DATABASE_URL || ''; +console.log('Connecting to database at:', dbUrl); const sql = postgres(dbUrl, { ssl: 'require' }); +/** + * Create database tables if they don't exist + */ +// export async function createTables() { +// try { +// // Create users table +// await sql` +// CREATE TABLE IF NOT EXISTS users ( +// id SERIAL PRIMARY KEY, +// name VARCHAR(255) NOT NULL, +// email TEXT NOT NULL UNIQUE, +// "emailVerified" TIMESTAMP, +// image TEXT +// ) +// `; + +// // Create daily_scores table +// await sql` +// CREATE TABLE IF NOT EXISTS daily_scores ( +// id SERIAL PRIMARY KEY, +// user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, +// date DATE NOT NULL, +// milliseconds INTEGER, +// UNIQUE(user_id, date) +// ) +// `; + +// // Create streaks table +// await sql` +// CREATE TABLE IF NOT EXISTS streaks ( +// id SERIAL PRIMARY KEY, +// user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, +// current_streak_length INTEGER DEFAULT 0, +// longest_streak_length INTEGER DEFAULT 0, +// current_streak_last_date DATE +// ) +// `; + +// console.log('✓ Database tables created'); +// } catch (error) { +// console.error('Error creating tables:', error); +// throw error; +// } +// } + export interface TestUser { - id: number; + id: number name: string; email: string; emailVerified: Date | null; image: string | null; } -export const testUsers: TestUser[] = [ - { - id: 1, - name: 'Test User One', - email: 'test1@example.com', - emailVerified: new Date('2024-01-01'), - image: null, - }, - { - id: 2, - name: 'Test User Two', - email: 'test2@example.com', - emailVerified: new Date('2024-01-01'), - image: null, - }, - { - id: 3, - name: 'Test User Three', - email: 'test3@example.com', - emailVerified: null, - image: null, - }, -]; - /** * Clear all test data from tables */ export async function clearTestData() { - await sql`DELETE FROM daily_scores WHERE user_id IN (1, 2, 3)`; - await sql`DELETE FROM streaks WHERE user_id IN (1, 2, 3)`; - await sql`DELETE FROM users WHERE id IN (1, 2, 3)`; + await sql`truncate table users cascade`; + await sql`truncate table daily_scores cascade`; + await sql`truncate table streaks cascade`; + + console.log('✓ Cleared test data from database'); } +const newTestUser = (id: number, verified: boolean): TestUser => ({ + id, + name: `Test User ${id}`, + email: `test${id}@example.com`, + emailVerified: verified ? new Date('2024-01-01') : null, + image: null, +}) + /** * Seed test users into the database */ -export async function seedTestUsers() { - for (const user of testUsers) { +export async function seedTestUsers(newUsers: TestUser[]) { + for (const user of newUsers) { await sql` INSERT INTO users (id, name, email, "emailVerified", image) VALUES (${user.id}, ${user.name}, ${user.email}, ${user.emailVerified}, ${user.image}) - ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - email = EXCLUDED.email, - "emailVerified" = EXCLUDED."emailVerified", - image = EXCLUDED.image `; } - console.log('✓ Seeded test users'); + console.log('✓ Seeded test users with IDs:', newUsers.map(u => u.id).join(', ')); } -/** - * Seed test streaks into the database - */ -export async function seedTestStreaks() { - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const twoDaysAgo = new Date(today); - twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); - - // User 1: Has a 3-day streak (completed yesterday) - await sql` - INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) - VALUES (1, 3, 5, ${yesterday.toISOString().split('T')[0]}) - ON CONFLICT (user_id) DO UPDATE SET - current_streak_length = EXCLUDED.current_streak_length, - longest_streak_length = EXCLUDED.longest_streak_length, - current_streak_last_date = EXCLUDED.current_streak_last_date - `; - - // User 2: Has a 1-day streak (completed two days ago, so streak will reset) +export async function insertStreakData(email: string, currentStreak: number, longestStreak: number, lastDate: string | null) { await sql` INSERT INTO streaks (user_id, current_streak_length, longest_streak_length, current_streak_last_date) - VALUES (2, 1, 10, ${twoDaysAgo.toISOString().split('T')[0]}) - ON CONFLICT (user_id) DO UPDATE SET - current_streak_length = EXCLUDED.current_streak_length, - longest_streak_length = EXCLUDED.longest_streak_length, - current_streak_last_date = EXCLUDED.current_streak_last_date + VALUES ((SELECT id FROM users WHERE email = ${email}), ${currentStreak}, ${longestStreak}, ${lastDate}) `; - - console.log('✓ Seeded test streaks'); } /** - * Seed test daily scores into the database + * + * @param userId + * @param date date should be of form YYYY-MM-DD + * @param milliseconds null indicates a missed day */ -export async function seedTestDailyScores() { - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const twoDaysAgo = new Date(today); - twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); - const threeDaysAgo = new Date(today); - threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); - - // User 1: Completed puzzles for the last 3 days - await sql` - INSERT INTO daily_scores (user_id, date, milliseconds) - VALUES - (1, ${threeDaysAgo.toISOString().split('T')[0]}, 45000), - (1, ${twoDaysAgo.toISOString().split('T')[0]}, 42000), - (1, ${yesterday.toISOString().split('T')[0]}, 38000) - ON CONFLICT (user_id, date) DO NOTHING - `; - - // User 2: Completed puzzle two days ago only +export async function insertDailyScore(userId: number, date: string, milliseconds: number | null) { await sql` INSERT INTO daily_scores (user_id, date, milliseconds) - VALUES (2, ${twoDaysAgo.toISOString().split('T')[0]}, 50000) - ON CONFLICT (user_id, date) DO NOTHING + VALUES (${userId}, ${date}, ${milliseconds}) `; - - console.log('✓ Seeded test daily scores'); } + /** * Setup all test data */ -export async function setupTestDatabase() { - try { - console.log('Setting up test database...'); - await clearTestData(); - await seedTestUsers(); - await seedTestStreaks(); - await seedTestDailyScores(); - console.log('✓ Test database setup complete'); - } catch (error) { - console.error('Error setting up test database:', error); - throw error; - } +// export async function setupTestDatabase() { +// try { +// console.log('Setting up test database...'); +// // await createTables(); +// await clearTestData(); +// // await seedTestUsers(); +// // await seedTestStreaks(); +// // await seedTestDailyScores(); +// console.log('✓ Test database setup complete'); +// } catch (error) { +// console.error('Error setting up test database:', error); +// throw error; +// } +// } + +export async function verifyUserExists(userId: number): Promise { + const result = await sql` + SELECT * FROM users WHERE id = ${userId} + `; + return result.length === 1; } /** - * Verify that a score was submitted for a user on a specific date + * Verify that a user exists by email */ -export async function verifyScoreSubmitted(userId: number, date: string): Promise { +export async function verifyUserExistsByEmail(email: string): Promise { const result = await sql` - SELECT * FROM daily_scores - WHERE user_id = ${userId} AND date = ${date} + SELECT * FROM users WHERE email = ${email} `; return result.length === 1; } +/** + * Verify that a score was submitted for a user on a specific date + */ +export async function verifyScoreSubmitted(userIdentifier: string | number, date: string): Promise { + let query; + if (typeof userIdentifier === 'string') { + // Assume it's an email + query = sql` + SELECT * FROM daily_scores + WHERE user_id = (SELECT id FROM users WHERE email = ${userIdentifier}) AND date = ${date} + `; + } else { + // It's a user ID + query = sql` + SELECT * FROM daily_scores + WHERE user_id = ${userIdentifier} AND date = ${date} + `; + } + const result = await query; + return result.length === 1; +} + /** * Verify that a score has the expected value */ -export async function verifyScoreValue(userId: number, date: string, milliseconds: number | null): Promise { +export async function verifyScoreValue(email: string, date: string, milliseconds: number | null): Promise { const result = await sql` SELECT * FROM daily_scores - WHERE user_id = ${userId} AND date = ${date} AND milliseconds IS NOT DISTINCT FROM ${milliseconds} + WHERE user_id = (SELECT id FROM users WHERE email = ${email}) AND date = ${date} AND milliseconds IS NOT DISTINCT FROM ${milliseconds} `; return result.length === 1; } @@ -178,10 +186,24 @@ export async function verifyScoreValue(userId: number, date: string, millisecond /** * Get a user's current streak */ -export async function getUserStreak(userId: number) { - const result = await sql` - SELECT * FROM streaks WHERE user_id = ${userId} - `; +export async function getUserStreak(userIdentifier: string | number) { + let query; + if (typeof userIdentifier === 'string') { + // Assume it's an email + query = sql` + SELECT user_id, current_streak_length, longest_streak_length, + date(current_streak_last_date) as current_streak_last_date + FROM streaks WHERE user_id = (SELECT id FROM users WHERE email = ${userIdentifier}) + `; + } else { + // It's a user ID + query = sql` + SELECT user_id, current_streak_length, longest_streak_length, + date(current_streak_last_date) as current_streak_last_date + FROM streaks WHERE user_id = ${userIdentifier} + `; + } + const result = await query; return result.length > 0 ? result[0] : null; } @@ -189,12 +211,12 @@ export async function getUserStreak(userId: number) { * Verify that a user's streak matches expected values */ export async function verifyStreak( - userId: number, + userIdentifier: string | number, expectedCurrentStreak: number, expectedLongestStreak: number, - expectedLastDate?: string + expectedLastDate: string | null ): Promise { - const streak = await getUserStreak(userId); + const streak = await getUserStreak(userIdentifier); if (!streak) return false; const currentMatches = streak.current_streak_length === expectedCurrentStreak; diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 185a1a4..e793b27 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -1,18 +1,20 @@ /** * Global setup for Playwright tests - * Runs once before all tests to prepare the test database + * Runs once before all tests start + * Clears test data to ensure a clean slate */ -import { setupTestDatabase } from './db-test-setup'; +import { clearTestData } from './db-test-setup'; -export default async function globalSetup() { - console.log('\n🚀 Running global test setup...\n'); - +async function globalSetup() { + console.log('🧹 Global setup: clearing test database...'); try { - await setupTestDatabase(); - console.log('\n✅ Global setup complete\n'); + await clearTestData(); + console.log('✓ Database cleared successfully'); } catch (error) { - console.error('\n❌ Global setup failed:', error); + console.error('✗ Error clearing database:', error); throw error; } } + +export default globalSetup; diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts new file mode 100644 index 0000000..3dcbc23 --- /dev/null +++ b/tests/global-teardown.ts @@ -0,0 +1,23 @@ +/** + * Global teardown for Playwright tests + * Runs once after all tests finish + * Clears test data and closes database connection + */ + +import { clearTestData, closeDatabaseConnection } from './db-test-setup'; + +async function globalTeardown() { + console.log('🧹 Global teardown: clearing test database and closing connection...'); + try { + await clearTestData(); + console.log('✓ Database cleared successfully'); + + await closeDatabaseConnection(); + console.log('✓ Database connection closed'); + } catch (error) { + console.error('✗ Error during teardown:', error); + throw error; + } +} + +export default globalTeardown; diff --git a/tests/hanzi-grid.spec.ts b/tests/hanzi-grid.spec.ts index 5d5c75d..3afa21a 100644 --- a/tests/hanzi-grid.spec.ts +++ b/tests/hanzi-grid.spec.ts @@ -4,19 +4,11 @@ import { collectTiles, clickTileByIndex, getSelectedTile, getTileByCharacter, cl test.describe('Two tile custom game', () => { test.beforeEach(async ({ page }) => { // Navigate to the custom game page with 2 tiles - await page.goto('http://localhost:3000/?dev=true&words=结婚'); + await page.goto('/?dev=true&words=结婚'); // Close the "How To" dialog if it appears - // const startButton = page.getByTestId('how-to-start-button'); - - // await expect(startButton).toBeVisible(); - - // await startButton.click() - await closeHowToDialog(page); - // await page.getByTestId('how-to-dialog').waitFor({ state: 'detached' }); - const howToDialog = page.getByTestId('how-to-dialog'); await expect(howToDialog).toHaveCount(0); @@ -39,22 +31,15 @@ test.describe('Two tile custom game', () => { await expect(hun).toHaveAttribute('data-match-color', color!); }); + }); test.describe('HanziGrid Component', () => { test.beforeEach(async ({ page }) => { // Navigate to the game page - await page.goto('http://localhost:3000'); + await page.goto(''); // Close the "How To" dialog if it appears - // const startButton = page.getByTestId('how-to-start-button'); - - // await expect(startButton).toBeVisible(); - - // await startButton.click() - - // await page.getByTestId('how-to-dialog').waitFor({ state: 'detached' }); - await closeHowToDialog(page); const howToDialog = page.getByTestId('how-to-dialog'); @@ -73,7 +58,7 @@ test.describe('HanziGrid Component', () => { }); test('should render 16 tiles in a 4x4 grid', async ({ page }) => { - const tiles = await collectTiles(page); + const tiles = collectTiles(page); await expect(tiles).toHaveCount(16); }); @@ -137,7 +122,7 @@ test.describe('HanziGrid Component', () => { test('should show timer display', async ({ page }) => { // Look for timer - const timer = await page.getByTestId('timer-display'); + const timer = page.getByTestId('timer-display'); await expect(timer).toBeVisible(); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 678aa9b..8034070 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -6,8 +6,6 @@ import { Page, Locator } from '@playwright/test'; export interface GameState { tileStates: Array<{ char: string; match: number | null; color: string | null; shaking: boolean; }>; - selectedTile: number | null; - completed: boolean; strikes: number; } @@ -18,6 +16,14 @@ export interface SavedGameState { milliseconds: number; } +export function howToDialog(page: Page): Locator { + return page.getByTestId('how-to-dialog'); +} + +export function resumeGameDialog(page: Page): Locator { + return page.getByTestId('resume-game-dialog'); +} + export function getGridElement(page: Page): Locator { return page.getByTestId('hanzi-grid'); } @@ -49,6 +55,69 @@ export async function closeHowToDialog(page: Page): Promise { await page.getByTestId('how-to-dialog').waitFor({ state: 'detached' }); } +/** + * Close any open dialog modals on the page + * Waits for all dialogs to be closed before returning + * @param page + */ +export async function closeAllDialogs(page: Page): Promise { + // Check if any dialogs exist (MUI Dialog uses role="dialog") + const dialogs = page.locator('[role="dialog"]'); + const dialogCount = await dialogs.count(); + + if (dialogCount === 0) { + return; // No dialogs open + } + + // Press ESC to close the topmost dialog + await page.press('body', 'Escape'); + + // Wait for dialogs to be detached and recursively close any remaining dialogs + await page.waitForTimeout(300); + + // Recursively check if more dialogs exist + const remainingDialogs = await page.locator('[role="dialog"]').count(); + if (remainingDialogs > 0) { + await closeAllDialogs(page); // Recursively close remaining dialogs + } +} + +/** + * Login a test user assuming on main page and no user is logged in + * @param page + * @param email + * @param name + */ +export async function loginTestUser(page: Page, email: string, name: string): Promise { + const returnTo = page.url(); + + // console.log(`[TEST] Starting login for email: ${email}`); + // console.log(`[TEST] Current page URL before login: ${returnTo}`); + + // Step 2: Click the user menu button + // console.log('[TEST] Clicking user menu button...'); + await page.getByTestId('user-menu-button').click(); + // await page.waitForTimeout(500); + + // Step 3: Click the "Sign in" menu item + // console.log('[TEST] Clicking sign-in menu item...'); + await page.getByTestId('sign-in-menu-item').click(); + // await page.waitForTimeout(500); + + // Step 4: Fill in the test credentials form + // console.log('[TEST] Filling in credentials form...'); + await page.getByRole('textbox', { name: /email/i }).fill(email); + // await page.waitForTimeout(300); + await page.getByRole('textbox', { name: /name/i }).fill(name); + // await page.waitForTimeout(300); + + // Click the sign in button for the test credentials provider + // console.log('[TEST] Clicking sign-in button...'); + await page.getByRole('button', { name: /sign in with test login/i }).click(); + + await page.waitForURL(returnTo, {timeout: 1000}) +} + export async function retrieveLocalSave(page: Page): Promise { return await page.evaluate(() => { const item = localStorage.getItem('zimi-save'); diff --git a/tests/local-storage.spec.ts b/tests/local-storage.spec.ts index f69f302..1b7e2b7 100644 --- a/tests/local-storage.spec.ts +++ b/tests/local-storage.spec.ts @@ -1,23 +1,28 @@ import { test, expect } from '@playwright/test'; -import { clickTileByIndex, closeHowToDialog, retrieveLocalSave } from './helpers'; +import { clickTileByIndex, closeHowToDialog, getTileByCharacter, retrieveLocalSave } from './helpers'; test.describe('LocalStorage Game State', () => { test.beforeEach(async ({ page }) => { - await page.goto('http://localhost:3000'); + await page.goto(''); // Ensure localStorage is cleared before each test (sanity check) expect(await retrieveLocalSave(page)).toBeNull(); }); - test('should save game state to localStorage when playing', async ({ page }) => { - await page.goto('http://localhost:3000'); + test('should save unfinished game state correctly', async ({ page }) => { + await page.goto('/?words=钱包,别人,男生,马上,请假,有时,前天,后边&dev=true'); await closeHowToDialog(page); // Click a few tiles to create some game state - await clickTileByIndex(page, 0); - await clickTileByIndex(page, 4); + // match 钱包 + await getTileByCharacter(page, '钱').click(); + await getTileByCharacter(page, '包').click(); + + // mismatch 马前 + await getTileByCharacter(page, '马').click(); + await getTileByCharacter(page, '前').click(); - await page.waitForTimeout(1000); // Let it save + await page.reload(); // Check that localStorage has saved game data const savedData = await retrieveLocalSave(page); @@ -32,20 +37,33 @@ test.describe('LocalStorage Game State', () => { expect(savedDate.getUTCMonth()).toEqual(today.getUTCMonth()); expect(savedDate.getUTCDate()).toEqual(today.getUTCDate()); - const { tileStates, strikes, completed } = savedData!.game; + const { tileStates, strikes } = savedData!.game; - expect(completed).toBeFalsy(); - expect([0,1].includes(strikes)).toBeTruthy(); - - const numMatches = tileStates.filter(t => t.match !== null).length; + // expect one strike and one matched pair + expect(strikes).toEqual(1); + expect(tileStates.every(t => !"钱包".includes(t.char) || t.match !== null)).toBe(true); + + // get rid of resume game dialog + const resumeButton = page.getByTestId('resume-game-button'); + await resumeButton.click(); + // unmatch 钱包 + await getTileByCharacter(page, '钱').click(); - // should have at least one strike or one matched pair - expect(strikes == 1 ? numMatches == 0 : numMatches == 1).toBeTruthy(); + await page.reload(); + + const savedDataAfterUnmatch = await retrieveLocalSave(page); + expect(savedDataAfterUnmatch).not.toBeNull(); + + const { tileStates: tileStatesAfterUnmatch, strikes: strikesAfterUnmatch } = savedDataAfterUnmatch!.game; + + // expect still one strike and zero matched pairs + expect(strikesAfterUnmatch).toEqual(1); + expect(tileStatesAfterUnmatch.every(t => t.match === null)).toBe(true); }); test('should show resume dialog when saved game exists', async ({ page }) => { // First visit: create a saved game - await page.goto('http://localhost:3000'); + await page.goto(''); await closeHowToDialog(page); // Make some progress @@ -65,7 +83,7 @@ test.describe('LocalStorage Game State', () => { test('should restore game state when resuming', async ({ page }) => { // First visit: create a saved game with specific state - await page.goto('http://localhost:3000/'); + await page.goto(''); await closeHowToDialog(page); // try matching two tiles @@ -81,15 +99,10 @@ test.describe('LocalStorage Game State', () => { // Reload the page await page.reload(); - // Resume the game - const resumeDialog = page.getByTestId('resume-game-dialog'); - await expect(resumeDialog).toBeVisible(); - + // // Resume the game const resumeButton = page.getByTestId('resume-game-button'); await resumeButton.click(); - await resumeDialog.waitFor({ state: 'hidden' }); - // Verify tiles still have the same content (same seed) await expect(page.getByTestId('hanzi-tile-0')).toHaveText(tile0Text!); await expect(page.getByTestId('hanzi-tile-4')).toHaveText(tile4Text!); @@ -98,20 +111,21 @@ test.describe('LocalStorage Game State', () => { test('should not show resume dialog for different date', async ({ page }) => { // Visit with one date and create saved game - await page.goto('http://localhost:3000?dev=true&date=2025-01-01'); + await page.goto('/?dev=true&date=2025-01-01'); await closeHowToDialog(page); await clickTileByIndex(page, 0); + await clickTileByIndex(page, 4); await page.waitForTimeout(500); // Visit with different date - await page.goto('http://localhost:3000?dev=true&date=2025-01-02'); + await page.goto('/?dev=true&date=2025-01-02'); // Should show how-to dialog, not resume dialog - const howToDialog = page.getByTestId('how-to-dialog'); + const howToDialog = page.getByTestId('how-to-start-button'); await expect(howToDialog).toBeVisible(); - const resumeDialog = page.getByTestId('resume-game-dialog'); + const resumeDialog = page.getByTestId('resume-game-button'); await expect(resumeDialog).not.toBeVisible(); }); diff --git a/tests/streak-tracking.spec.ts b/tests/streak-tracking.spec.ts deleted file mode 100644 index 3cc5fc1..0000000 --- a/tests/streak-tracking.spec.ts +++ /dev/null @@ -1,337 +0,0 @@ -/** - * End-to-end tests for streak tracking with database integration - * Tests the complete flow of completing puzzles, tracking streaks, and submitting scores - */ - -import { test, expect, Page } from '@playwright/test'; -import { closeHowToDialog, getTileByCharacter } from './helpers'; -import { - testUsers, - verifyScoreSubmitted, - verifyScoreValue, - verifyStreak, - getUserStreak, - setupTestDatabase -} from './db-test-setup'; - -// Helper function to mock NextAuth session -async function mockAuthSession(page: Page, userId: number) { - const user = testUsers.find(u => u.id === userId); - if (!user) throw new Error(`User ${userId} not found`); - - await page.route('**/api/auth/session', route => { - route.fulfill({ - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user: { - name: user.name, - email: user.email, - image: user.image, - }, - expires: '2099-12-31T23:59:59.999Z', - }), - }); - }); -} - -// Helper function to complete a simple 2-character puzzle -async function completePuzzle(page: Page, word: string) { - await closeHowToDialog(page); - - const char1 = getTileByCharacter(page, word[0]); - const char2 = getTileByCharacter(page, word[1]); - - await char1.click(); - await char2.click(); - - // Wait for tiles to be matched - await expect(char1).toHaveAttribute('data-match-color', /.+/); -} - -// Get today's date in YYYY-MM-DD format -function getTodayDateString(): string { - return new Date().toISOString().split('T')[0]; -} - -// Helper to wait for score to be recorded in database -async function waitForScoreRecorded(userId: number, date: string, maxWaitMs: number = 3000): Promise { - const startTime = Date.now(); - while (Date.now() - startTime < maxWaitMs) { - if (await verifyScoreSubmitted(userId, date)) { - return true; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - return false; -} - -test.describe('Streak Tracking - Authenticated User (Already Logged In)', () => { - test.beforeEach(async ({ page }) => { - // Reset test database before each test - await setupTestDatabase(); - - // Mock authentication for User 1 (has 3-day streak, completed yesterday) - await mockAuthSession(page, 1); - }); - - test('should submit score and update streak when authenticated user completes puzzle', async ({ page }) => { - const today = getTodayDateString(); - - // Navigate to game with simple 2-character word - await page.goto('/?dev=true&words=你好&preventRestore=true'); - - // Complete the puzzle - await completePuzzle(page, '你好'); - - // Wait for streak popup to appear - await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); - - // Check that streak length is displayed correctly (should be 4 now: 3 + 1) - const streakText = await page.getByTestId('streak-length').textContent(); - expect(streakText).toContain('4'); - - // Wait for score to be recorded in database - await waitForScoreRecorded(1, today); - - // Check that score was submitted - const scoreSubmitted = await verifyScoreSubmitted(1, today); - expect(scoreSubmitted).toBe(true); - - // Check that streak was updated correctly (3 -> 4) - const streakValid = await verifyStreak(1, 4, 5, today); - expect(streakValid).toBe(true); - }); - - test('should not overwrite existing score if user tries to submit twice', async ({ page }) => { - const today = getTodayDateString(); - - // First completion - await page.goto('/?dev=true&words=你好&preventRestore=true'); - await completePuzzle(page, '你好'); - await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); - await waitForScoreRecorded(1, today); - - // Get the first score - const streak1 = await getUserStreak(1); - const initialStreak = streak1?.current_streak_length; - - // Close streak popup - await page.getByTestId('streak-popup').click(); - await expect(page.getByTestId('streak-popup')).not.toBeVisible(); - - // Try to complete again (simulate clearing cookies and playing again) - await page.goto('/?dev=true&words=测试&preventRestore=true&preventStorage=true'); - await completePuzzle(page, '测试'); - - // Streak popup should appear again - await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); - - // Give API call a moment to complete - await new Promise(resolve => setTimeout(resolve, 500)); - - // But streak should not increase (still same as before) - const streak2 = await getUserStreak(1); - expect(streak2?.current_streak_length).toBe(initialStreak); - - // Should still only have one score for today - const scoreSubmitted = await verifyScoreSubmitted(1, today); - expect(scoreSubmitted).toBe(true); - }); - - test('should reset streak if user misses a day', async ({ page }) => { - // User 2 has a streak from 2 days ago, so it should reset - await mockAuthSession(page, 2); - - const today = getTodayDateString(); - - await page.goto('/?dev=true&words=世界&preventRestore=true'); - await completePuzzle(page, '世界'); - - await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); - - // Streak should show 1 (reset because user missed yesterday) - const streakText = await page.getByTestId('streak-length').textContent(); - expect(streakText).toContain('1'); - - await waitForScoreRecorded(1, today); - - // Verify streak was reset to 1, but longest streak is preserved (10) - const streakValid = await verifyStreak(2, 1, 10, today); - expect(streakValid).toBe(true); - }); - - test('should record failed game (3 strikes) with null score', async ({ page }) => { - const today = getTodayDateString(); - - await page.goto('/?dev=true&words=你好&preventRestore=true'); - await closeHowToDialog(page); - - // Make 3 incorrect guesses to get 3 strikes - const tiles = await page.getByTestId(/^hanzi-tile-/).all(); - - // Click first tile, then different non-matching tiles - await tiles[0].click(); - await tiles[1].click(); // This should create a strike if not matching - - // Wait for shake animation to complete - await page.waitForFunction(() => !document.querySelector('[data-shaking="true"]'), { timeout: 1000 }).catch(() => {}); - - await tiles[0].click(); - await tiles[1].click(); - - await page.waitForFunction(() => !document.querySelector('[data-shaking="true"]'), { timeout: 1000 }).catch(() => {}); - - await tiles[0].click(); - await tiles[1].click(); - - // Wait for strikes indicator to show 3 strikes - await expect(page.getByTestId('strikes-indicator').locator('[data-strike-active="true"]')).toHaveCount(3, { timeout: 3000 }); - - // No streak popup should appear (user failed) - await expect(page.getByTestId('streak-popup')).not.toBeVisible(); - - // Verify null score was recorded - const scoreRecorded = await verifyScoreValue(1, today, null); - expect(scoreRecorded).toBe(true); - - // Verify streak was reset to 0 - const streak = await getUserStreak(1); - expect(streak?.current_streak_length).toBe(0); - }); -}); - -test.describe('Streak Tracking - Unauthenticated User (Login After Completion)', () => { - test.beforeEach(async ({ page }) => { - await setupTestDatabase(); - }); - - test('should show login prompt when unauthenticated user completes puzzle', async ({ page }) => { - // Start without authentication - await page.route('**/api/auth/session', route => { - route.fulfill({ - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - }); - - await page.goto('/?dev=true&words=朋友&preventRestore=true'); - await completePuzzle(page, '朋友'); - - // Login prompt should appear - await expect(page.getByTestId('login-prompt-modal')).toBeVisible({ timeout: 5000 }); - - // Streak popup should NOT appear - await expect(page.getByTestId('streak-popup')).not.toBeVisible(); - }); - - test('should submit pending score after user logs in', async ({ page }) => { - const today = getTodayDateString(); - - // Start without authentication - let isAuthenticated = false; - - await page.route('**/api/auth/session', route => { - if (isAuthenticated) { - route.fulfill({ - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user: { - name: testUsers[2].name, - email: testUsers[2].email, - image: testUsers[2].image, - }, - expires: '2099-12-31T23:59:59.999Z', - }), - }); - } else { - route.fulfill({ - status: 200, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - } - }); - - // Complete puzzle while unauthenticated - await page.goto('/?dev=true&words=学习&preventRestore=true'); - await completePuzzle(page, '学习'); - - // Login prompt should appear - await expect(page.getByTestId('login-prompt-modal')).toBeVisible({ timeout: 5000 }); - - // Simulate login by changing authentication state and reloading - isAuthenticated = true; - await page.reload(); - - // Wait for page to load - grid should be visible - await expect(page.getByTestId('hanzi-grid')).toBeVisible({ timeout: 5000 }); - - // After reload with auth, streak popup should appear - await expect(page.getByTestId('streak-popup')).toBeVisible({ timeout: 5000 }); - - // Streak should be 1 (new streak) - const streakText = await page.getByTestId('streak-length').textContent(); - expect(streakText).toContain('1'); - - await waitForScoreRecorded(1, today); - - // Verify score was submitted for User 3 - const scoreSubmitted = await verifyScoreSubmitted(3, today); - expect(scoreSubmitted).toBe(true); - - // Verify streak was created - const streakValid = await verifyStreak(3, 1, 1, today); - expect(streakValid).toBe(true); - }); -}); - -test.describe('Streak Display', () => { - test.beforeEach(async ({ page }) => { - await setupTestDatabase(); - await mockAuthSession(page, 1); - }); - - test('should display correct streak information in popup', async ({ page }) => { - await page.goto('/?dev=true&words=开心&preventRestore=true'); - await completePuzzle(page, '开心'); - - const streakPopup = page.getByTestId('streak-popup'); - await expect(streakPopup).toBeVisible({ timeout: 5000 }); - - // Check streak length is displayed - const streakLength = page.getByTestId('streak-length'); - await expect(streakLength).toBeVisible(); - await expect(streakLength).toContainText('4'); - - // Check "Days" or "Day" text is present - await expect(streakLength).toContainText('Days'); - }); - - test('should show "Streak Started!" for new streak', async ({ page }) => { - // User 3 has no streak yet - await mockAuthSession(page, 3); - - await page.goto('/?dev=true&words=快乐&preventRestore=true'); - await completePuzzle(page, '快乐'); - - const streakPopup = page.getByTestId('streak-popup'); - await expect(streakPopup).toBeVisible({ timeout: 5000 }); - - // Should show "Streak Started!" for first day - await expect(streakPopup).toContainText('Streak Started!'); - }); - - test('should show "Streak Updated!" for continuing streak', async ({ page }) => { - await page.goto('/?dev=true&words=努力&preventRestore=true'); - await completePuzzle(page, '努力'); - - const streakPopup = page.getByTestId('streak-popup'); - await expect(streakPopup).toBeVisible({ timeout: 5000 }); - - // Should show "Streak Updated!" when continuing - await expect(streakPopup).toContainText('Streak Updated!'); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 822d1e3..b86eb1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,7 +41,8 @@ ".next/types/**/*.ts", "__tests__/**/*", "tests/**/*", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + ".next/dev/dev/types/**/*.ts" ], "exclude": [ "node_modules" From 12c014ce9c0ca17d8c516d4218bd51cb7982c3c5 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Mon, 19 Jan 2026 15:49:25 -0700 Subject: [PATCH 25/31] add dotenv --- next-env.d.ts | 2 +- package-lock.json | 66 ++++---------------------------------------- package.json | 2 +- playwright.config.js | 4 ++- tests/helpers.ts | 12 -------- 5 files changed, 11 insertions(+), 75 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index dd7d0fb..9e42fd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "zimi", "dependencies": { "@auth/neon-adapter": "^1.11.1", "@emotion/react": "^11.14.0", @@ -12,6 +13,7 @@ "@mui/material": "^7.3.5", "@neondatabase/serverless": "^1.0.2", "bcrypt": "^6.0.0", + "dotenv": "^17.2.3", "immer": "^10.2.0", "motion": "^12.23.25", "net": "^1.0.2", @@ -35,7 +37,6 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/seedrandom": "^3.0.0", - "dotenv-cli": "^11.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "next": "^16.0.7", @@ -3905,52 +3906,6 @@ "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-cli": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz", - "integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6", - "dotenv": "^17.1.0", - "dotenv-expand": "^12.0.0", - "minimist": "^1.2.6" - }, - "bin": { - "dotenv": "cli.js" - } - }, - "node_modules/dotenv-expand": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", - "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -6011,16 +5966,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -6678,9 +6623,10 @@ } }, "node_modules/preact": { - "version": "10.27.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", - "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", + "version": "10.28.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", + "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", + "license": "MIT", "peer": true, "funding": { "type": "opencollective", diff --git a/package.json b/package.json index 9bf2799..8d10c91 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@mui/material": "^7.3.5", "@neondatabase/serverless": "^1.0.2", "bcrypt": "^6.0.0", + "dotenv": "^17.2.3", "immer": "^10.2.0", "motion": "^12.23.25", "net": "^1.0.2", @@ -37,7 +38,6 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/seedrandom": "^3.0.0", - "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "next": "^16.0.7", diff --git a/playwright.config.js b/playwright.config.js index 27f6ce2..8878a18 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -6,9 +6,11 @@ import { defineConfig, devices } from '@playwright/test'; * https://github.com/motdotla/dotenv */ import dotenv from 'dotenv'; -import path from 'path'; +import path from 'node:path'; dotenv.config({ path: path.resolve(__dirname, '.env.test') }); +console.log('Using BASE_URL:', process.env.DATABASE_URL); + /** * @see https://playwright.dev/docs/test-configuration */ diff --git a/tests/helpers.ts b/tests/helpers.ts index 8034070..30bd133 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -90,29 +90,17 @@ export async function closeAllDialogs(page: Page): Promise { */ export async function loginTestUser(page: Page, email: string, name: string): Promise { const returnTo = page.url(); - - // console.log(`[TEST] Starting login for email: ${email}`); - // console.log(`[TEST] Current page URL before login: ${returnTo}`); - // Step 2: Click the user menu button - // console.log('[TEST] Clicking user menu button...'); await page.getByTestId('user-menu-button').click(); - // await page.waitForTimeout(500); // Step 3: Click the "Sign in" menu item - // console.log('[TEST] Clicking sign-in menu item...'); await page.getByTestId('sign-in-menu-item').click(); - // await page.waitForTimeout(500); // Step 4: Fill in the test credentials form - // console.log('[TEST] Filling in credentials form...'); await page.getByRole('textbox', { name: /email/i }).fill(email); - // await page.waitForTimeout(300); await page.getByRole('textbox', { name: /name/i }).fill(name); - // await page.waitForTimeout(300); // Click the sign in button for the test credentials provider - // console.log('[TEST] Clicking sign-in button...'); await page.getByRole('button', { name: /sign in with test login/i }).click(); await page.waitForURL(returnTo, {timeout: 1000}) From f7ce9986ac27242989ba2fb28dae6ccb00954c83 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Mon, 19 Jan 2026 16:00:16 -0700 Subject: [PATCH 26/31] debug workflow statement --- .github/workflows/neon_workflow.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index 9ec0252..6de32eb 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -73,6 +73,12 @@ jobs: run: npm ci - name: Install Playwright Browsers run: npx playwright install --with-deps + - name: Debug - Check DATABASE_URL + env: + DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + run: | + echo "DATABASE_URL is set: ${DATABASE_URL:+yes}" + echo "DATABASE_URL length: ${#DATABASE_URL}" - name: Run Playwright E2E tests env: # Use the branched database URL from Neon workflow From 2c546bd499e5e56bd1836d4a9ff7d251ee40b9ea Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Mon, 19 Jan 2026 16:11:31 -0700 Subject: [PATCH 27/31] workflow debugging --- .github/workflows/neon_workflow.yml | 9 +++++ .github/workflows/test.yml | 57 ----------------------------- 2 files changed, 9 insertions(+), 57 deletions(-) delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index 6de32eb..57d0fc8 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -59,10 +59,19 @@ jobs: branch_name: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} api_key: ${{ secrets.NEON_API_KEY }} expires_at: ${{ env.EXPIRES_AT }} + - name: Debug - Print Neon outputs + run: | + echo "db_url: ${{ steps.create_neon_branch.outputs.db_url }}" + echo "db_url_with_pooler: ${{ steps.create_neon_branch.outputs.db_url_with_pooler }}" run_tests: name: Run E2E Tests with Neon Branch needs: create_neon_branch + if: | + github.event_name == 'pull_request' && ( + github.event.action == 'synchronize' + || github.event.action == 'opened' + || github.event.action == 'reopened') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 9753784..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Test Suite -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - workflow_run: - workflows: ["Create/Delete Branch for Pull Request"] - types: - - completed - -env: - NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} - -jobs: - jest: - name: Jest Unit Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm ci - - name: Run Jest tests - run: npm test - - playwright: - name: Playwright E2E Tests - timeout-minutes: 60 - runs-on: ubuntu-latest - # Run after Jest tests pass - needs: jest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm ci - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - name: Run Playwright tests - run: npm run test:e2e - env: - # For PR tests, these would be set by Neon branching workflow - # For main/master branch, use the default POSTGRES_URL secret - TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} - POSTGRES_URL: ${{ secrets.POSTGRES_URL }} - DATABASE_URL: ${{ secrets.DATABASE_URL || secrets.POSTGRES_URL }} - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 From 268b73b18957717575efa5ecbaccd2a75fef6f0a Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Mon, 19 Jan 2026 16:17:01 -0700 Subject: [PATCH 28/31] pooled url is not returned by neondb action, just use unpooled url --- .github/workflows/neon_workflow.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index 57d0fc8..b8a83ae 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -84,16 +84,16 @@ jobs: run: npx playwright install --with-deps - name: Debug - Check DATABASE_URL env: - DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url }} run: | echo "DATABASE_URL is set: ${DATABASE_URL:+yes}" echo "DATABASE_URL length: ${#DATABASE_URL}" - name: Run Playwright E2E tests env: # Use the branched database URL from Neon workflow - # TEST_DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} - POSTGRES_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} - DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + # TEST_DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url }} + POSTGRES_URL: ${{ needs.create_neon_branch.outputs.db_url }} + DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url }} NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} run: npm run test:e2e - uses: actions/upload-artifact@v4 From 31258a2f60075948c5319c04171ed352571d2a81 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Mon, 19 Jan 2026 16:26:09 -0700 Subject: [PATCH 29/31] put e2e tests in branch job --- .github/workflows/neon_workflow.yml | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index b8a83ae..6cf6a6d 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -35,11 +35,8 @@ jobs: - name: Run Jest tests run: npm test - create_neon_branch: - name: Create Neon Branch - outputs: - db_url: ${{ steps.create_neon_branch.outputs.db_url }} - db_url_with_pooler: ${{ steps.create_neon_branch.outputs.db_url_with_pooler }} + create_and_test_neon_branch: + name: Create Neon Branch and Run E2E Tests needs: setup if: | github.event_name == 'pull_request' && ( @@ -63,17 +60,6 @@ jobs: run: | echo "db_url: ${{ steps.create_neon_branch.outputs.db_url }}" echo "db_url_with_pooler: ${{ steps.create_neon_branch.outputs.db_url_with_pooler }}" - - run_tests: - name: Run E2E Tests with Neon Branch - needs: create_neon_branch - if: | - github.event_name == 'pull_request' && ( - github.event.action == 'synchronize' - || github.event.action == 'opened' - || github.event.action == 'reopened') - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: @@ -84,16 +70,14 @@ jobs: run: npx playwright install --with-deps - name: Debug - Check DATABASE_URL env: - DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url }} + DATABASE_URL: ${{ steps.create_neon_branch.outputs.db_url }} run: | echo "DATABASE_URL is set: ${DATABASE_URL:+yes}" echo "DATABASE_URL length: ${#DATABASE_URL}" - name: Run Playwright E2E tests env: - # Use the branched database URL from Neon workflow - # TEST_DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url }} - POSTGRES_URL: ${{ needs.create_neon_branch.outputs.db_url }} - DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url }} + DATABASE_URL: ${{ steps.create_neon_branch.outputs.db_url }} + POSTGRES_URL: ${{ steps.create_neon_branch.outputs.db_url }} NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} run: npm run test:e2e - uses: actions/upload-artifact@v4 From 11ed8fe83dfede9c912e3350ded807c0e2387d60 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Mon, 19 Jan 2026 16:55:31 -0700 Subject: [PATCH 30/31] . --- .github/workflows/neon_workflow.yml | 6 ++++-- tests/helpers.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index 6cf6a6d..2744d57 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -44,6 +44,8 @@ jobs: || github.event.action == 'opened' || github.event.action == 'reopened') runs-on: ubuntu-latest + env: + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} steps: - name: Get branch expiration date as an env variable (2 weeks from now) id: get_expiration_date @@ -56,6 +58,8 @@ jobs: branch_name: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} api_key: ${{ secrets.NEON_API_KEY }} expires_at: ${{ env.EXPIRES_AT }} + - name: Set DATABASE_URL from Neon branch + run: echo "DATABASE_URL=${{ steps.create_neon_branch.outputs.db_url }}" >> $GITHUB_ENV - name: Debug - Print Neon outputs run: | echo "db_url: ${{ steps.create_neon_branch.outputs.db_url }}" @@ -76,9 +80,7 @@ jobs: echo "DATABASE_URL length: ${#DATABASE_URL}" - name: Run Playwright E2E tests env: - DATABASE_URL: ${{ steps.create_neon_branch.outputs.db_url }} POSTGRES_URL: ${{ steps.create_neon_branch.outputs.db_url }} - NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} run: npm run test:e2e - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} diff --git a/tests/helpers.ts b/tests/helpers.ts index 30bd133..e2eaf8a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -103,7 +103,7 @@ export async function loginTestUser(page: Page, email: string, name: string): Pr // Click the sign in button for the test credentials provider await page.getByRole('button', { name: /sign in with test login/i }).click(); - await page.waitForURL(returnTo, {timeout: 1000}) + await page.waitForURL(returnTo, {timeout: 10000}) } export async function retrieveLocalSave(page: Page): Promise { From b60df487a7dfc62460e80646e37fea9506358c38 Mon Sep 17 00:00:00 2001 From: Nicolas Winsten Date: Mon, 19 Jan 2026 20:03:00 -0700 Subject: [PATCH 31/31] set NEXTAUTH_URL in workflow --- .github/workflows/neon_workflow.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml index 2744d57..a481137 100644 --- a/.github/workflows/neon_workflow.yml +++ b/.github/workflows/neon_workflow.yml @@ -46,6 +46,7 @@ jobs: runs-on: ubuntu-latest env: NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + NEXTAUTH_URL: http://localhost:3001 # URL of main page for testing after authoriztion redirect steps: - name: Get branch expiration date as an env variable (2 weeks from now) id: get_expiration_date