TidyQuest/client/src/components/pages/Leaderboard.tsx
root a7cd81fe81 Initial release: TidyQuest v0.1.0
TidyQuest is a self-hosted web application that gamifies household chores
using RPG mechanics. Complete tasks, earn coins, unlock achievements, and
compete with your family on the leaderboard.

Features:
- 🎯 Health-based task tracking with visual decay over time
- 💰 Coin system with customizable rewards
- 🔥 Daily/weekly streak tracking
- 🏆 Family leaderboard (week/month/quarter/year)
- 🎖️ Automatic achievement unlocking
- 📅 Calendar view for upcoming tasks
- 🌍 Multilingual support (EN/FR/DE/ES)
- 📱 Optional Telegram notifications
- 🏖️ Vacation mode (pause task decay)
- 🐳 Docker deployment (single container)

Tech Stack:
- Frontend: React 19 + Vite + TypeScript
- Backend: Node.js + Express + TypeScript
- Database: SQLite3 with WAL mode
- Auth: JWT + bcrypt
- Deployment: Docker Compose

Includes:
- 8 room types with 60+ predefined tasks
- 10 preset rewards
- 12 achievement types
- Complete API documentation
- Security best practices guide
- Comprehensive README with examples

License: GNU Affero General Public License v3.0 (AGPL-3.0)
This ensures TidyQuest remains free and open-source forever, preventing
proprietary SaaS forks. Any modified hosted version must share source code.
2026-02-17 18:40:09 +01:00

111 lines
5.6 KiB
TypeScript

import { UserAvatar } from '../shared/UserAvatar';
import { FireIcon, CoinIcon } from '../icons/UIIcons';
import { useTranslation } from '../../hooks/useTranslation';
interface LeaderboardProps {
users: Array<{ id: number; displayName: string; avatarColor: string; avatarType?: string; avatarPreset?: string; avatarPhotoUrl?: string; coins: number; currentStreak: number; points: number }>;
period: 'week' | 'month' | 'quarter' | 'year';
language?: string;
onPeriodChange: (period: 'week' | 'month' | 'quarter' | 'year') => void;
}
export function Leaderboard({ users, period, language, onPeriodChange }: LeaderboardProps) {
const { t } = useTranslation(language);
const sorted = [...users].sort((a, b) => b.points - a.points);
// Podium order: 2nd, 1st, 3rd
const podium = [sorted[1], sorted[0], sorted[2]].filter(Boolean);
const podiumHeights = [100, 140, 75];
const podiumRanks = [2, 1, 3];
const podiumGradients = [
'linear-gradient(180deg, #E2E8F0, #94A3B8)',
'linear-gradient(180deg, #FDE68A, #F59E0B)',
'linear-gradient(180deg, #FED7AA, #EA580C)',
];
return (
<div className="page-enter" style={{ maxWidth: 700 }}>
{/* Period Toggle */}
<div style={{
display: 'flex', gap: 4, backgroundColor: 'var(--warm-accent-light)', borderRadius: 14,
padding: 4, marginBottom: 24, width: 'fit-content', border: '1.5px solid var(--warm-border)',
}}>
{(['week', 'month', 'quarter', 'year'] as const).map((p) => (
<button key={p} onClick={() => onPeriodChange(p)}
className="tq-btn"
style={{
padding: '9px 22px', borderRadius: 11,
backgroundColor: period === p ? 'var(--warm-accent)' : 'transparent',
fontSize: 13, fontWeight: 800,
color: period === p ? '#fff' : 'var(--warm-text-light)',
}}>
{p === 'week'
? t('leaderboard.thisWeek')
: p === 'month'
? t('leaderboard.thisMonth')
: p === 'quarter'
? t('leaderboard.thisQuarter')
: t('leaderboard.thisYear')}
</button>
))}
</div>
{/* Podium */}
{podium.length > 0 && (
<div className="tq-card" style={{ padding: 32, marginBottom: 20, background: 'var(--warm-streak-bg)' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-end', gap: 24, marginBottom: 8 }}>
{podium.map((u, i) => (
<div key={u.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flex: 1, maxWidth: 150 }}>
<UserAvatar name={u.displayName} color={u.avatarColor} size={52} avatarType={u.avatarType as any} avatarPreset={u.avatarPreset} avatarPhotoUrl={u.avatarPhotoUrl} />
<div style={{ fontSize: 14, fontWeight: 800, color: 'var(--warm-text)', marginTop: 6, marginBottom: 6 }}>{u.displayName}</div>
<div style={{
width: '100%', height: podiumHeights[i], borderRadius: '18px 18px 0 0',
background: podiumGradients[i],
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ fontSize: 12, fontWeight: 900, color: '#fff', opacity: 0.8 }}>#{podiumRanks[i]}</div>
<div style={{ fontSize: 24, fontWeight: 900, color: '#fff' }}>{u.points}</div>
<div style={{ fontSize: 10, color: '#ffffff99', fontWeight: 700 }}>{t('leaderboard.points')}</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Stats List */}
{sorted.map((u, i) => (
<div key={u.id} className="tq-card" style={{
display: 'flex', alignItems: 'center', gap: 16, padding: '16px 22px', marginBottom: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: 10,
backgroundColor: i === 0 ? 'var(--warm-accent-light)' : 'var(--warm-bg-subtle)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 900, color: i === 0 ? 'var(--warm-accent)' : 'var(--warm-text-light)',
border: i === 0 ? '1.5px solid var(--warm-accent)' : '1px solid var(--warm-border)',
}}>#{i + 1}</div>
<UserAvatar name={u.displayName} color={u.avatarColor} size={44} avatarType={u.avatarType as any} avatarPreset={u.avatarPreset} avatarPhotoUrl={u.avatarPhotoUrl} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 800, color: 'var(--warm-text)' }}>{u.displayName}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 12, color: 'var(--warm-text-light)', fontWeight: 600 }}>
<FireIcon /> {u.currentStreak}d {t('leaderboard.streak')}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 12, color: 'var(--warm-text-light)', fontWeight: 600 }}>
<CoinIcon /> {u.coins}
</span>
</div>
</div>
<div style={{ fontSize: 24, fontWeight: 900, color: 'var(--warm-accent)' }}>{u.points}</div>
</div>
))}
{sorted.length === 0 && (
<div className="tq-card" style={{ padding: 40, textAlign: 'center', color: 'var(--warm-text-light)', fontWeight: 600 }}>
{t('leaderboard.noData')}
</div>
)}
</div>
);
}