-
Notifications
You must be signed in to change notification settings - Fork 0
Night Shift: Unicode display names for i18n users #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,7 +19,10 @@ function getDateDaysAgo(days: number): string { | |||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Calculate current streak for a user | ||||||||||||||||||||||||||||||||||
| async function calculateCurrentStreak(ctx: any, userId: string): Promise<number> { | ||||||||||||||||||||||||||||||||||
| async function calculateCurrentStreak( | ||||||||||||||||||||||||||||||||||
| ctx: any, | ||||||||||||||||||||||||||||||||||
| userId: string, | ||||||||||||||||||||||||||||||||||
| ): Promise<number> { | ||||||||||||||||||||||||||||||||||
| const practiceLogs = await ctx.db | ||||||||||||||||||||||||||||||||||
| .query("userPracticeLog") | ||||||||||||||||||||||||||||||||||
| .withIndex("by_user", (q: any) => q.eq("userId", userId)) | ||||||||||||||||||||||||||||||||||
|
|
@@ -51,7 +54,7 @@ async function calculateCurrentStreak(ctx: any, userId: string): Promise<number> | |||||||||||||||||||||||||||||||||
| const startDate = hasPracticedToday ? today : yesterday; | ||||||||||||||||||||||||||||||||||
| let currentStreak = 0; | ||||||||||||||||||||||||||||||||||
| let date = startDate; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| while (practiceMap.has(date)) { | ||||||||||||||||||||||||||||||||||
| currentStreak++; | ||||||||||||||||||||||||||||||||||
| const dateObj = new Date(date); | ||||||||||||||||||||||||||||||||||
|
|
@@ -66,7 +69,7 @@ async function calculateCurrentStreak(ctx: any, userId: string): Promise<number> | |||||||||||||||||||||||||||||||||
| // resolves songs with error resilience, computes weighted score. | ||||||||||||||||||||||||||||||||||
| async function calculateProgressScore( | ||||||||||||||||||||||||||||||||||
| ctx: any, | ||||||||||||||||||||||||||||||||||
| userId: string | ||||||||||||||||||||||||||||||||||
| userId: string, | ||||||||||||||||||||||||||||||||||
| ): Promise<{ score: number; topLanguage: string }> { | ||||||||||||||||||||||||||||||||||
| const wordProgress = await ctx.db | ||||||||||||||||||||||||||||||||||
| .query("wordProgress") | ||||||||||||||||||||||||||||||||||
|
|
@@ -82,17 +85,25 @@ async function calculateProgressScore( | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const uniqueSongIds = [...new Set(lineProgress.map((l: any) => l.songId))]; | ||||||||||||||||||||||||||||||||||
| const songSettled = await Promise.allSettled( | ||||||||||||||||||||||||||||||||||
| uniqueSongIds.map((id: any) => ctx.db.get(id)) | ||||||||||||||||||||||||||||||||||
| uniqueSongIds.map((id: any) => ctx.db.get(id)), | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| const songMap = new Map( | ||||||||||||||||||||||||||||||||||
| uniqueSongIds.map((id: any, i: number) => [ | ||||||||||||||||||||||||||||||||||
| id, | ||||||||||||||||||||||||||||||||||
| songSettled[i].status === 'fulfilled' ? (songSettled[i] as PromiseFulfilledResult<any>).value : null, | ||||||||||||||||||||||||||||||||||
| ]) | ||||||||||||||||||||||||||||||||||
| songSettled[i].status === "fulfilled" | ||||||||||||||||||||||||||||||||||
| ? (songSettled[i] as PromiseFulfilledResult<any>).value | ||||||||||||||||||||||||||||||||||
| : null, | ||||||||||||||||||||||||||||||||||
| ]), | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const languageScores = new Map<string, { wordsLearned: number; linesCompleted: number }>(); | ||||||||||||||||||||||||||||||||||
| languageScores.set("mixed", { wordsLearned: wordProgress.length, linesCompleted: 0 }); | ||||||||||||||||||||||||||||||||||
| const languageScores = new Map< | ||||||||||||||||||||||||||||||||||
| string, | ||||||||||||||||||||||||||||||||||
| { wordsLearned: number; linesCompleted: number } | ||||||||||||||||||||||||||||||||||
| >(); | ||||||||||||||||||||||||||||||||||
| languageScores.set("mixed", { | ||||||||||||||||||||||||||||||||||
| wordsLearned: wordProgress.length, | ||||||||||||||||||||||||||||||||||
| linesCompleted: 0, | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| for (const line of lineProgress) { | ||||||||||||||||||||||||||||||||||
| const song = songMap.get(line.songId); | ||||||||||||||||||||||||||||||||||
|
|
@@ -111,7 +122,9 @@ async function calculateProgressScore( | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| for (const [language, scores] of languageScores.entries()) { | ||||||||||||||||||||||||||||||||||
| const multiplier = getLanguageMultiplier(language); | ||||||||||||||||||||||||||||||||||
| const languageScore = (scores.wordsLearned * multiplier) + (scores.linesCompleted * multiplier * 0.5); | ||||||||||||||||||||||||||||||||||
| const languageScore = | ||||||||||||||||||||||||||||||||||
| scores.wordsLearned * multiplier + | ||||||||||||||||||||||||||||||||||
| scores.linesCompleted * multiplier * 0.5; | ||||||||||||||||||||||||||||||||||
| totalScore += languageScore; | ||||||||||||||||||||||||||||||||||
| if (languageScore > maxLanguageScore) { | ||||||||||||||||||||||||||||||||||
| maxLanguageScore = languageScore; | ||||||||||||||||||||||||||||||||||
|
|
@@ -144,7 +157,9 @@ export const getUserInfo = query({ | |||||||||||||||||||||||||||||||||
| // Get streak leaderboard | ||||||||||||||||||||||||||||||||||
| export const getStreakLeaderboard = query({ | ||||||||||||||||||||||||||||||||||
| args: { | ||||||||||||||||||||||||||||||||||
| period: v.optional(v.union(v.literal("weekly"), v.literal("monthly"), v.literal("all-time"))), | ||||||||||||||||||||||||||||||||||
| period: v.optional( | ||||||||||||||||||||||||||||||||||
| v.union(v.literal("weekly"), v.literal("monthly"), v.literal("all-time")), | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| limit: v.optional(v.number()), | ||||||||||||||||||||||||||||||||||
| offset: v.optional(v.number()), | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
|
|
@@ -157,7 +172,7 @@ export const getStreakLeaderboard = query({ | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Calculate streak for each user and collect results | ||||||||||||||||||||||||||||||||||
| const userStreaks = []; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| for (const user of users) { | ||||||||||||||||||||||||||||||||||
| // Use user's ID for practice data lookup | ||||||||||||||||||||||||||||||||||
| const streak = await calculateCurrentStreak(ctx, user._id); | ||||||||||||||||||||||||||||||||||
|
|
@@ -172,21 +187,24 @@ export const getStreakLeaderboard = query({ | |||||||||||||||||||||||||||||||||
| // Batch-fetch songs with deduplication + error resilience | ||||||||||||||||||||||||||||||||||
| const uniqueSongIds = [...new Set(lineProgress.map((l) => l.songId))]; | ||||||||||||||||||||||||||||||||||
| const songSettled = await Promise.allSettled( | ||||||||||||||||||||||||||||||||||
| uniqueSongIds.map((id) => ctx.db.get(id)) | ||||||||||||||||||||||||||||||||||
| uniqueSongIds.map((id) => ctx.db.get(id)), | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| const songMap = new Map( | ||||||||||||||||||||||||||||||||||
| uniqueSongIds.map((id, i) => [ | ||||||||||||||||||||||||||||||||||
| id, | ||||||||||||||||||||||||||||||||||
| songSettled[i].status === 'fulfilled' ? songSettled[i].value : null, | ||||||||||||||||||||||||||||||||||
| ]) | ||||||||||||||||||||||||||||||||||
| songSettled[i].status === "fulfilled" ? songSettled[i].value : null, | ||||||||||||||||||||||||||||||||||
| ]), | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Count lines per language | ||||||||||||||||||||||||||||||||||
| const languageCounts = new Map<string, number>(); | ||||||||||||||||||||||||||||||||||
| for (const line of lineProgress) { | ||||||||||||||||||||||||||||||||||
| const song = songMap.get(line.songId); | ||||||||||||||||||||||||||||||||||
| if (song?.sourceLanguage) { | ||||||||||||||||||||||||||||||||||
| languageCounts.set(song.sourceLanguage, (languageCounts.get(song.sourceLanguage) || 0) + 1); | ||||||||||||||||||||||||||||||||||
| languageCounts.set( | ||||||||||||||||||||||||||||||||||
| song.sourceLanguage, | ||||||||||||||||||||||||||||||||||
| (languageCounts.get(song.sourceLanguage) || 0) + 1, | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
@@ -227,7 +245,9 @@ export const getStreakLeaderboard = query({ | |||||||||||||||||||||||||||||||||
| // Get progress leaderboard with difficulty multipliers | ||||||||||||||||||||||||||||||||||
| export const getProgressLeaderboard = query({ | ||||||||||||||||||||||||||||||||||
| args: { | ||||||||||||||||||||||||||||||||||
| period: v.optional(v.union(v.literal("weekly"), v.literal("monthly"), v.literal("all-time"))), | ||||||||||||||||||||||||||||||||||
| period: v.optional( | ||||||||||||||||||||||||||||||||||
| v.union(v.literal("weekly"), v.literal("monthly"), v.literal("all-time")), | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| limit: v.optional(v.number()), | ||||||||||||||||||||||||||||||||||
| offset: v.optional(v.number()), | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
|
|
@@ -240,9 +260,12 @@ export const getProgressLeaderboard = query({ | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Calculate progress score for each user | ||||||||||||||||||||||||||||||||||
| const userScores = []; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| for (const user of users) { | ||||||||||||||||||||||||||||||||||
| const { score, topLanguage } = await calculateProgressScore(ctx, user._id); | ||||||||||||||||||||||||||||||||||
| const { score, topLanguage } = await calculateProgressScore( | ||||||||||||||||||||||||||||||||||
| ctx, | ||||||||||||||||||||||||||||||||||
| user._id, | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| userScores.push({ | ||||||||||||||||||||||||||||||||||
| displayName: user.displayName!, | ||||||||||||||||||||||||||||||||||
|
|
@@ -272,7 +295,9 @@ export const getProgressLeaderboard = query({ | |||||||||||||||||||||||||||||||||
| export const getUserRank = query({ | ||||||||||||||||||||||||||||||||||
| args: { | ||||||||||||||||||||||||||||||||||
| type: v.union(v.literal("streak"), v.literal("progress")), | ||||||||||||||||||||||||||||||||||
| period: v.optional(v.union(v.literal("weekly"), v.literal("monthly"), v.literal("all-time"))), | ||||||||||||||||||||||||||||||||||
| period: v.optional( | ||||||||||||||||||||||||||||||||||
| v.union(v.literal("weekly"), v.literal("monthly"), v.literal("all-time")), | ||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| handler: async (ctx, { type, period: _period = "all-time" }) => { | ||||||||||||||||||||||||||||||||||
| const userId = await getAuthUserId(ctx); | ||||||||||||||||||||||||||||||||||
|
|
@@ -314,7 +339,10 @@ export const getUserRank = query({ | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| let betterCount = 0; | ||||||||||||||||||||||||||||||||||
| for (const otherUser of allUsers) { | ||||||||||||||||||||||||||||||||||
| const { score: otherScore } = await calculateProgressScore(ctx, otherUser._id); | ||||||||||||||||||||||||||||||||||
| const { score: otherScore } = await calculateProgressScore( | ||||||||||||||||||||||||||||||||||
| ctx, | ||||||||||||||||||||||||||||||||||
| otherUser._id, | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| if (otherScore > userScore) { | ||||||||||||||||||||||||||||||||||
| betterCount++; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
@@ -342,10 +370,13 @@ export const setDisplayName = mutation({ | |||||||||||||||||||||||||||||||||
| return { success: false, error: "Display name must be 3-20 characters" }; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Validate display name contains valid characters (alphanumeric + spaces) | ||||||||||||||||||||||||||||||||||
| const validPattern = /^[a-zA-Z0-9\s]+$/; | ||||||||||||||||||||||||||||||||||
| // Validate display name contains valid characters (Unicode letters, numbers + spaces) | ||||||||||||||||||||||||||||||||||
| const validPattern = /^[\p{L}\p{N}\s]+$/u; | ||||||||||||||||||||||||||||||||||
| if (!validPattern.test(displayName)) { | ||||||||||||||||||||||||||||||||||
| return { success: false, error: "Display name can only contain letters, numbers, and spaces" }; | ||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||
| success: false, | ||||||||||||||||||||||||||||||||||
| error: "Display name can only contain letters, numbers, and spaces", | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+373
to
+379
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In JavaScript (ECMAScript), regex Practically, it’s equivalent to this set (as listed by MDN): So yes,
Sources: Citations:
🌐 Web query:
💡 Result:
Devanagari (Hindi) — examples of
|
||||||||||||||||||||||||||||||||||
| // Validate display name contains valid characters (Unicode letters, numbers + spaces) | |
| const validPattern = /^[\p{L}\p{N}\s]+$/u; | |
| if (!validPattern.test(displayName)) { | |
| return { success: false, error: "Display name can only contain letters, numbers, and spaces" }; | |
| return { | |
| success: false, | |
| error: "Display name can only contain letters, numbers, and spaces", | |
| }; | |
| // Validate display name contains valid characters (Unicode letters, numbers + spaces) | |
| // Letters + combining marks + numbers + space separators (no tabs/newlines) | |
| const validPattern = /^[\p{L}\p{M}\p{N}\p{Zs}]+$/u; | |
| if (!validPattern.test(displayName)) { | |
| return { | |
| success: false, | |
| error: "Display name can only contain letters, numbers, and spaces", | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@convex/leaderboard.ts` around lines 373 - 379, The display name validation
regex (validPattern) is too permissive and misses Unicode combining marks;
update the pattern used where displayName is tested so it accepts Unicode
letters, numbers and combining marks but only literal space characters (not
tabs/newlines/other whitespace). Specifically, replace the character class
[\p{L}\p{N}\s] with one that adds \p{M} for combining marks and uses a literal
space instead of \s, and keep the start/end anchors and /u flag as used in
validPattern to ensure line terminators and control whitespace are rejected
while preserving i18n support.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,11 +15,13 @@ function WelcomePage() { | |
| const [error, setError] = useState<string | null>(null); | ||
| const [loading, setLoading] = useState(false); | ||
|
|
||
| const setDisplayNameMutation = useConvexMutation(api.leaderboard.setDisplayName); | ||
| const setDisplayNameMutation = useConvexMutation( | ||
| api.leaderboard.setDisplayName, | ||
| ); | ||
|
|
||
| // Check if user already has a display name | ||
| const { data: userInfo } = useQuery( | ||
| convexQuery(api.leaderboard.getUserInfo, {}) | ||
| convexQuery(api.leaderboard.getUserInfo, {}), | ||
| ); | ||
|
|
||
| // If user already has displayName, redirect to dashboard | ||
|
|
@@ -95,7 +97,7 @@ function WelcomePage() { | |
| maxLength={20} | ||
| /> | ||
| <p className="text-xs text-gray-500 mt-1"> | ||
| 3-20 characters, letters and spaces only | ||
| 3-20 characters, letters, numbers, and spaces | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# First, let's find if WelcomePage.test.tsx exists anywhere
echo "=== Searching for WelcomePage.test.tsx ==="
fd 'WelcomePage\.test\.tsx$' 2>/dev/null || echo "No test file found"
echo ""
echo "=== Searching for test files in welcome route directory ==="
fd '\.test\.tsx$' src/routes/_authed/ 2>/dev/null || echo "No test files found in that directory"
echo ""
echo "=== Locating welcome.tsx component ==="
fd 'welcome\.tsx$' src/routes/_authed/ 2>/dev/null
echo ""
echo "=== Checking component structure at src/routes/_authed/welcome.tsx ==="
wc -l src/routes/_authed/welcome.tsx 2>/dev/null && echo "File exists, reading..."Repository: EtanHey/songscript Length of output: 367 🏁 Script executed: # Read the welcome.tsx file to see the component definition and line 100
cat -n src/routes/_authed/welcome.tsx | head -120Repository: EtanHey/songscript Length of output: 4765 🏁 Script executed: # Check for any test files in the project that might test this component
echo "=== Searching for test files that reference WelcomePage ==="
rg -l 'WelcomePage' --type=tsx --type=ts src/ 2>/dev/null | grep -i testRepository: EtanHey/songscript Length of output: 121 Create The 🤖 Prompt for AI Agents |
||
| </p> | ||
| </div> | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unicode names reject combining marks
Medium Severity
setDisplayNamevalidates with^[\p{L}\p{N}\s]+$, which excludes Unicode mark characters. Many valid names require combining marks, so users in several scripts can still be blocked despite the Unicode rollout. This makes the new i18n display-name support incomplete inconvex/leaderboard.ts.