Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions frontend/app/[locale]/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getTranslations } from 'next-intl/server';
import { Heart, MessageSquare } from 'lucide-react';
import { getTranslations } from 'next-intl/server';

import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync';
import { AchievementsSection } from '@/components/dashboard/AchievementsSection';
Expand All @@ -12,11 +12,11 @@ import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
import { StatsCard } from '@/components/dashboard/StatsCard';
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
import { getUserLastAttemptPerQuiz, getUserQuizStats } from '@/db/queries/quizzes/quiz';
import { getUserProfile, getUserGlobalRank } from '@/db/queries/users';
import { getUserGlobalRank, getUserProfile } from '@/db/queries/users';
import { redirect } from '@/i18n/routing';
import { getSponsors, getAllSponsors } from '@/lib/about/github-sponsors';
import { getCurrentUser } from '@/lib/auth';
import { getAllSponsors, getSponsors } from '@/lib/about/github-sponsors';
import { computeAchievements } from '@/lib/achievements';
import { getCurrentUser } from '@/lib/auth';
import { checkHasStarredRepo, resolveGitHubLogin } from '@/lib/github-stars';

export async function generateMetadata({
Expand Down Expand Up @@ -188,7 +188,6 @@ export default async function DashboardPage({
const highScores = attempts.filter((a) => Number(a.percentage) >= 90).length;
const uniqueQuizzes = lastAttempts.length;

// Night Owl: any attempt completed between 00:00 and 05:00 local time
const hasNightOwl = attempts.some((a) => {
if (!a.completedAt) return false;
const hour = new Date(a.completedAt).getHours();
Expand All @@ -205,7 +204,7 @@ export default async function DashboardPage({
totalPoints: user.points,
topLeaderboard: false,
hasStarredRepo,
sponsorCount: matchedSponsor ? 1 : 0, // TODO: wire to actual sponsorship history count
sponsorCount: matchedSponsor ? 1 : 0,
hasNightOwl,
});

Expand Down Expand Up @@ -257,15 +256,15 @@ export default async function DashboardPage({
totalAttempts={totalAttempts}
globalRank={globalRank}
/>
<div className="grid gap-8 lg:grid-cols-2">
<div id="stats" className="grid gap-8 lg:grid-cols-2 scroll-mt-8">
<StatsCard stats={stats} attempts={attempts} />
<ActivityHeatmapCard attempts={attempts} locale={locale} currentStreak={currentStreak} />
</div>
</div>
<div className="mt-8">
<AchievementsSection achievements={achievements} />
</div>
<div className="mt-8">
<div id="quiz-results" className="mt-8 scroll-mt-8">
<QuizResultsSection attempts={lastAttempts} locale={locale} />
</div>
<div className="mt-8">
Expand All @@ -281,4 +280,4 @@ export default async function DashboardPage({
</DynamicGridBackground>
</div>
);
}
}
61 changes: 55 additions & 6 deletions frontend/app/[locale]/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Metadata } from 'next';
import LeaderboardClient from '@/components/leaderboard/LeaderboardClient';
import { getLeaderboardData } from '@/db/queries/leaderboard';
import { getSponsors } from '@/lib/about/github-sponsors';
import { ACHIEVEMENTS } from '@/lib/achievements';
import { getCurrentUser } from '@/lib/auth';
import { getAllStargazers } from '@/lib/github-stars';

export const metadata: Metadata = {
title: 'Leaderboard | DevLovers',
Expand All @@ -12,25 +14,72 @@ export const metadata: Metadata = {

export const dynamic = 'force-dynamic';

// Map GitHub sponsor tier color → achievement id
const TIER_ACHIEVEMENT: Record<'gold' | 'silver' | 'bronze', string> = {
gold: 'golden_patron',
silver: 'silver_patron',
bronze: 'supporter',
};

export default async function LeaderboardPage() {
const [rows, session, sponsors] = await Promise.all([
const [rows, session, sponsors, stargazerList] = await Promise.all([
getLeaderboardData(),
getCurrentUser(),
getSponsors(),
getAllStargazers(),
]);

// Build O(1) lookup sets for stargazer matching
const stargazerLogins = new Set(stargazerList.map(s => s.login));
const stargazerAvatars = new Set(stargazerList.map(s => s.avatarBase));

const users = rows.map(({ email, ...user }) => {
const emailLower = email.toLowerCase();
const nameLower = user.username.toLowerCase();
const isSponsor = sponsors.some(

const matchedSponsor = sponsors.find(
s =>
(s.email && s.email.toLowerCase() === emailLower) ||
(nameLower && s.login.toLowerCase() === nameLower) ||
(nameLower && s.name.toLowerCase() === nameLower) ||
(nameLower && s.login && s.login.toLowerCase() === nameLower) ||
(nameLower && s.name && s.name.toLowerCase() === nameLower) ||
(user.avatar && s.avatarUrl && user.avatar.includes(s.avatarUrl.split('?')[0]))
);
return { ...user, isSponsor };

const isSponsor = !!matchedSponsor;
let achievements = user.achievements ?? [];

// ── Inject sponsor achievement based on GitHub tier color ──────────
if (matchedSponsor) {
const achievementId = TIER_ACHIEVEMENT[matchedSponsor.tierColor];
const def = ACHIEVEMENTS.find(a => a.id === achievementId);
if (def && !achievements.some(a => a.id === achievementId)) {
achievements = [
{ id: def.id, icon: def.icon, gradient: def.gradient, glow: def.glow },
...achievements,
];
}
}

// ── Inject star_gazer if user has starred the repo ─────────────────
// Match by GitHub login (username) or by avatar URL base
const avatarBase = user.avatar?.split('?')[0] ?? '';
const hasStarred =
stargazerLogins.has(nameLower) ||
(avatarBase.includes('avatars.githubusercontent.com') &&
stargazerAvatars.has(avatarBase));

if (hasStarred && !achievements.some(a => a.id === 'star_gazer')) {
const def = ACHIEVEMENTS.find(a => a.id === 'star_gazer');
if (def) {
achievements = [
{ id: def.id, icon: def.icon, gradient: def.gradient, glow: def.glow },
...achievements,
];
}
}

return { ...user, isSponsor, achievements };
});

return <LeaderboardClient initialUsers={users} currentUser={session} />;
}
}
110 changes: 110 additions & 0 deletions frontend/app/api/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';

import {
enforceRateLimit,
getRateLimitSubject,
rateLimitResponse,
} from '@/lib/security/rate-limit';

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const MAX_FILES = 5;
const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[a-zA-Z0-9.-]{1,253}$/;

function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

export async function POST(req: NextRequest) {
const subject = getRateLimitSubject(req);
const rl = await enforceRateLimit({
key: `feedback:${subject}`,
limit: 5,
windowSeconds: 3600, // 5 submissions per IP per hour
});
if (!rl.ok) return rateLimitResponse({ retryAfterSeconds: rl.retryAfterSeconds });

const gmailUser = process.env.GMAIL_USER;
const gmailPass = process.env.GMAIL_APP_PASSWORD;
const emailFrom = process.env.EMAIL_FROM;

if (!gmailUser || !gmailPass || !emailFrom) {
console.error('Feedback API: Missing email environment variables');
return NextResponse.json({ success: false }, { status: 500 });
}

let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ success: false }, { status: 400 });
}

const name = (formData.get('name') as string | null)?.trim();
const email = (formData.get('email') as string | null)?.trim();
const category = (formData.get('category') as string | null)?.trim();
const message = (formData.get('message') as string | null)?.trim();

if (!name || !email || !category || !message) {
return NextResponse.json({ success: false }, { status: 400 });
}

if (!EMAIL_RE.test(email)) {
return NextResponse.json({ success: false }, { status: 400 });
}

// Strip CRLF and RFC 5322 specials from email before use in headers
const safeEmail = email.replace(/[\r\n<>"]/g, '');

const rawFiles = formData.getAll('attachment');
const files = rawFiles.filter((f): f is File => f instanceof File && f.size > 0);

if (files.length > MAX_FILES) {
return NextResponse.json({ success: false }, { status: 400 });
}

const attachments: { filename: string; content: Buffer; contentType: string }[] = [];

for (const f of files) {
if (f.size > MAX_FILE_SIZE) {
return NextResponse.json({ success: false, tooLarge: true }, { status: 413 });
}
const buffer = Buffer.from(await f.arrayBuffer());
attachments.push({ filename: f.name, content: buffer, contentType: f.type });
}
Comment on lines +71 to +79
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

MIME header injection: sanitize f.name and f.type before passing to nodemailer.

f.name comes from the filename parameter of the multipart Content-Disposition header — a crafted HTTP client can set it to anything, including a value containing \r\n. Similarly, f.type is the raw Content-Type of the individual form part. Nodemailer places both into outgoing MIME headers (Content-Type: …; name="…" / Content-Disposition: attachment; filename="…"). If nodemailer doesn't encode CRLF in these values, a malicious actor can inject arbitrary MIME headers into the forwarded email.

Strip at minimum CRLF and null bytes from f.name, and validate f.type against an allowlist or a basic MIME token pattern before use:

🛡️ Proposed fix
-    const buffer = Buffer.from(await f.arrayBuffer());
-    attachments.push({ filename: f.name, content: buffer, contentType: f.type });
+    const buffer = Buffer.from(await f.arrayBuffer());
+    const safeFilename = f.name.replace(/[\r\n\0"\\]/g, '').trim().slice(0, 255) || 'attachment';
+    const MIME_TYPE_RE = /^[a-zA-Z0-9][a-zA-Z0-9!#$%&'*+\-.^_`|~]*\/[a-zA-Z0-9][a-zA-Z0-9!#$%&'*+\-.^_`|~]*$/;
+    const safeContentType = MIME_TYPE_RE.test(f.type) ? f.type : 'application/octet-stream';
+    attachments.push({ filename: safeFilename, content: buffer, contentType: safeContentType });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const attachments: { filename: string; content: Buffer; contentType: string }[] = [];
for (const f of files) {
if (f.size > MAX_FILE_SIZE) {
return NextResponse.json({ success: false, tooLarge: true }, { status: 413 });
}
const buffer = Buffer.from(await f.arrayBuffer());
attachments.push({ filename: f.name, content: buffer, contentType: f.type });
}
const attachments: { filename: string; content: Buffer; contentType: string }[] = [];
for (const f of files) {
if (f.size > MAX_FILE_SIZE) {
return NextResponse.json({ success: false, tooLarge: true }, { status: 413 });
}
const buffer = Buffer.from(await f.arrayBuffer());
const safeFilename = f.name.replace(/[\r\n\0"\\]/g, '').trim().slice(0, 255) || 'attachment';
const MIME_TYPE_RE = /^[a-zA-Z0-9][a-zA-Z0-9!#$%&'*+\-.^_`|~]*\/[a-zA-Z0-9][a-zA-Z0-9!#$%&'*+\-.^_`|~]*$/;
const safeContentType = MIME_TYPE_RE.test(f.type) ? f.type : 'application/octet-stream';
attachments.push({ filename: safeFilename, content: buffer, contentType: safeContentType });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/feedback/route.ts` around lines 71 - 79, In the loop that
builds attachments (for (const f of files) { … }) sanitize and validate incoming
file metadata before pushing to attachments: strip CRLF (\r, \n), null bytes and
other control characters from f.name (and fallback to a generated safe filename
if the result is empty) and validate f.type against an allowlist or a strict
MIME token/type pattern (e.g. /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i); if f.type is
invalid, replace it with "application/octet-stream" or omit the media-type, then
push the sanitized values into attachments instead of raw f.name/f.type to
prevent MIME header injection when nodemailer constructs headers.


const mailer = nodemailer.createTransport({
service: 'gmail',
auth: { user: gmailUser, pass: gmailPass },
});

// Sanitize name for use in email header (strip CR/LF and RFC 5322 specials)
const safeNameForHeader = name.replace(/[\r\n"<>\\]/g, '');

try {
await mailer.sendMail({
from: emailFrom,
replyTo: safeNameForHeader ? `"${safeNameForHeader}" <${safeEmail}>` : safeEmail,
to: emailFrom,
subject: `DevLovers Feedback: ${category.replace(/[\r\n]/g, '')}`,
html: `
<p><strong>Name:</strong> ${escapeHtml(name)}</p>
<p><strong>Email:</strong> ${escapeHtml(email)}</p>
<p><strong>Category:</strong> ${escapeHtml(category)}</p>
<p><strong>Message:</strong></p>
<p>${escapeHtml(message).replace(/\n/g, '<br>')}</p>
`,
attachments,
});

return NextResponse.json({ success: true });
} catch (err) {
console.error('Feedback API: Failed to send email', err);
return NextResponse.json({ success: false }, { status: 500 });
}
}
18 changes: 18 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,24 @@ html {
.btn:hover {
background-color: var(--accent-hover);
}

.dashboard-card {
@apply relative z-10 overflow-hidden rounded-3xl border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md transition-all duration-300;

&:hover {
@apply -translate-y-1 shadow-md;

border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}

&:is(.dark *) {
@apply border-neutral-800 bg-neutral-900/10;
}

&:is(.dark *):hover {
border-color: color-mix(in srgb, #ff2d55 30%, transparent);
}
}
}

.container-main {
Expand Down
5 changes: 2 additions & 3 deletions frontend/components/dashboard/AchievementsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ export function AchievementsSection({ achievements }: AchievementsSectionProps)

const earnedCount = achievements.filter((a) => a.earned).length;

const cardStyles =
'relative z-10 overflow-hidden rounded-3xl border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:border-(--accent-primary)/30 dark:border-neutral-800 dark:bg-neutral-900/10 dark:hover:border-(--accent-primary)/30';
const cardStyles = 'dashboard-card';

const previewBadges = achievements.slice(0, 6);
const remainingBadges = achievements.slice(6);
Expand Down Expand Up @@ -92,4 +91,4 @@ export function AchievementsSection({ achievements }: AchievementsSectionProps)
</div>
</section>
);
}
}
20 changes: 7 additions & 13 deletions frontend/components/dashboard/ActivityHeatmapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
left: number;
} | null>(null);

const cardStyles = `
relative z-10 flex flex-col justify-between overflow-hidden rounded-3xl
border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md
dark:border-neutral-800 dark:bg-neutral-900/10
p-6 sm:p-8 transition-all duration-300 hover:-translate-y-1 hover:shadow-md
hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30
`;
const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8';

const todayStart = useMemo(() => {
const d = new Date();
Expand Down Expand Up @@ -278,7 +272,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit

<div className="flex flex-col items-end gap-2 shrink-0 sm:flex-row sm:items-center">
{currentStreak !== undefined && currentStreak > 0 && (
<span className="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-orange-500/10 px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px] h-[22px] sm:h-auto font-bold tracking-wide text-orange-600 dark:bg-orange-500/20 dark:text-orange-400 border border-orange-500/20">
<span className="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-orange-500/10 px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px] h-5.5 sm:h-auto font-bold tracking-wide text-orange-600 dark:bg-orange-500/20 dark:text-orange-400 border border-orange-500/20">
<Flame className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
Comment on lines +275 to 276
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and inspect Tailwind config for custom spacing/zIndex tokens
configs=$(fd -t f 'tailwind\.config\.(js|ts|cjs|mjs)' .)
if [ -z "$configs" ]; then
  echo "No tailwind config found."
  exit 0
fi

for f in $configs; do
  echo "== File: $f =="
  cat -n "$f"
  echo ""
done

Repository: DevLoversTeam/devlovers.net

Length of output: 96


🏁 Script executed:

#!/bin/bash
# Verify the specific classes are used in ActivityHeatmapCard.tsx
file="frontend/components/dashboard/ActivityHeatmapCard.tsx"
if [ -f "$file" ]; then
  echo "=== Lines 275-276 ==="
  sed -n '275,276p' "$file"
  echo ""
  echo "=== Lines 291-292 ==="
  sed -n '291,292p' "$file"
  echo ""
  echo "=== Lines 349-350 ==="
  sed -n '349,350p' "$file"
  echo ""
  echo "=== Lines 455-456 ==="
  sed -n '455,456p' "$file"
  echo ""
  echo "=== Lines 462-463 ==="
  sed -n '462,463p' "$file"
  echo ""
  echo "=== All occurrences of h-5.5, min-w-17.5, min-w-27.5, z-9999 ==="
  rg "(h-5\.5|min-w-17\.5|min-w-27\.5|z-9999)" "$file" -n
else
  echo "File not found: $file"
fi

Repository: DevLoversTeam/devlovers.net

Length of output: 1788


🏁 Script executed:

#!/bin/bash
# Check for Tailwind config in common alternative locations
echo "=== Checking next.config.js ==="
[ -f next.config.js ] && cat -n next.config.js || echo "Not found"

echo -e "\n=== Checking postcss.config.js ==="
[ -f postcss.config.js ] && cat -n postcss.config.js || echo "Not found"

echo -e "\n=== Checking for CSS/SCSS files with custom token definitions ==="
fd -t f '\.(css|scss)$' . | head -20

echo -e "\n=== Checking for tailwind directives in CSS files ==="
rg "@apply|@tailwind|theme\(" --type css --type scss | head -30

echo -e "\n=== Check package.json for Tailwind setup ==="
cat package.json | jq '.devDependencies, .dependencies' 2>/dev/null | grep -i tailwind || echo "No Tailwind in dependencies"

Repository: DevLoversTeam/devlovers.net

Length of output: 473


🏁 Script executed:

#!/bin/bash
# Search for theme or spacing definitions that might define these custom values
rg "(spacing|zIndex|extend)" --type js --type ts | grep -E "(5\.5|17\.5|27\.5|9999)" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Check globals.css
echo "=== frontend/app/globals.css ==="
cat -n frontend/app/globals.css | head -50

echo -e "\n=== Find package.json ==="
fd -t f 'package\.json' . | head -5

echo -e "\n=== Project structure ==="
find . -maxdepth 2 -type f -name '*.json' | grep -E '(package|tsconfig|tailwind)' | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 2417


🏁 Script executed:

#!/bin/bash
# Check if Tailwind classes would be recognized even without explicit config
# Look for any tailwind setup in the project
echo "=== Search for tailwind references in all files ==="
rg -i "tailwind" --type ts --type tsx --type js --type json | head -20

echo -e "\n=== Check actual dimensions - is 5.5 even a valid Tailwind default? ==="
# 5.5 in spacing would be 22px (5.5 * 4px), but Tailwind default spacing goes 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10...
# 5.5 is NOT in the default scale
echo "Tailwind default spacing scale: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10"
echo "5.5 is not in default scale - would need custom config"

Repository: DevLoversTeam/devlovers.net

Length of output: 360


🏁 Script executed:

#!/bin/bash
# Check globals.css
echo "=== frontend/app/globals.css ==="
cat -n frontend/app/globals.css

echo -e "\n=== Find package.json ==="
fd -t f 'package\.json' .

echo -e "\n=== Check tailwind references ==="
rg -i "tailwind" -t ts -t tsx -t js -t json | head -30

Repository: DevLoversTeam/devlovers.net

Length of output: 17919


🏁 Script executed:

#!/bin/bash
# Check if Tailwind is setup as dependency elsewhere
echo "=== Check all package.json files ==="
find . -name 'package.json' -type f -exec sh -c 'echo "File: $1"; cat "$1" | jq ".devDependencies | keys[] | select(. | contains(\"tailwind\"))" 2>/dev/null || echo "Cannot parse or no tailwind"' _ {} \;

Repository: DevLoversTeam/devlovers.net

Length of output: 232


🏁 Script executed:

#!/bin/bash
# Double-check: verify if any custom spacing values are defined in globals.css
echo "=== Search for spacing or spacing-related definitions in globals.css ==="
grep -n "spacing\|--spacing\|--width\|--height" frontend/app/globals.css || echo "No spacing definitions found"

echo -e "\n=== Search for zIndex definitions ==="
grep -n "zIndex\|--z-\|z-index" frontend/app/globals.css || echo "No zIndex definitions found"

echo -e "\n=== Confirm Tailwind v4 syntax in use ==="
head -10 frontend/app/globals.css

Repository: DevLoversTeam/devlovers.net

Length of output: 596


Replace non-existent Tailwind utility classes with arbitrary values or define custom tokens.

Classes h-5.5, min-w-17.5, min-w-27.5, and z-9999 are not defined in the Tailwind configuration (checked in frontend/app/globals.css). These styles will not apply without custom token definitions. Use arbitrary values as a fallback.

🔧 Suggested fix using arbitrary values
-              <span className="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-orange-500/10 px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px] h-5.5 sm:h-auto font-bold tracking-wide text-orange-600 dark:bg-orange-500/20 dark:text-orange-400 border border-orange-500/20">
+              <span className="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-orange-500/10 px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px] h-[22px] sm:h-auto font-bold tracking-wide text-orange-600 dark:bg-orange-500/20 dark:text-orange-400 border border-orange-500/20">
...
-                <span className="hidden sm:block min-w-17.5 text-center">
+                <span className="hidden sm:block min-w-[70px] text-center">
...
-            className="fixed z-9999 pointer-events-none"
+            className="fixed z-[9999] pointer-events-none"
...
-            <div className="bg-gray-900 dark:bg-white rounded-xl px-3 py-2 shadow-xl shadow-black/30 text-center min-w-27.5">
+            <div className="bg-gray-900 dark:bg-white rounded-xl px-3 py-2 shadow-xl shadow-black/30 text-center min-w-[110px]">

Also applies to: lines 291–292, 349–350, 455–456, 462–463

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/dashboard/ActivityHeatmapCard.tsx` around lines 275 -
276, The Tailwind utilities h-5.5, min-w-17.5, min-w-27.5 and z-9999 used in the
ActivityHeatmapCard span (and other occurrences in ActivityHeatmapCard.tsx) are
not defined in your Tailwind config; update those class strings to use Tailwind
arbitrary values (e.g. h-[1.375rem], min-w-[4.375rem], min-w-[6.875rem],
z-[9999]) or add corresponding custom tokens in your Tailwind config, and
replace each occurrence (the span with Flame and the other repeated locations)
accordingly so the styles actually apply.

<span className="hidden xs:inline">
{currentStreak} {currentStreak === 1 ? tProfile('dayStreak', { fallback: 'Day Streak' }) : tProfile('daysStreak', { fallback: 'Days Streak' })}
Expand All @@ -294,7 +288,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
aria-expanded={isDropdownOpen}
aria-haspopup="listbox"
>
<span className="hidden sm:block min-w-[70px] text-center">
<span className="hidden sm:block min-w-17.5 text-center">
{periodOptions.find(o => o.value === periodOffset)?.label}
</span>
<motion.span
Expand Down Expand Up @@ -352,7 +346,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
</div>

<div className="relative flex w-full flex-1 flex-col justify-center -mb-2">
<div className="absolute left-0 inset-y-0 z-10 w-[44px] pointer-events-none flex flex-col justify-center pb-1">
<div className="absolute left-0 inset-y-0 z-10 w-11 pointer-events-none flex flex-col justify-center pb-1">
<svg width={44} height={svgHeight} className="block">
{monthsData.map((m, i) => (
<text key={`l-${i}`} x="32" y={16 + i * 34 + 3} fontSize="9" textAnchor="end" className="fill-gray-400 dark:fill-gray-500 font-bold uppercase tracking-widest">
Expand Down Expand Up @@ -458,14 +452,14 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 4 }}
transition={{ duration: 0.1 }}
className="fixed z-[9999] pointer-events-none"
className="fixed z-9999 pointer-events-none"
style={{
top: tooltip.top,
left: tooltip.left,
transform: 'translate(-50%, -100%)',
}}
>
<div className="bg-gray-900 dark:bg-white rounded-xl px-3 py-2 shadow-xl shadow-black/30 text-center min-w-[110px]">
<div className="bg-gray-900 dark:bg-white rounded-xl px-3 py-2 shadow-xl shadow-black/30 text-center min-w-27.5">
<p className="text-[11px] font-semibold text-white dark:text-gray-900">
{tooltip.date.toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric' })}
</p>
Expand Down Expand Up @@ -520,4 +514,4 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
</div>
</section>
);
}
}
Loading