diff --git a/packages/app/package.json b/packages/app/package.json index a462c99c..67284f77 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -23,7 +23,7 @@ "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache" }, "dependencies": { - "@auth/prisma-adapter": "^1.0.0", + "@auth/prisma-adapter": "^2.8.0", "@code-racer/wss": "file:../wss", "@hookform/resolvers": "^3.1.1", "@next-auth/prisma-adapter": "^1.0.7", @@ -51,11 +51,11 @@ "clsx": "^1.2.1", "cmdk": "^0.2.0", "lucide-react": "^0.259.0", - "next": "13.4.9", + "next": "^13.5.8", "next-auth": "^4.22.1", "next-themes": "^0.2.1", "nextjs-toploader": "^1.4.2", - "openai": "^3.3.0", + "openai": "^4.89.0", "postcss": "8.4.25", "prettier": "2.8.8", "prettier-plugin-go-template": "0.0.13", @@ -89,7 +89,7 @@ "@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/parser": "^5.61.0", "autoprefixer": "10.4.14", - "cypress": "^12.17.2", + "cypress": "^14.2.0", "eslint": "8.44.0", "eslint-config-next": "13.4.9", "eslint-config-prettier": "^8.8.0", diff --git a/packages/app/prisma/migrations/20250321212642_add_wpm_fields_clean/migration.sql b/packages/app/prisma/migrations/20250321212642_add_wpm_fields_clean/migration.sql new file mode 100644 index 00000000..975493b5 --- /dev/null +++ b/packages/app/prisma/migrations/20250321212642_add_wpm_fields_clean/migration.sql @@ -0,0 +1,196 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER'); + +-- CreateEnum +CREATE TYPE "AchievementType" AS ENUM ('FIRST_RACE', 'FIRST_SNIPPET', 'FIFTH_RACE'); + +-- CreateEnum +CREATE TYPE "VoteType" AS ENUM ('UP', 'DOWN'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT, + "email" TEXT, + "email_verified" TIMESTAMP(3), + "image" TEXT, + "averageAccuracy" DECIMAL(5,2) NOT NULL DEFAULT 0, + "averageCpm" DECIMAL(6,2) NOT NULL DEFAULT 0, + "average_wpm" DECIMAL(6,2) NOT NULL DEFAULT 0, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "bio" TEXT, + "languagesMap" JSONB, + "topLanguages" TEXT[], + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "accounts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "session_token" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification_tokens" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateTable +CREATE TABLE "results" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "accuracy" DECIMAL(5,2) NOT NULL, + "cpm" INTEGER NOT NULL, + "words_per_minute" INTEGER NOT NULL DEFAULT 0, + "taken_time" TEXT NOT NULL, + "error_count" INTEGER, + "snippetId" TEXT NOT NULL, + + CONSTRAINT "results_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "achievements" ( + "user_id" TEXT NOT NULL, + "achievement_type" "AchievementType" NOT NULL, + "unlocked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "achievements_pkey" PRIMARY KEY ("user_id","achievement_type") +); + +-- CreateTable +CREATE TABLE "snippets" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "language" TEXT NOT NULL, + "user_id" TEXT, + "on_review" BOOLEAN NOT NULL DEFAULT false, + "rating" INTEGER NOT NULL DEFAULT 0, + "name" TEXT, + + CONSTRAINT "snippets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "race" ( + "id" TEXT NOT NULL, + "snippet_id" TEXT NOT NULL, + "started_at" TIMESTAMP(3), + "ended_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "race_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "race_participants" ( + "id" TEXT NOT NULL, + "raceId" TEXT NOT NULL, + "user_id" TEXT, + "result_id" TEXT, + + CONSTRAINT "race_participants_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "snippet_votes" ( + "snippetId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "VoteType" NOT NULL, + + CONSTRAINT "snippet_votes_pkey" PRIMARY KEY ("userId","snippetId") +); + +-- CreateTable +CREATE TABLE "notification" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "cta_url" TEXT, + "read" BOOLEAN NOT NULL DEFAULT false, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token"); + +-- CreateIndex +CREATE UNIQUE INDEX "verification_tokens_token_key" ON "verification_tokens"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token"); + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "results" ADD CONSTRAINT "results_snippetId_fkey" FOREIGN KEY ("snippetId") REFERENCES "snippets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "results" ADD CONSTRAINT "results_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "achievements" ADD CONSTRAINT "achievements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "snippets" ADD CONSTRAINT "snippets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "race" ADD CONSTRAINT "race_snippet_id_fkey" FOREIGN KEY ("snippet_id") REFERENCES "snippets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "race_participants" ADD CONSTRAINT "race_participants_raceId_fkey" FOREIGN KEY ("raceId") REFERENCES "race"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "race_participants" ADD CONSTRAINT "race_participants_result_id_fkey" FOREIGN KEY ("result_id") REFERENCES "results"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "race_participants" ADD CONSTRAINT "race_participants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "snippet_votes" ADD CONSTRAINT "snippet_votes_snippetId_fkey" FOREIGN KEY ("snippetId") REFERENCES "snippets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "snippet_votes" ADD CONSTRAINT "snippet_votes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notification" ADD CONSTRAINT "notification_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/app/prisma/migrations/20250321212807_update_wpm_field_names/migration.sql b/packages/app/prisma/migrations/20250321212807_update_wpm_field_names/migration.sql new file mode 100644 index 00000000..9737a531 --- /dev/null +++ b/packages/app/prisma/migrations/20250321212807_update_wpm_field_names/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `words_per_minute` on the `results` table. All the data in the column will be lost. + - You are about to drop the column `average_wpm` on the `users` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "results" DROP COLUMN "words_per_minute", +ADD COLUMN "wpm" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "average_wpm", +ADD COLUMN "averageWpm" DECIMAL(6,2) NOT NULL DEFAULT 0; diff --git a/packages/app/prisma/migrations/20250321213946_update_wpm_field_name/migration.sql b/packages/app/prisma/migrations/20250321213946_update_wpm_field_name/migration.sql new file mode 100644 index 00000000..94782112 --- /dev/null +++ b/packages/app/prisma/migrations/20250321213946_update_wpm_field_name/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `wpm` on the `results` table. All the data in the column will be lost. + - You are about to drop the column `averageWpm` on the `users` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "results" DROP COLUMN "wpm", +ADD COLUMN "words_per_minute" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "averageWpm", +ADD COLUMN "average_wpm" DECIMAL(6,2) NOT NULL DEFAULT 0; diff --git a/packages/app/prisma/migrations/migration_lock.toml b/packages/app/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/packages/app/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/packages/app/prisma/schema.prisma b/packages/app/prisma/schema.prisma index 143b37fe..c2fef00b 100644 --- a/packages/app/prisma/schema.prisma +++ b/packages/app/prisma/schema.prisma @@ -17,6 +17,7 @@ model User { image String? averageAccuracy Decimal @default(0) @db.Decimal(5, 2) averageCpm Decimal @default(0) @db.Decimal(6, 2) + averageWpm Decimal @default(0) @db.Decimal(6, 2) @map("average_wpm") role UserRole @default(USER) bio String? languagesMap Json? @@ -80,6 +81,7 @@ model Result { takenTime String @map("taken_time") errorCount Int? @map("error_count") snippetId String + wpm Int @default(0) @map("words_per_minute") RaceParticipant RaceParticipant[] snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -106,7 +108,7 @@ model Snippet { rating Int @default(0) name String? Race Race[] - Result Result[] + Result Result[] votes SnippetVote[] User User? @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/packages/app/src/app/api/auth/[...nextauth]/route.ts b/packages/app/src/app/api/auth/[...nextauth]/route.ts index d9a9c3ac..40c5a9ff 100644 --- a/packages/app/src/app/api/auth/[...nextauth]/route.ts +++ b/packages/app/src/app/api/auth/[...nextauth]/route.ts @@ -18,7 +18,7 @@ declare module "next-auth" { export const nextAuthOptions = { adapter: PrismaAdapter(prisma), session: { - strategy: "jwt", + strategy: "database", }, secret: env.NEXTAUTH_SECRET, providers: [ @@ -28,46 +28,39 @@ export const nextAuthOptions = { }), ], callbacks: { - async signIn(options) { + async signIn({ user }) { const racerCode = randomstring.generate({ length: 4, charset: "numeric", }); - options.user.email = `${options.user.id}@example.com`; - options.user.name = `Racer ${racerCode}`; - return true; - }, - async jwt({ token, user }) { - const dbUser = await prisma.user.findFirst({ - where: { - email: token.email, - }, + + // Check if user exists + const existingUser = await prisma.user.findUnique({ + where: { email: user.email || `${user.id}@example.com` }, }); - if (!dbUser) { - if (user) { - token.id = user.id; - } - return token; + if (!existingUser) { + // Create new user if doesn't exist + await prisma.user.create({ + data: { + email: user.email || `${user.id}@example.com`, + name: `Racer ${racerCode}`, + averageAccuracy: 0, + averageCpm: 0, + averageWpm: 0, + role: "USER", + image: user.image, + }, + }); } - return { - id: dbUser.id, - name: dbUser.name, - email: dbUser.email, - role: dbUser.role, - picture: dbUser.image, - }; + return true; }, - async session({ token, session }) { - if (token) { - session.user.id = token.id; - session.user.name = token.name; - session.user.email = token.email; - session.user.role = token.role; - session.user.image = token.picture; + async session({ session, user }) { + if (session.user) { + session.user.id = user.id; + session.user.role = user.role; } - return session; }, }, diff --git a/packages/app/src/app/dashboard/_components/performanceComparison.tsx b/packages/app/src/app/dashboard/_components/performanceComparison.tsx index e44de214..02611d6a 100644 --- a/packages/app/src/app/dashboard/_components/performanceComparison.tsx +++ b/packages/app/src/app/dashboard/_components/performanceComparison.tsx @@ -124,12 +124,16 @@ export default function PerformanceComparison({ return ( - Cpm + CPM + WPM Accuracy + + + diff --git a/packages/app/src/app/dashboard/_components/recentRaces.tsx b/packages/app/src/app/dashboard/_components/recentRaces.tsx index 10359470..12f7c514 100644 --- a/packages/app/src/app/dashboard/_components/recentRaces.tsx +++ b/packages/app/src/app/dashboard/_components/recentRaces.tsx @@ -89,7 +89,7 @@ export function RecentRacesTable({ header: () => { return (
- Cpm + CPM @@ -104,6 +104,26 @@ export function RecentRacesTable({ ); }, }, + { + accessorKey: "wpm", + header: () => { + return ( +
+ WPM + + + + + + +

Words per minute

+
+
+
+
+ ); + }, + }, { accessorKey: "createdAt", header: "Date", diff --git a/packages/app/src/app/race/_components/race/race-practice.tsx b/packages/app/src/app/race/_components/race/race-practice.tsx index 1acbe6fb..f25d09eb 100644 --- a/packages/app/src/app/race/_components/race/race-practice.tsx +++ b/packages/app/src/app/race/_components/race/race-practice.tsx @@ -80,6 +80,7 @@ export default function RacePractice({ user, snippet }: RacePracticeProps) { char: input.slice(-1), accuracy: calculateAccuracy(input.length, totalErrors), cpm: calculateCPM(input.length, timeTaken), + wpm: Math.round(calculateCPM(input.length, timeTaken) / 5), time: Date.now(), }, ]) @@ -243,12 +244,14 @@ export default function RacePractice({ user, snippet }: RacePracticeProps) { if (value === code[input.length - 1] && value !== " ") { const currTime = Date.now(); const timeTaken = startTime ? (currTime - startTime.getTime()) / 1000 : 0; + const currentCpm = calculateCPM(input.length, timeTaken); setChartTimeStamp((prev) => [ ...prev, { char: value, accuracy: calculateAccuracy(input.length, totalErrors), - cpm: calculateCPM(input.length, timeTaken), + cpm: currentCpm, + wpm: Math.round(currentCpm / 5), time: currTime, }, ]); @@ -347,12 +350,14 @@ export default function RacePractice({ user, snippet }: RacePracticeProps) { if (e.key !== " ") { const currTime = Date.now(); const timeTaken = startTime ? (currTime - startTime.getTime()) / 1000 : 0; + const currentCpm = calculateCPM(input.length, timeTaken); setChartTimeStamp((prevArray) => [ ...prevArray, { char: e.key, accuracy: calculateAccuracy(input.length, totalErrors), - cpm: calculateCPM(input.length, timeTaken), + cpm: currentCpm, + wpm: Math.round(currentCpm / 5), time: currTime, }, ]); diff --git a/packages/app/src/app/race/_components/race/types.d.ts b/packages/app/src/app/race/_components/race/types.d.ts index 3189ee34..84b94d60 100644 --- a/packages/app/src/app/race/_components/race/types.d.ts +++ b/packages/app/src/app/race/_components/race/types.d.ts @@ -8,5 +8,6 @@ export interface ChartTimeStamp { char: string; accuracy: number; cpm: number; + wpm: number; time: number; } diff --git a/packages/app/src/app/race/actions.ts b/packages/app/src/app/race/actions.ts index b72d5d83..22e02708 100644 --- a/packages/app/src/app/race/actions.ts +++ b/packages/app/src/app/race/actions.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { Prisma } from "@prisma/client"; +import { Decimal } from "@prisma/client/runtime/library"; import { UnauthorizedError } from "@/lib/exceptions/custom-hooks"; import { prisma } from "@/lib/prisma"; import { getCurrentUser } from "@/lib/session"; @@ -20,6 +21,7 @@ export const saveUserResultAction = validatedCallback({ const user = await getCurrentUser(); if (!user) throw new UnauthorizedError(); + if (!user.id) throw new Error("User ID not found"); const userData = await prisma.user.findUnique({ where: { id: user.id }, @@ -64,6 +66,8 @@ export const saveUserResultAction = validatedCallback({ .sort((a, b) => languagesMap[b] - languagesMap[a]) .splice(0, 3); + const wpm = Math.round(input.cpm / 5); // Standard calculation: CPM/5 = WPM + return await prisma.$transaction(async (tx) => { const result = await tx.result.create({ data: { @@ -71,7 +75,8 @@ export const saveUserResultAction = validatedCallback({ takenTime: input.timeTaken.toString(), errorCount: input.errors, cpm: input.cpm, - accuracy: new Prisma.Decimal(input.accuracy), + wpm: wpm, + accuracy: new Decimal(input.accuracy), snippetId: input.snippetId, RaceParticipant: input.raceParticipantId ? { @@ -90,6 +95,7 @@ export const saveUserResultAction = validatedCallback({ _avg: { accuracy: true, cpm: true, + wpm: true, }, }); @@ -98,8 +104,9 @@ export const saveUserResultAction = validatedCallback({ id: user.id, }, data: { - averageAccuracy: avgValues._avg.accuracy ?? 0, - averageCpm: avgValues._avg.cpm ?? 0, + averageAccuracy: new Decimal(avgValues?._avg?.accuracy ?? 0), + averageCpm: new Decimal(avgValues?._avg?.cpm ?? 0), + average_wpm: new Decimal(avgValues?._avg?.wpm ?? 0), languagesMap: JSON.stringify(languagesMap), topLanguages: topLanguages, }, diff --git a/packages/app/src/app/result/loaders.ts b/packages/app/src/app/result/loaders.ts index 9da6b6e2..01734914 100644 --- a/packages/app/src/app/result/loaders.ts +++ b/packages/app/src/app/result/loaders.ts @@ -11,6 +11,7 @@ import { z } from "zod"; export type ParsedRacesResult = Omit & { createdAt: string; + wpm: number; }; export async function getFirstRaceBadge() { @@ -122,6 +123,7 @@ const loadCurrentResults = validatedCallback({ snippetId: z.string(), accuracy: z.number(), cpm: z.number(), + wpm: z.number(), errorCount: z.number(), takenTime: z.string(), }), @@ -159,11 +161,13 @@ export const getTopTen = validatedCallback({ id: z.string(), accuracy: z.number(), cpm: z.number(), + wpm: z.number(), user: z.object({ id: z.string(), name: z.string(), averageAccuracy: z.number(), averageCpm: z.number(), + averageWpm: z.number(), image: z.string(), }), }) @@ -186,6 +190,7 @@ export const getTopTen = validatedCallback({ name: true, averageAccuracy: true, averageCpm: true, + averageWpm: true, image: true, }, }, diff --git a/packages/app/src/app/result/page.tsx b/packages/app/src/app/result/page.tsx index cb5013a8..99f5a3c9 100644 --- a/packages/app/src/app/result/page.tsx +++ b/packages/app/src/app/result/page.tsx @@ -84,6 +84,10 @@ async function AuthenticatedPage({ resultId, user }: AuthenticatedPageProps) { raceResults = await getUserResultsForSnippet(currentRaceResult.snippetId); cardObjects = [ + { + title: "WPM", + value: currentRaceResult?.wpm?.toString(), + }, { title: "CPM", value: currentRaceResult?.cpm.toString(), diff --git a/packages/app/src/app/result/result-chart.tsx b/packages/app/src/app/result/result-chart.tsx index c36211bd..ed96d73d 100644 --- a/packages/app/src/app/result/result-chart.tsx +++ b/packages/app/src/app/result/result-chart.tsx @@ -22,6 +22,7 @@ interface ChartTimeStamp { accuracy: number; cpm: number; time: number; + wpm: number; } type TResultChart = { @@ -62,6 +63,13 @@ export function ResultChart({ code }: TResultChart) { stroke="#0ee2c6" activeDot={{ r: 8 }} /> + RenderTooltip(props, setActiveCharIndex)} /> @@ -131,11 +139,12 @@ function RenderTooltip( return (
- Cpm : {data.cpm} + CPM: {data.cpm} + WPM: {data.wpm} - Accuracy : {Math.ceil(data.accuracy)} + Accuracy: {Math.ceil(data.accuracy)}% - Character : {data.char} + Character: {data.char}
); diff --git a/packages/app/src/app/result/topten.tsx b/packages/app/src/app/result/topten.tsx index 7c30e790..7eae8ede 100644 --- a/packages/app/src/app/result/topten.tsx +++ b/packages/app/src/app/result/topten.tsx @@ -31,6 +31,7 @@ export async function TopTable({ snippetId }: { snippetId?: string }) { User Average CPM + Average WPM Average Accuracy @@ -53,6 +54,7 @@ export async function TopTable({ snippetId }: { snippetId?: string }) { {topten.cpm} + {topten.wpm} 75