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/.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/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 bad8ebf..db4b178 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"; @@ -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 new file mode 100644 index 0000000..79a3bf5 --- /dev/null +++ b/app/ui/game-session.js @@ -0,0 +1,296 @@ +'use client'; + +import GameView from "./game-view"; +import { useRef, useEffect, useReducer, useState } from "react"; +import { useStopwatch } from "react-timer-hook"; +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"; + + + +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); + } +} + +/** + * 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 } | 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 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({ + autoStart: false, + interval: 20, + }); + + // 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 = preventRestore ? null : 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 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 && !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) && !preventStorage) { + saveLocalState(currentGameState, timerTotalMilliseconds(stopWatch), 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 && } + + { gameIsFinished(currentGameState) ? "Look at scores" : "Resume" }} + /> + + {streakData && ( + setShowStreakPopup(false)} + streakLength={streakData.streak} + isNewStreak={streakData.streak === 1} + /> + )} + + setShowLoginPrompt(false)} + /> + +
+ + { 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 9d4cf14..fd6a9cb 100644 --- a/app/ui/game-view.js +++ b/app/ui/game-view.js @@ -5,84 +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); - 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 ( @@ -132,128 +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 [playedFailAnimation, setPlayedFailAnimation] = 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); - setPlayedFailAnimation(savedGame.game.strikes === 3); - } 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]); - - 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 - 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(); - } +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) && } -
) } diff --git a/app/ui/hanzi-grid.js b/app/ui/hanzi-grid.js index 264d768..d54837f 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,49 @@ 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}`); } } } -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' }); + setSelectedTile(null); } else if (selectedTile !== null) { // check if selected tiles form a word @@ -98,19 +105,17 @@ 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' }); + shakeTiles(tiles); + setSelectedTile(null); dispatch({ type: 'strike'}); - setTimeout(() => { - dispatch({ type: 'clear-shake', tiles }); - }, 500); } } else { - dispatch({ type: 'select', tile: index }); + setSelectedTile(index); } } @@ -123,10 +128,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/app/ui/how-to-box.js b/app/ui/how-to-box.js index 5b62e60..6630f72 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/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/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} + } + + + +
+ ); +} 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 8a278b9..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,10 +13,11 @@ "@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", - "next-auth": "^4.24.13", + "next-auth": "^4.24.7", "nodemailer": "^7.0.10", "postgres": "^3.4.7", "react": "^19.2.1", @@ -79,89 +81,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", @@ -221,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" @@ -261,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", @@ -817,6 +738,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -839,6 +761,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -855,9 +778,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" @@ -949,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", @@ -989,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", @@ -1975,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", @@ -2188,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" @@ -2210,14 +2137,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 +2157,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 +2172,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 +2187,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 +2202,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 +2217,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 +2232,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 +2247,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" ], @@ -2369,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" }, @@ -2709,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", @@ -2856,13 +2785,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", @@ -2944,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" } @@ -2973,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" } @@ -2983,6 +2907,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3567,6 +3492,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3976,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", @@ -5570,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", @@ -6123,11 +6062,12 @@ "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==", + "peer": true, "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 +6080,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": { @@ -6238,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" } @@ -6274,16 +6215,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", @@ -6692,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" @@ -6786,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" } @@ -6794,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" }, @@ -7443,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 fc813a0..8d10c91 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "@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", - "next-auth": "^4.24.13", + "next-auth": "^4.24.7", "nodemailer": "^7.0.10", "postgres": "^3.4.7", "react": "^19.2.1", 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}>; }