Skip to content
Open
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
1,452 changes: 1,400 additions & 52 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"tasks",
Expand All @@ -18,9 +20,9 @@
"author": "",
"license": "ISC",
"dependencies": {
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4",
Expand All @@ -37,5 +39,8 @@
"supabase": "^2.24.3",
"typescript": "^5.8.3",
"zod": "^3.24.3"
},
"devDependencies": {
"vitest": "^2.1.9"
}
}
19 changes: 12 additions & 7 deletions src/app/RaceTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,31 @@ type TaskType = {

type RaceTrackVisualizationProps = {
tasks?: TaskType[];
/** Car position 0–100. When provided, the demo animation is disabled. */
carPosition?: number;
className?: string;
};

const RaceTrackVisualization: React.FC<RaceTrackVisualizationProps> = ({
tasks = [],
carPosition: carPositionProp,
className = "",
}) => {
const [carPosition, setCarPosition] = useState(10);
// Real data drives the car when either tasks or an explicit position is given.
const isDriven = tasks.length > 0 || carPositionProp !== undefined;
const [demoPosition, setDemoPosition] = useState(10);

// Demo animation for the placeholder
// Demo animation only runs for the empty/placeholder state.
useEffect(() => {
if (isDriven) return;
const interval = setInterval(() => {
setCarPosition((prev) => {
if (prev >= 85) return 10;
return prev + 5;
});
setDemoPosition((prev) => (prev >= 85 ? 10 : prev + 5));
}, 1000);

return () => clearInterval(interval);
}, []);
}, [isDriven]);

const carPosition = isDriven ? (carPositionProp ?? 10) : demoPosition;

