diff --git a/.github/workflows/neon_workflow.yml b/.github/workflows/neon_workflow.yml new file mode 100644 index 0000000..a481137 --- /dev/null +++ b/.github/workflows/neon_workflow.yml @@ -0,0 +1,141 @@ +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 + + 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_and_test_neon_branch: + name: Create Neon Branch and Run E2E Tests + needs: setup + if: | + github.event_name == 'pull_request' && ( + github.event.action == 'synchronize' + || github.event.action == 'opened' + || github.event.action == 'reopened') + 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 + 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 }} + - 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 }}" + echo "db_url_with_pooler: ${{ steps.create_neon_branch.outputs.db_url_with_pooler }}" + - 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: Debug - Check DATABASE_URL + env: + 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: + POSTGRES_URL: ${{ steps.create_neon_branch.outputs.db_url }} + run: npm run test:e2e + - 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. +# 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 }} + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index d6504d9..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Test Suite -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] -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 - 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 - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 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 new file mode 100644 index 0000000..9a6fab1 --- /dev/null +++ b/app/api/submit-score/route.js @@ -0,0 +1,40 @@ +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); + 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; + 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: submissionSuccess && streakUpdateSuccess, + submission: submissionResult, + newStreak: streakUpdateSuccess ? newStreak : null, + dailyAlreadyHasSubmission: dailyAlreadyHasSubmission + }); + } 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..080c9ed 100644 --- a/app/layout.js +++ b/app/layout.js @@ -5,10 +5,57 @@ 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'; +import { streakIsCurrent } from 'app/lib/utils'; -const styleClass = { +const appBarStyle = { backgroundColor: mahjongTileFace, + boxShadow: 3, + borderBottom: '4px solid #a855f7', +} + +async function StreakBanner() { + const streakInfo = await getStreakInfo(); + // 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} +
+ ); + } } export default function RootLayout({ children }) { @@ -19,30 +66,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 5ba0861..96b27d0 100644 --- a/app/lib/db/db.js +++ b/app/lib/db/db.js @@ -1,10 +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 { 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` @@ -19,123 +19,118 @@ 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 - * @returns + * 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 {object | null} the inserted row if submission was successful, null if user had already submitted for today */ -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'); } 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 *; `; - 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(`User ${session.user.email} submission result`, result); + return result.length > 0 ? result[0] : null; +} + + +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<{streak: number, longestStreak: number, lastDate: string} | null>} + */ +export async function getStreakInfo() { + const session = await getServerSession(authOptions); + + if (session == null) { + return null; + } + + const result = await sql` + 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) { + 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 streakRowToObj(result[0]); + } + +} + +/** + * 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<{streak: number, longestStreak: number, lastDate: string} | null>} - true if streak was updated successfully + */ +export async function updateStreak(completed, date) { + const session = await getServerSession(authOptions); + + if (session == null) { + throw new Error('Unauthenticated user tried to update streak'); + } + + // 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); + + // 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 NULL END + RETURNING *; + `; + + console.log(`Updated streak for ${session.user.email}:`, result[0]); + + return streakRowToObj(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/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 9d47916..db4b178 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) { @@ -26,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 @@ -37,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 @@ -56,7 +56,15 @@ export default async function Page(props) { return (
- +
); } 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/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 e0f2606..79a3bf5 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"; @@ -47,6 +50,32 @@ function saveLocalState(gameState, milliseconds, dateSeed, words) { } } +/** + * 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 @@ -76,12 +105,20 @@ function retrieveLocalState(dateStr, currentWords) { } } -export default function GameSession({ words, shuffledChars, dateSeed, hskLevel }) { +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 { 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); // Initialize stopwatch with saved time if resuming const stopWatch = useStopwatch({ @@ -89,13 +126,40 @@ 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', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ milliseconds, date: dateSeed }), + }) + .then(res => res.json()) + .then(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.newStreak); + setTimeout(() => setShowStreakPopup(true), 500); + } + } + return data; + }) + .catch(error => { + console.error('Error submitting score:', error.message || error); + throw error; + }); + }; // 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); @@ -107,18 +171,49 @@ export default function GameSession({ words, shuffledChars, dateSeed, hskLevel } } }, [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('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 + // Show login prompt + setTimeout(() => setShowLoginPrompt(true), 1000); + } + }, [dateSeed, status, currentGameState]); + 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, timerTotalMilliseconds(stopWatch), dateSeed, words); }, [currentGameState, dateSeed, words]); + // Submit score when game is finished + // 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, status]); // 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); + if (gameBegun && !gameIsFinished(currentGameState) && !preventStorage) { + saveLocalState(currentGameState, timerTotalMilliseconds(stopWatch), dateSeed, words); } }; @@ -153,7 +248,21 @@ 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.streak} + isNewStreak={streakData.streak === 1} + /> + )} + + setShowLoginPrompt(false)} />
@@ -171,7 +280,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) }} 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 new file mode 100644 index 0000000..f7c283d --- /dev/null +++ b/app/ui/login-prompt-modal.js @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box +} from '@mui/material'; +import { signIn } from 'next-auth/react'; + +export default function LoginPromptModal({ open, onClose }) { + const handleSignIn = () => { + signIn(); + }; + + return ( + + + Login to track your streak! + + + + + + Sign in to track your daily scores, build streaks, and compete with others! + + + + Keep your streak alive by solving the puzzle each day + + + + + + + + + + + ); +} diff --git a/app/ui/streak-popup.js b/app/ui/streak-popup.js new file mode 100644 index 0000000..d8df25e --- /dev/null +++ b/app/ui/streak-popup.js @@ -0,0 +1,45 @@ +'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/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..75d2cbb 100644 --- a/app/ui/user-menu.js +++ b/app/ui/user-menu.js @@ -8,12 +8,13 @@ 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") { return (Sign Out) } else if (status === "unauthenticated") { - return (Sign in); + return (Sign in); } else { return <> } @@ -36,13 +37,13 @@ export default function UserMenu() { return (
- {session && (

{session.user.name}

)} @@ -57,6 +58,7 @@ export default function UserMenu() { transformOrigin={{ horizontal: 'right', vertical: 'top' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > + {session && ({session.user.name})}
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/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 f8214ca..9e42fd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,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", @@ -139,6 +140,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" @@ -179,6 +181,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", @@ -735,6 +738,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -757,6 +761,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -867,6 +872,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", @@ -907,6 +913,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", @@ -1893,6 +1900,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", @@ -2106,6 +2114,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" @@ -2287,6 +2296,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" }, @@ -2627,6 +2637,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", @@ -2855,6 +2866,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" } @@ -2884,6 +2896,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" } @@ -2894,6 +2907,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3478,6 +3492,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3887,6 +3902,18 @@ "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==", + "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", @@ -5481,6 +5508,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", @@ -6037,6 +6065,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", @@ -6149,6 +6178,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" } @@ -6593,9 +6623,11 @@ } }, "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", "url": "https://opencollective.com/preact" @@ -6687,6 +6719,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" } @@ -6695,6 +6728,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" }, @@ -7344,6 +7378,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..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", diff --git a/playwright.config.js b/playwright.config.js index cf12b44..8878a18 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -5,9 +5,11 @@ 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 '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 @@ -25,10 +27,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 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: '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', @@ -41,15 +47,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. */ // { @@ -74,8 +80,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 new file mode 100644 index 0000000..041b6b1 --- /dev/null +++ b/tests/db-test-setup.ts @@ -0,0 +1,242 @@ +/** + * Database test setup script + * Seeds a test database with users, streaks, and daily scores for testing + */ + +import postgres from 'postgres'; + +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 + name: string; + email: string; + emailVerified: Date | null; + image: string | null; +} + +/** + * Clear all test data from tables + */ +export async function clearTestData() { + 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(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}) + `; + } + console.log('✓ Seeded test users with IDs:', newUsers.map(u => u.id).join(', ')); +} + +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 ((SELECT id FROM users WHERE email = ${email}), ${currentStreak}, ${longestStreak}, ${lastDate}) + `; +} + +/** + * + * @param userId + * @param date date should be of form YYYY-MM-DD + * @param milliseconds null indicates a missed day + */ +export async function insertDailyScore(userId: number, date: string, milliseconds: number | null) { + await sql` + INSERT INTO daily_scores (user_id, date, milliseconds) + VALUES (${userId}, ${date}, ${milliseconds}) + `; +} + + +/** + * Setup all test data + */ +// 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 user exists by email + */ +export async function verifyUserExistsByEmail(email: string): Promise { + const result = await sql` + 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(email: string, date: string, milliseconds: number | null): Promise { + const result = await sql` + SELECT * FROM daily_scores + WHERE user_id = (SELECT id FROM users WHERE email = ${email}) AND date = ${date} AND milliseconds IS NOT DISTINCT FROM ${milliseconds} + `; + return result.length === 1; +} + +/** + * Get a user's current streak + */ +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; +} + +/** + * Verify that a user's streak matches expected values + */ +export async function verifyStreak( + userIdentifier: string | number, + expectedCurrentStreak: number, + expectedLongestStreak: number, + expectedLastDate: string | null +): Promise { + const streak = await getUserStreak(userIdentifier); + if (!streak) return false; + + const currentMatches = streak.current_streak_length === expectedCurrentStreak; + const longestMatches = streak.longest_streak_length === expectedLongestStreak; + + // 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; +} + +/** + * 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..e793b27 --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,20 @@ +/** + * Global setup for Playwright tests + * Runs once before all tests start + * Clears test data to ensure a clean slate + */ + +import { clearTestData } from './db-test-setup'; + +async function globalSetup() { + console.log('🧹 Global setup: clearing test database...'); + try { + await clearTestData(); + console.log('✓ Database cleared successfully'); + } catch (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..e2eaf8a 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,57 @@ 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(); + // Step 2: Click the user menu button + await page.getByTestId('user-menu-button').click(); + + // Step 3: Click the "Sign in" menu item + await page.getByTestId('sign-in-menu-item').click(); + + // Step 4: Fill in the test credentials form + await page.getByRole('textbox', { name: /email/i }).fill(email); + await page.getByRole('textbox', { name: /name/i }).fill(name); + + // 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: 10000}) +} + 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/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" 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}>; }