// Default placeholder tasks if none provided
const demoTasks =
Expand Down
9 changes: 9 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
} from '../../lib/api/supabase';
import { getUserPublicTasks } from '../../lib/api/publicTasks';
import Board from '../../components/ui/Board';
import RaceTrackVisualization from '../RaceTrack';
import { computeRaceTasks, computeCarPosition } from '../../lib/raceProgress';
import Button, { ButtonSize } from '../../components/ui/Button';
import CreateTaskModal from '../../components/task/CreateTaskModal';
import CreatePublicTaskModal from '../../components/task/CreatePublicTaskModal';
Expand Down Expand Up @@ -520,6 +522,13 @@ const DashboardPage: React.FC = () => {
</div>

<div className={styles.sidebar}>
<Board title="Your Lap">
<RaceTrackVisualization
tasks={computeRaceTasks(myTasks)}
carPosition={computeCarPosition(myTasks)}
/>
</Board>

<Board title="Leaderboard" className={styles.leaderboard}>
{leaderboard.map((entry, index) => (
<div key={entry.id} className={styles.leaderboardEntry}>
Expand Down
32 changes: 31 additions & 1 deletion src/app/leaderboard/Leaderboard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,34 @@
align-items: flex-start;
gap: 0.5rem;
}
}
}
/* Scope toggle (Everyone / Friends only) */
.scopeToggle {
display: inline-flex;
gap: 0.25rem;
margin-top: 1rem;
background: var(--background-alt, #f1f3f5);
padding: 0.25rem;
border-radius: 999px;
}

.scopeButton {
border: none;
background: transparent;
padding: 0.4rem 1rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary, #666);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}

.scopeButton:hover {
color: var(--text, #222);
}

.scopeActive {
background: var(--primary, #4f46e5);
color: #fff;
}
56 changes: 52 additions & 4 deletions src/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import styles from './Leaderboard.module.css';

// Leaderboard types
const LEADERBOARD_TYPES = [
{
id: 'champions',
name: 'Champions',
description: 'Ranked by the composite score: throughput × quality × on-time',
icon: '👑',
},
{
id: 'tasks-completed',
name: 'Tasks Completed',
Expand Down Expand Up @@ -47,24 +53,33 @@ const LeaderboardPage: React.FC = () => {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [friendsOnly, setFriendsOnly] = useState(false);

useEffect(() => {
if (user) {
fetchLeaderboardData(activeLeaderboard);
}
}, [user, activeLeaderboard]);
}, [user, activeLeaderboard, friendsOnly]);

const fetchLeaderboardData = async (leaderboardType: string) => {
setIsLoading(true);
setError(null);

try {
const leaderboardData = await getLeaderboard();

// Sort the leaderboard based on the type
let sortedData = [...leaderboardData];


// Friends-only toggle keeps the current user plus accepted friends.
if (friendsOnly) {
sortedData = sortedData.filter(entry => entry.isFriend);
}

switch (leaderboardType) {
case 'champions':
sortedData.sort((a, b) => (b.compositeScore || 0) - (a.compositeScore || 0));
break;
case 'tasks-completed':
sortedData.sort((a, b) => b.tasksCompleted - a.tasksCompleted);
break;
Expand Down Expand Up @@ -135,6 +150,20 @@ const LeaderboardPage: React.FC = () => {
<div className={styles.header}>
<h1 className={styles.title}>Leaderboards</h1>
<p className={styles.subtitle}>See who's leading in different categories</p>
<div className={styles.scopeToggle}>
<button
className={`${styles.scopeButton} ${!friendsOnly ? styles.scopeActive : ''}`}
onClick={() => setFriendsOnly(false)}
>
Everyone
</button>
<button
className={`${styles.scopeButton} ${friendsOnly ? styles.scopeActive : ''}`}
onClick={() => setFriendsOnly(true)}
>
Friends only
</button>
</div>
</div>

{error && (
Expand Down Expand Up @@ -188,12 +217,14 @@ const LeaderboardPage: React.FC = () => {
<div className={styles.rankColumn}>Rank</div>
<div className={styles.userColumn}>User</div>
<div className={styles.statsColumn}>
{activeLeaderboard === 'champions' && 'Score'}
{activeLeaderboard === 'tasks-completed' && 'Tasks Completed'}
{activeLeaderboard === 'quality-rating' && 'Avg. Quality'}
{activeLeaderboard === 'speed-demons' && 'Avg. Time'}
{activeLeaderboard === 'consistency' && 'Tasks Overdue'}
</div>
<div className={styles.extendedStatsColumn}>
{activeLeaderboard === 'champions' && 'Breakdown'}
{activeLeaderboard === 'tasks-completed' && 'Additional Stats'}
{activeLeaderboard === 'quality-rating' && 'Rating Breakdown'}
{activeLeaderboard === 'speed-demons' && 'Performance'}
Expand Down Expand Up @@ -240,12 +271,29 @@ const LeaderboardPage: React.FC = () => {
</div>
</div>
<div className={styles.statsColumn}>
{activeLeaderboard === 'champions' && <span className={styles.statValue}>{(entry.compositeScore || 0).toFixed(1)}</span>}
{activeLeaderboard === 'tasks-completed' && <span className={styles.statValue}>{entry.tasksCompleted}</span>}
{activeLeaderboard === 'quality-rating' && <span className={styles.statValue}>{formatRating(entry.avgQualityRating)}</span>}
{activeLeaderboard === 'speed-demons' && <span className={styles.statValue}>{formatTime(entry.avgCompletionTime)}</span>}
{activeLeaderboard === 'consistency' && <span className={styles.statValue}>{entry.tasksOverdue}</span>}
</div>
<div className={styles.extendedStatsColumn}>
{activeLeaderboard === 'champions' && (
<>
<div className={styles.miniStat}>
<span className={styles.miniStatLabel}>Tasks</span>
<span className={styles.miniStatValue}>{entry.tasksCompleted}</span>
</div>
<div className={styles.miniStat}>
<span className={styles.miniStatLabel}>Quality</span>
<span className={styles.miniStatValue}>{formatRating(entry.avgQualityRating)}</span>
</div>
<div className={styles.miniStat}>
<span className={styles.miniStatLabel}>5★ Tasks</span>
<span className={styles.miniStatValue}>{entry.perfectTasks || 0}</span>
</div>
</>
)}
{activeLeaderboard === 'tasks-completed' && (
<>
<div className={styles.miniStat}>
Expand Down
36 changes: 35 additions & 1 deletion src/app/task/TasksPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -477,4 +477,38 @@
font-size: 0.75rem;
padding: 0.4rem 0.8rem;
}
}
}
/* Search + priority filter row (spec US-5) */
.searchRow {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}

.searchInput {
flex: 1 1 240px;
min-width: 0;
padding: 0.6rem 0.9rem;
border: 1px solid var(--border, #d0d5dd);
border-radius: 8px;
font-size: 0.95rem;
background: var(--background, #fff);
color: var(--text, #1a1a1a);
}

.searchInput:focus {
outline: none;
border-color: var(--primary, #4f46e5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
}

.prioritySelect {
padding: 0.6rem 0.9rem;
border: 1px solid var(--border, #d0d5dd);
border-radius: 8px;
font-size: 0.95rem;
background: var(--background, #fff);
color: var(--text, #1a1a1a);
cursor: pointer;
}
Loading