feat: implement TodoList component with filtering and priority management

feat: add useAmbientSound hook for ambient sound management

feat: create useLocalStorage hook for persistent state management

feat: develop useTheme hook for theme switching functionality

feat: implement useTimer hook for Pomodoro timer logic

feat: create useTodos hook for managing todo list functionality

style: add global styles and custom scrollbar for better UI experience

chore: set up main entry point for the application

feat: define types for Timer, Todo, and Statistics

feat: create utility function for class name merging

chore: configure Tailwind CSS for styling

chore: set up TypeScript configuration for the project

chore: configure Vite for development and build process
This commit is contained in:
Puechberty Arthur
2026-03-30 19:27:27 +02:00
commit b933c6040c
27 changed files with 4520 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Dependencies
node_modules/
# Build outputs
dist/
# Environment variables
.env
.env.*
!.env.example
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor and OS files
.vscode/
.DS_Store
Thumbs.db
+42
View File
@@ -0,0 +1,42 @@
# FocusPomodoro
Application Pomodoro moderne construite avec React, Vite et Tailwind CSS.
## Site en ligne
Lien officiel: https://pomodoro.arthurp.fr
Ce lien est la référence publique du projet pour le référencement et le partage.
## Stack technique
- React 19
- Vite 7
- TypeScript
- Tailwind CSS 4
- Nginx (via Docker)
## Lancer en local
```bash
npm install
npm run dev
```
## Build de production
```bash
npm run build
npm run preview
```
## Déploiement Docker
```bash
docker compose up --build
```
## Sécurité et publication
- Les artefacts locaux sont exclus via `.gitignore` (`dist/`, `node_modules/`, fichiers `.env*`).
- Le projet est prêt pour un premier push GitHub.
+25
View File
@@ -0,0 +1,25 @@
services:
# Service de build - construit l'app puis s'arrête
builder:
image: node:20-alpine
working_dir: /app
volumes:
- .:/app
- node_modules:/app/node_modules
command: sh -c "npm install && npm run build"
# Service nginx - attend que le build soit terminé
pomodoro:
image: nginx:alpine
ports:
- "3009:80"
volumes:
- ./dist:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
depends_on:
builder:
condition: service_completed_successfully
volumes:
node_modules:
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍅</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="FocusPomodoro - Une application de productivité utilisant la technique Pomodoro avec minuteur, ambiance sonore et liste de tâches." />
<meta name="theme-color" content="#f43f5e" />
<title>🍅 FocusPomodoro - Restez concentré</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
# Cache pour les assets statiques
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback - redirige toutes les routes vers index.html
location / {
try_files $uri $uri/ /index.html;
}
}
+2575
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "react-vite-tailwind",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "2.1.1",
"lucide-react": "^0.563.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.17",
"@types/node": "^22.0.0",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.1",
"tailwindcss": "4.1.17",
"typescript": "5.9.3",
"vite": "7.2.4",
"vite-plugin-singlefile": "2.3.0"
}
}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { Header } from './components/Header';
import { Timer } from './components/Timer';
import { AmbientSound } from './components/AmbientSound';
import { TodoList } from './components/TodoList';
import { Statistics } from './components/Statistics';
import { Settings } from './components/Settings';
import { useTimer } from './hooks/useTimer';
import { useAmbientSound } from './hooks/useAmbientSound';
import { useTodos } from './hooks/useTodos';
import { useTheme } from './hooks/useTheme';
export default function App() {
const { theme, toggleTheme } = useTheme();
const [settingsOpen, setSettingsOpen] = useState(false);
const {
mode,
timeLeft,
isRunning,
progress,
sessionCount,
settings,
statistics,
changeMode,
toggleTimer,
resetTimer,
updateSettings,
skipSession,
} = useTimer();
const {
currentSound,
volume,
isPlaying,
playSound,
changeVolume,
toggleSound,
} = useAmbientSound();
const {
todos,
completedCount,
activeCount,
addTodo,
toggleTodo,
deleteTodo,
clearCompleted,
incrementPomodoro,
} = useTodos();
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-rose-50/30 to-indigo-50/30 dark:from-gray-900 dark:via-gray-900 dark:to-gray-800 transition-colors duration-300">
<div className="container mx-auto px-4 max-w-6xl">
<Header
theme={theme}
onToggleTheme={toggleTheme}
onOpenSettings={() => setSettingsOpen(true)}
/>
<main className="py-8">
<div className="grid lg:grid-cols-3 gap-8">
{/* Main Timer Section */}
<div className="lg:col-span-2">
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-3xl p-8 shadow-xl shadow-gray-200/50 dark:shadow-none border border-gray-100 dark:border-gray-700">
<Timer
mode={mode}
timeLeft={timeLeft}
isRunning={isRunning}
progress={progress}
sessionCount={sessionCount}
dailyGoal={settings.dailyGoal}
completedToday={statistics.completedToday}
onModeChange={changeMode}
onToggle={toggleTimer}
onReset={resetTimer}
onSkip={skipSession}
/>
</div>
{/* Ambient Sound - Below Timer on Desktop */}
<div className="mt-6">
<AmbientSound
currentSound={currentSound}
volume={volume}
isPlaying={isPlaying}
onSoundChange={playSound}
onVolumeChange={changeVolume}
onToggle={toggleSound}
/>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
<Statistics
statistics={statistics}
dailyGoal={settings.dailyGoal}
/>
<TodoList
todos={todos}
completedCount={completedCount}
activeCount={activeCount}
onAdd={addTodo}
onToggle={toggleTodo}
onDelete={deleteTodo}
onClearCompleted={clearCompleted}
onIncrementPomodoro={incrementPomodoro}
/>
</div>
</div>
{/* Tips Section */}
<div className="mt-12 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-2xl p-6 border border-gray-100 dark:border-gray-700">
<h3 className="font-semibold text-gray-800 dark:text-white mb-4">
💡 Conseils pour la technique Pomodoro
</h3>
<div className="grid md:grid-cols-3 gap-4 text-sm text-gray-600 dark:text-gray-400">
<div className="flex gap-3">
<span className="text-2xl">🎯</span>
<div>
<p className="font-medium text-gray-800 dark:text-white">Restez concentré</p>
<p>Évitez toute distraction pendant les sessions de focus</p>
</div>
</div>
<div className="flex gap-3">
<span className="text-2xl"></span>
<div>
<p className="font-medium text-gray-800 dark:text-white">Prenez de vraies pauses</p>
<p>Éloignez-vous de l'écran, étirez-vous, hydratez-vous</p>
</div>
</div>
<div className="flex gap-3">
<span className="text-2xl">📝</span>
<div>
<p className="font-medium text-gray-800 dark:text-white">Planifiez vos tâches</p>
<p>Divisez les gros projets en petites tâches gérables</p>
</div>
</div>
</div>
</div>
{/* Footer */}
<footer className="text-center py-8 text-sm text-gray-400 dark:text-gray-500">
<p>
Créé avec pour améliorer votre productivité
</p>
<p className="mt-1">
© 2024 FocusPomodoro Projet Portfolio
</p>
</footer>
</main>
</div>
<Settings
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
settings={settings}
onUpdateSettings={updateSettings}
/>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import React from 'react';
import { Volume2, VolumeX, CloudRain, Coffee, Radio, Trees, Waves } from 'lucide-react';
import { SoundType } from '../hooks/useAmbientSound';
interface AmbientSoundProps {
currentSound: SoundType;
volume: number;
isPlaying: boolean;
onSoundChange: (sound: SoundType) => void;
onVolumeChange: (volume: number) => void;
onToggle: () => void;
}
export function AmbientSound({
currentSound,
volume,
isPlaying,
onSoundChange,
onVolumeChange,
onToggle,
}: AmbientSoundProps) {
const sounds: { key: SoundType; label: string; icon: React.ReactNode }[] = [
{ key: 'none', label: 'Aucun', icon: <VolumeX className="w-5 h-5" /> },
{ key: 'rain', label: 'Pluie', icon: <CloudRain className="w-5 h-5" /> },
{ key: 'cafe', label: 'Café', icon: <Coffee className="w-5 h-5" /> },
{ key: 'whitenoise', label: 'Bruit blanc', icon: <Radio className="w-5 h-5" /> },
{ key: 'forest', label: 'Forêt', icon: <Trees className="w-5 h-5" /> },
{ key: 'ocean', label: 'Océan', icon: <Waves className="w-5 h-5" /> },
];
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<Volume2 className="w-5 h-5 text-indigo-500" />
Ambiance sonore
</h3>
{currentSound !== 'none' && (
<button
onClick={onToggle}
className={`px-3 py-1 rounded-full text-sm font-medium transition-all ${
isPlaying
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{isPlaying ? '🔊 En cours' : '🔇 Muet'}
</button>
)}
</div>
{/* Sound selector */}
<div className="grid grid-cols-3 gap-2 mb-4">
{sounds.map(({ key, label, icon }) => (
<button
key={key}
onClick={() => onSoundChange(key)}
className={`flex flex-col items-center gap-1 p-3 rounded-xl transition-all ${
currentSound === key
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400 ring-2 ring-indigo-500'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
}`}
>
{icon}
<span className="text-xs font-medium">{label}</span>
</button>
))}
</div>
{/* Volume slider */}
<div className="flex items-center gap-3">
<VolumeX className="w-4 h-4 text-gray-400" />
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4
[&::-webkit-slider-thumb]:bg-indigo-500 [&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-transform
[&::-webkit-slider-thumb]:hover:scale-110"
disabled={currentSound === 'none'}
/>
<Volume2 className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500 dark:text-gray-400 w-12 text-right">
{Math.round(volume * 100)}%
</span>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import React from 'react';
import { TimerMode } from '../types';
interface CircularProgressProps {
progress: number;
size?: number;
strokeWidth?: number;
mode: TimerMode;
children: React.ReactNode;
}
export function CircularProgress({
progress,
size = 280,
strokeWidth = 8,
mode,
children
}: CircularProgressProps) {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (progress / 100) * circumference;
const getColor = () => {
switch (mode) {
case 'focus': return 'stroke-rose-500 dark:stroke-rose-400';
case 'shortBreak': return 'stroke-emerald-500 dark:stroke-emerald-400';
case 'longBreak': return 'stroke-indigo-500 dark:stroke-indigo-400';
}
};
const getBgColor = () => {
switch (mode) {
case 'focus': return 'stroke-rose-100 dark:stroke-rose-900/30';
case 'shortBreak': return 'stroke-emerald-100 dark:stroke-emerald-900/30';
case 'longBreak': return 'stroke-indigo-100 dark:stroke-indigo-900/30';
}
};
return (
<div className="relative inline-flex items-center justify-center">
<svg
width={size}
height={size}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
className={getBgColor()}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
strokeLinecap="round"
className={`${getColor()} transition-all duration-300 ease-out`}
style={{
strokeDasharray: circumference,
strokeDashoffset: offset,
}}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
{children}
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Settings, Moon, Sun, Timer } from 'lucide-react';
import { Theme } from '../hooks/useTheme';
interface HeaderProps {
theme: Theme;
onToggleTheme: () => void;
onOpenSettings: () => void;
}
export function Header({ theme, onToggleTheme, onOpenSettings }: HeaderProps) {
return (
<header className="flex items-center justify-between py-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-rose-500 to-rose-600 rounded-xl flex items-center justify-center shadow-lg shadow-rose-500/25">
<Timer className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-800 dark:text-white">
FocusPomodoro
</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">
Restez concentré, soyez productif
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onToggleTheme}
className="p-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition-all"
title={theme === 'light' ? 'Mode sombre' : 'Mode clair'}
>
{theme === 'light' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
</button>
<button
onClick={onOpenSettings}
className="p-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition-all"
title="Paramètres"
>
<Settings className="w-5 h-5" />
</button>
</div>
</header>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { Settings as SettingsIcon, X, Clock, Target, Zap } from 'lucide-react';
import { TimerSettings } from '../types';
interface SettingsProps {
isOpen: boolean;
onClose: () => void;
settings: TimerSettings;
onUpdateSettings: (settings: Partial<TimerSettings>) => void;
}
export function Settings({ isOpen, onClose, settings, onUpdateSettings }: SettingsProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700">
<h2 className="text-xl font-bold text-gray-800 dark:text-white flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-indigo-500" />
Paramètres
</h2>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-6">
{/* Timer durations */}
<div>
<h3 className="font-medium text-gray-800 dark:text-white mb-4 flex items-center gap-2">
<Clock className="w-4 h-4 text-rose-500" />
Durées (en minutes)
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">
🍅 Focus
</label>
<input
type="number"
min="1"
max="60"
value={settings.focusDuration}
onChange={(e) => onUpdateSettings({ focusDuration: parseInt(e.target.value) || 25 })}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500"
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">
Pause courte
</label>
<input
type="number"
min="1"
max="30"
value={settings.shortBreakDuration}
onChange={(e) => onUpdateSettings({ shortBreakDuration: parseInt(e.target.value) || 5 })}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500"
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">
🌴 Pause longue
</label>
<input
type="number"
min="1"
max="60"
value={settings.longBreakDuration}
onChange={(e) => onUpdateSettings({ longBreakDuration: parseInt(e.target.value) || 15 })}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500"
/>
</div>
</div>
</div>
{/* Daily goal */}
<div>
<h3 className="font-medium text-gray-800 dark:text-white mb-4 flex items-center gap-2">
<Target className="w-4 h-4 text-emerald-500" />
Objectif quotidien
</h3>
<div className="flex items-center gap-4">
<input
type="range"
min="1"
max="16"
value={settings.dailyGoal}
onChange={(e) => onUpdateSettings({ dailyGoal: parseInt(e.target.value) })}
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4
[&::-webkit-slider-thumb]:bg-emerald-500 [&::-webkit-slider-thumb]:rounded-full"
/>
<span className="w-16 text-center font-medium text-gray-800 dark:text-white">
{settings.dailyGoal} 🍅
</span>
</div>
</div>
{/* Auto-start options */}
<div>
<h3 className="font-medium text-gray-800 dark:text-white mb-4 flex items-center gap-2">
<Zap className="w-4 h-4 text-amber-500" />
Automatisation
</h3>
<div className="space-y-3">
<label className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-xl cursor-pointer">
<span className="text-gray-700 dark:text-gray-300">Démarrer les pauses automatiquement</span>
<input
type="checkbox"
checked={settings.autoStartBreaks}
onChange={(e) => onUpdateSettings({ autoStartBreaks: e.target.checked })}
className="w-5 h-5 rounded text-rose-500 focus:ring-rose-500 cursor-pointer"
/>
</label>
<label className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-xl cursor-pointer">
<span className="text-gray-700 dark:text-gray-300">Démarrer les pomodoros automatiquement</span>
<input
type="checkbox"
checked={settings.autoStartPomodoros}
onChange={(e) => onUpdateSettings({ autoStartPomodoros: e.target.checked })}
className="w-5 h-5 rounded text-rose-500 focus:ring-rose-500 cursor-pointer"
/>
</label>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-100 dark:border-gray-700">
<button
onClick={onClose}
className="w-full py-3 bg-indigo-500 hover:bg-indigo-600 text-white font-medium rounded-xl transition-all"
>
Fermer
</button>
</div>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { TrendingUp, Flame, Target, Award } from 'lucide-react';
import { Statistics as StatsType } from '../types';
interface StatisticsProps {
statistics: StatsType;
dailyGoal: number;
}
export function Statistics({ statistics, dailyGoal }: StatisticsProps) {
const goalProgress = Math.min((statistics.completedToday / dailyGoal) * 100, 100);
const goalReached = statistics.completedToday >= dailyGoal;
const stats = [
{
label: 'Aujourd\'hui',
value: statistics.completedToday,
icon: Target,
color: 'text-rose-500',
bgColor: 'bg-rose-50 dark:bg-rose-900/30',
},
{
label: 'Total',
value: statistics.completedTotal,
icon: TrendingUp,
color: 'text-indigo-500',
bgColor: 'bg-indigo-50 dark:bg-indigo-900/30',
},
{
label: 'Série actuelle',
value: `${statistics.currentStreak} jour${statistics.currentStreak > 1 ? 's' : ''}`,
icon: Flame,
color: 'text-orange-500',
bgColor: 'bg-orange-50 dark:bg-orange-900/30',
},
{
label: 'Meilleure série',
value: `${statistics.bestStreak} jour${statistics.bestStreak > 1 ? 's' : ''}`,
icon: Award,
color: 'text-amber-500',
bgColor: 'bg-amber-50 dark:bg-amber-900/30',
},
];
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-indigo-500" />
Statistiques
</h3>
{goalReached && (
<span className="text-xs px-2 py-1 bg-emerald-100 dark:bg-emerald-900/50 text-emerald-600 dark:text-emerald-400 rounded-full flex items-center gap-1">
<Award className="w-3 h-3" />
Objectif atteint !
</span>
)}
</div>
{/* Goal progress */}
<div className="mb-6">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">Progression quotidienne</span>
<span className="font-medium text-gray-800 dark:text-white">
{statistics.completedToday}/{dailyGoal} 🍅
</span>
</div>
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
goalReached
? 'bg-gradient-to-r from-emerald-400 to-emerald-500'
: 'bg-gradient-to-r from-rose-400 to-rose-500'
}`}
style={{ width: `${goalProgress}%` }}
/>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{stats.map(({ label, value, icon: Icon, color, bgColor }) => (
<div
key={label}
className={`${bgColor} rounded-xl p-4 transition-transform hover:scale-105`}
>
<Icon className={`w-5 h-5 ${color} mb-2`} />
<div className="text-2xl font-bold text-gray-800 dark:text-white">
{value}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{label}</div>
</div>
))}
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { Play, Pause, RotateCcw, SkipForward } from 'lucide-react';
import { TimerMode } from '../types';
import { CircularProgress } from './CircularProgress';
interface TimerProps {
mode: TimerMode;
timeLeft: number;
isRunning: boolean;
progress: number;
sessionCount: number;
dailyGoal: number;
completedToday: number;
onModeChange: (mode: TimerMode) => void;
onToggle: () => void;
onReset: () => void;
onSkip: () => void;
}
export function Timer({
mode,
timeLeft,
isRunning,
progress,
sessionCount,
dailyGoal,
completedToday,
onModeChange,
onToggle,
onReset,
onSkip,
}: TimerProps) {
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
const modes: { key: TimerMode; label: string; emoji: string }[] = [
{ key: 'focus', label: 'Focus', emoji: '🍅' },
{ key: 'shortBreak', label: 'Pause courte', emoji: '☕' },
{ key: 'longBreak', label: 'Pause longue', emoji: '🌴' },
];
const getModeStyles = (key: TimerMode, isActive: boolean) => {
const base = 'px-4 py-2 rounded-full text-sm font-medium transition-all duration-200';
if (!isActive) return `${base} text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700`;
switch (key) {
case 'focus': return `${base} bg-rose-500 text-white shadow-lg shadow-rose-500/25`;
case 'shortBreak': return `${base} bg-emerald-500 text-white shadow-lg shadow-emerald-500/25`;
case 'longBreak': return `${base} bg-indigo-500 text-white shadow-lg shadow-indigo-500/25`;
}
};
const getButtonStyles = () => {
switch (mode) {
case 'focus': return 'bg-rose-500 hover:bg-rose-600 shadow-lg shadow-rose-500/30';
case 'shortBreak': return 'bg-emerald-500 hover:bg-emerald-600 shadow-lg shadow-emerald-500/30';
case 'longBreak': return 'bg-indigo-500 hover:bg-indigo-600 shadow-lg shadow-indigo-500/30';
}
};
return (
<div className="flex flex-col items-center">
{/* Mode selector */}
<div className="flex gap-2 mb-8 p-1 bg-gray-100 dark:bg-gray-800 rounded-full">
{modes.map(({ key, label, emoji }) => (
<button
key={key}
onClick={() => onModeChange(key)}
className={getModeStyles(key, mode === key)}
>
<span className="mr-1">{emoji}</span>
{label}
</button>
))}
</div>
{/* Circular Timer */}
<CircularProgress progress={progress} mode={mode} size={300} strokeWidth={10}>
<div className="text-center">
<div className="text-6xl md:text-7xl font-bold font-mono text-gray-800 dark:text-white tracking-tight">
{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Session {(sessionCount % 4) + 1}/4
</div>
</div>
</CircularProgress>
{/* Daily progress */}
<div className="mt-6 mb-4">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>Objectif du jour :</span>
<div className="flex gap-1">
{Array.from({ length: dailyGoal }).map((_, i) => (
<div
key={i}
className={`w-3 h-3 rounded-full transition-all ${
i < completedToday
? 'bg-rose-500 scale-110'
: 'bg-gray-200 dark:bg-gray-700'
}`}
/>
))}
</div>
<span className="font-medium">{completedToday}/{dailyGoal}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-4 mt-4">
<button
onClick={onReset}
className="p-3 rounded-full text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
title="Réinitialiser (R)"
>
<RotateCcw className="w-6 h-6" />
</button>
<button
onClick={onToggle}
className={`p-5 rounded-full text-white transition-all transform hover:scale-105 active:scale-95 ${getButtonStyles()}`}
title={isRunning ? 'Pause (Espace)' : 'Démarrer (Espace)'}
>
{isRunning ? <Pause className="w-8 h-8" /> : <Play className="w-8 h-8 ml-1" />}
</button>
<button
onClick={onSkip}
className="p-3 rounded-full text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
title="Passer"
>
<SkipForward className="w-6 h-6" />
</button>
</div>
{/* Keyboard shortcuts hint */}
<div className="mt-6 text-xs text-gray-400 dark:text-gray-500">
<kbd className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded">Espace</kbd> Play/Pause
<kbd className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded ml-2">R</kbd> Reset
</div>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import React, { useState } from 'react';
import { Plus, Check, Trash2, ListTodo, Sparkles } from 'lucide-react';
import { Todo, Priority } from '../types';
interface TodoListProps {
todos: Todo[];
completedCount: number;
activeCount: number;
onAdd: (text: string, priority: Priority) => void;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onClearCompleted: () => void;
onIncrementPomodoro: (id: string) => void;
}
export function TodoList({
todos,
completedCount,
activeCount,
onAdd,
onToggle,
onDelete,
onClearCompleted,
onIncrementPomodoro,
}: TodoListProps) {
const [newTodo, setNewTodo] = useState('');
const [priority, setPriority] = useState<Priority>('medium');
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newTodo.trim()) {
onAdd(newTodo, priority);
setNewTodo('');
}
};
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
const getPriorityColor = (p: Priority) => {
switch (p) {
case 'high': return 'text-red-500 bg-red-50 dark:bg-red-900/30';
case 'medium': return 'text-amber-500 bg-amber-50 dark:bg-amber-900/30';
case 'low': return 'text-blue-500 bg-blue-50 dark:bg-blue-900/30';
}
};
const getPriorityLabel = (p: Priority) => {
switch (p) {
case 'high': return 'Haute';
case 'medium': return 'Moyenne';
case 'low': return 'Basse';
}
};
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<ListTodo className="w-5 h-5 text-rose-500" />
Tâches
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded-full">
{activeCount} active{activeCount > 1 ? 's' : ''}
</span>
</div>
</div>
{/* Add todo form */}
<form onSubmit={handleSubmit} className="mb-4">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Ajouter une tâche..."
className="flex-1 min-w-0 px-4 py-2.5 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-800 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:border-transparent transition-all"
/>
<div className="flex gap-2">
<select
value={priority}
onChange={(e) => setPriority(e.target.value as Priority)}
className="flex-1 sm:flex-none px-3 py-2.5 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-rose-500"
>
<option value="high">🔴 Haute</option>
<option value="medium">🟡 Moyenne</option>
<option value="low">🔵 Basse</option>
</select>
<button
type="submit"
className="flex-shrink-0 p-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-xl transition-all hover:scale-105 active:scale-95 shadow-lg shadow-rose-500/25"
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
</form>
{/* Filter tabs */}
<div className="flex gap-1 mb-4 p-1 bg-gray-100 dark:bg-gray-700 rounded-lg">
{(['all', 'active', 'completed'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-all ${
filter === f
? 'bg-white dark:bg-gray-600 text-gray-800 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
{f === 'all' ? 'Toutes' : f === 'active' ? 'Actives' : 'Terminées'}
</button>
))}
</div>
{/* Todo list */}
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
{filteredTodos.length === 0 ? (
<div className="text-center py-8 text-gray-400 dark:text-gray-500">
<Sparkles className="w-10 h-10 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{filter === 'all'
? 'Aucune tâche pour le moment'
: filter === 'active'
? 'Toutes les tâches sont terminées !'
: 'Aucune tâche terminée'}
</p>
</div>
) : (
filteredTodos.map((todo) => (
<div
key={todo.id}
className={`group flex items-center gap-3 p-3 rounded-xl transition-all ${
todo.completed
? 'bg-gray-50 dark:bg-gray-700/50'
: 'bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
>
<button
onClick={() => onToggle(todo.id)}
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
todo.completed
? 'bg-emerald-500 border-emerald-500 text-white'
: 'border-gray-300 dark:border-gray-500 hover:border-emerald-500'
}`}
>
{todo.completed && <Check className="w-4 h-4" />}
</button>
<div className="flex-1 min-w-0">
<p className={`text-gray-800 dark:text-white truncate ${
todo.completed ? 'line-through text-gray-400 dark:text-gray-500' : ''
}`}>
{todo.text}
</p>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${getPriorityColor(todo.priority)}`}>
{getPriorityLabel(todo.priority)}
</span>
{todo.pomodorosSpent > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500">
🍅 {todo.pomodorosSpent}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!todo.completed && (
<button
onClick={() => onIncrementPomodoro(todo.id)}
className="p-1.5 text-gray-400 hover:text-rose-500 rounded-lg hover:bg-rose-50 dark:hover:bg-rose-900/30 transition-all"
title="Ajouter un pomodoro"
>
🍅
</button>
)}
<button
onClick={() => onDelete(todo.id)}
className="p-1.5 text-gray-400 hover:text-red-500 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
{/* Clear completed */}
{completedCount > 0 && (
<button
onClick={onClearCompleted}
className="mt-4 w-full py-2 text-sm text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
>
Supprimer les {completedCount} tâche{completedCount > 1 ? 's' : ''} terminée{completedCount > 1 ? 's' : ''}
</button>
)}
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useState, useRef, useCallback, useEffect } from 'react';
export type SoundType = 'none' | 'rain' | 'cafe' | 'whitenoise' | 'forest' | 'ocean';
export function useAmbientSound() {
const [currentSound, setCurrentSound] = useState<SoundType>('none');
const [volume, setVolume] = useState(0.5);
const [isPlaying, setIsPlaying] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const nodesRef = useRef<{
sources: AudioBufferSourceNode[];
gains: GainNode[];
masterGain: GainNode | null;
}>({ sources: [], gains: [], masterGain: null });
const stopSound = useCallback(() => {
nodesRef.current.sources.forEach(source => {
try { source.stop(); } catch (e) {}
});
nodesRef.current.sources = [];
nodesRef.current.gains = [];
setIsPlaying(false);
}, []);
const createNoiseBuffer = useCallback((ctx: AudioContext, type: 'white' | 'brown' | 'pink') => {
const bufferSize = ctx.sampleRate * 2;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const output = buffer.getChannelData(0);
if (type === 'white') {
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
} else if (type === 'brown') {
let lastOut = 0;
for (let i = 0; i < bufferSize; i++) {
const white = Math.random() * 2 - 1;
output[i] = (lastOut + 0.02 * white) / 1.02;
lastOut = output[i];
output[i] *= 3.5;
}
} else if (type === 'pink') {
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
for (let i = 0; i < bufferSize; i++) {
const white = Math.random() * 2 - 1;
b0 = 0.99886 * b0 + white * 0.0555179;
b1 = 0.99332 * b1 + white * 0.0750759;
b2 = 0.96900 * b2 + white * 0.1538520;
b3 = 0.86650 * b3 + white * 0.3104856;
b4 = 0.55000 * b4 + white * 0.5329522;
b5 = -0.7616 * b5 - white * 0.0168980;
output[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
output[i] *= 0.11;
b6 = white * 0.115926;
}
}
return buffer;
}, []);
const playSound = useCallback((type: SoundType) => {
stopSound();
if (type === 'none') {
setCurrentSound('none');
return;
}
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}
const ctx = audioContextRef.current;
const masterGain = ctx.createGain();
masterGain.gain.value = volume;
masterGain.connect(ctx.destination);
nodesRef.current.masterGain = masterGain;
const createLoopingSource = (buffer: AudioBuffer, gainValue: number = 1) => {
const source = ctx.createBufferSource();
const gain = ctx.createGain();
source.buffer = buffer;
source.loop = true;
gain.gain.value = gainValue;
source.connect(gain);
gain.connect(masterGain);
source.start();
nodesRef.current.sources.push(source);
nodesRef.current.gains.push(gain);
return { source, gain };
};
switch (type) {
case 'rain': {
// Bruit de pluie = bruit rose + bruit brun
const pinkBuffer = createNoiseBuffer(ctx, 'pink');
const brownBuffer = createNoiseBuffer(ctx, 'brown');
createLoopingSource(pinkBuffer, 0.6);
createLoopingSource(brownBuffer, 0.3);
break;
}
case 'cafe': {
// Ambiance café = bruit brun léger + variations
const brownBuffer = createNoiseBuffer(ctx, 'brown');
const pinkBuffer = createNoiseBuffer(ctx, 'pink');
createLoopingSource(brownBuffer, 0.4);
createLoopingSource(pinkBuffer, 0.15);
break;
}
case 'whitenoise': {
const whiteBuffer = createNoiseBuffer(ctx, 'white');
createLoopingSource(whiteBuffer, 0.3);
break;
}
case 'forest': {
// Forêt = bruit rose doux
const pinkBuffer = createNoiseBuffer(ctx, 'pink');
const brownBuffer = createNoiseBuffer(ctx, 'brown');
createLoopingSource(pinkBuffer, 0.25);
createLoopingSource(brownBuffer, 0.15);
break;
}
case 'ocean': {
// Océan = bruit brun
const brownBuffer = createNoiseBuffer(ctx, 'brown');
createLoopingSource(brownBuffer, 0.7);
break;
}
}
setCurrentSound(type);
setIsPlaying(true);
}, [volume, stopSound, createNoiseBuffer]);
const changeVolume = useCallback((newVolume: number) => {
setVolume(newVolume);
if (nodesRef.current.masterGain) {
nodesRef.current.masterGain.gain.value = newVolume;
}
}, []);
const toggleSound = useCallback(() => {
if (isPlaying) {
stopSound();
} else if (currentSound !== 'none') {
playSound(currentSound);
}
}, [isPlaying, currentSound, playSound, stopSound]);
useEffect(() => {
return () => {
stopSound();
if (audioContextRef.current) {
audioContextRef.current.close();
}
};
}, [stopSound]);
return {
currentSound,
volume,
isPlaying,
playSound,
stopSound,
changeVolume,
toggleSound,
};
}
+30
View File
@@ -0,0 +1,30 @@
import { useState, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = useCallback((value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setValue];
}
+36
View File
@@ -0,0 +1,36 @@
import { useCallback, useEffect } from 'react';
import { useLocalStorage } from './useLocalStorage';
export type Theme = 'light' | 'dark';
export function useTheme() {
const [theme, setTheme] = useLocalStorage<Theme>('pomodoro-theme', 'light');
useEffect(() => {
const root = window.document.documentElement;
const body = window.document.body;
// Remove previous theme classes
root.classList.remove('light', 'dark');
body.classList.remove('light', 'dark');
// Add current theme class
root.classList.add(theme);
body.classList.add(theme);
// Update background color immediately
if (theme === 'dark') {
root.style.colorScheme = 'dark';
body.style.backgroundColor = '#111827';
} else {
root.style.colorScheme = 'light';
body.style.backgroundColor = '#fdf2f8';
}
}, [theme]);
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, [setTheme]);
return { theme, toggleTheme };
}
+364
View File
@@ -0,0 +1,364 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { TimerMode, TimerSettings, Statistics, DEFAULT_SETTINGS, DEFAULT_STATISTICS } from '../types';
import { useLocalStorage } from './useLocalStorage';
export function useTimer() {
const [settings, setSettings] = useLocalStorage<TimerSettings>('pomodoro-settings', DEFAULT_SETTINGS);
const [statistics, setStatistics] = useLocalStorage<Statistics>('pomodoro-statistics', DEFAULT_STATISTICS);
const [mode, setMode] = useState<TimerMode>('focus');
const [timeLeft, setTimeLeft] = useState(settings.focusDuration * 60);
const [isRunning, setIsRunning] = useState(false);
const [sessionCount, setSessionCount] = useState(0);
const audioContextRef = useRef<AudioContext | null>(null);
const totalTime = useRef(settings.focusDuration * 60);
// Refs pour le timer basé sur le temps réel
const endTimeRef = useRef<number | null>(null);
const timerCompletedWhileHiddenRef = useRef(false);
const titleBlinkIntervalRef = useRef<NodeJS.Timeout | null>(null);
const getDuration = useCallback((timerMode: TimerMode) => {
switch (timerMode) {
case 'focus': return settings.focusDuration * 60;
case 'shortBreak': return settings.shortBreakDuration * 60;
case 'longBreak': return settings.longBreakDuration * 60;
}
}, [settings]);
const progress = ((totalTime.current - timeLeft) / totalTime.current) * 100;
const playNotificationSound = useCallback(() => {
try {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}
const ctx = audioContextRef.current;
// Jouer une mélodie agréable
const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6
notes.forEach((freq, i) => {
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = freq;
oscillator.type = 'sine';
const startTime = ctx.currentTime + i * 0.15;
gainNode.gain.setValueAtTime(0.3, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
oscillator.start(startTime);
oscillator.stop(startTime + 0.3);
});
} catch (error) {
console.warn('Could not play notification sound:', error);
}
}, []);
const requestNotificationPermission = useCallback(async () => {
if ('Notification' in window && Notification.permission === 'default') {
await Notification.requestPermission();
}
}, []);
const showNotification = useCallback((title: string, body: string) => {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, {
body,
icon: '🍅',
requireInteraction: true, // La notification reste jusqu'à ce que l'utilisateur clique
tag: 'pomodoro-timer' // Évite les notifications en double
});
}
}, []);
// Fonction pour faire clignoter le titre
const startTitleBlink = useCallback((message: string) => {
if (titleBlinkIntervalRef.current) return; // Déjà en train de clignoter
const originalTitle = document.title;
let isOriginal = true;
titleBlinkIntervalRef.current = setInterval(() => {
document.title = isOriginal ? `🔔 ${message}` : originalTitle;
isOriginal = !isOriginal;
}, 1000);
}, []);
const stopTitleBlink = useCallback(() => {
if (titleBlinkIntervalRef.current) {
clearInterval(titleBlinkIntervalRef.current);
titleBlinkIntervalRef.current = null;
}
}, []);
const updateStatistics = useCallback(() => {
const today = new Date().toDateString();
setStatistics(prev => {
const isNewDay = prev.lastCompletedDate !== today;
const newCompletedToday = isNewDay ? 1 : prev.completedToday + 1;
// Calcul de la série
let newStreak = prev.currentStreak;
if (isNewDay) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (prev.lastCompletedDate === yesterday.toDateString()) {
newStreak = prev.currentStreak + 1;
} else if (prev.lastCompletedDate !== today) {
newStreak = 1;
}
}
return {
completedToday: newCompletedToday,
completedTotal: prev.completedTotal + 1,
currentStreak: newStreak,
bestStreak: Math.max(prev.bestStreak, newStreak),
lastCompletedDate: today,
};
});
}, [setStatistics]);
const handleTimerComplete = useCallback(() => {
const isPageHidden = document.visibilityState === 'hidden';
// Si la page est visible, jouer le son immédiatement
if (!isPageHidden) {
playNotificationSound();
} else {
// Sinon, marquer qu'on doit jouer le son quand l'utilisateur revient
timerCompletedWhileHiddenRef.current = true;
}
setIsRunning(false);
endTimeRef.current = null;
// Faire clignoter le titre si la page est cachée
if (isPageHidden) {
startTitleBlink(mode === 'focus' ? 'Pomodoro terminé !' : 'Pause terminée !');
}
if (mode === 'focus') {
updateStatistics();
const newSessionCount = sessionCount + 1;
setSessionCount(newSessionCount);
showNotification('🍅 Pomodoro terminé !', 'Bravo ! Il est temps de faire une pause.');
// Après 4 pomodoros, pause longue
if (newSessionCount % 4 === 0) {
setMode('longBreak');
const duration = getDuration('longBreak');
setTimeLeft(duration);
totalTime.current = duration;
if (settings.autoStartBreaks) {
setIsRunning(true);
endTimeRef.current = Date.now() + duration * 1000;
}
} else {
setMode('shortBreak');
const duration = getDuration('shortBreak');
setTimeLeft(duration);
totalTime.current = duration;
if (settings.autoStartBreaks) {
setIsRunning(true);
endTimeRef.current = Date.now() + duration * 1000;
}
}
} else {
showNotification('⏰ Pause terminée !', 'C\'est reparti pour un nouveau pomodoro !');
setMode('focus');
const duration = getDuration('focus');
setTimeLeft(duration);
totalTime.current = duration;
if (settings.autoStartPomodoros) {
setIsRunning(true);
endTimeRef.current = Date.now() + duration * 1000;
}
}
}, [mode, sessionCount, settings, getDuration, playNotificationSound, showNotification, updateStatistics, startTitleBlink]);
// Timer basé sur le temps réel (fonctionne même en arrière-plan)
useEffect(() => {
let interval: NodeJS.Timeout;
if (isRunning) {
// Si on démarre et qu'il n'y a pas d'heure de fin, la définir
if (endTimeRef.current === null) {
endTimeRef.current = Date.now() + timeLeft * 1000;
}
const updateTimer = () => {
if (endTimeRef.current === null) return;
const remaining = Math.round((endTimeRef.current - Date.now()) / 1000);
if (remaining <= 0) {
setTimeLeft(0);
handleTimerComplete();
} else {
setTimeLeft(remaining);
}
};
// Mise à jour toutes les 100ms pour plus de précision
interval = setInterval(updateTimer, 100);
}
return () => {
if (interval) clearInterval(interval);
};
}, [isRunning, handleTimerComplete]);
// Gestionnaire de visibilité - jouer le son et arrêter le clignotement quand l'utilisateur revient
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// L'utilisateur est revenu sur l'onglet
stopTitleBlink();
// Si le timer s'est terminé pendant son absence, jouer le son
if (timerCompletedWhileHiddenRef.current) {
timerCompletedWhileHiddenRef.current = false;
playNotificationSound();
}
// Recalculer le temps restant si le timer est en cours
if (isRunning && endTimeRef.current !== null) {
const remaining = Math.round((endTimeRef.current - Date.now()) / 1000);
if (remaining <= 0) {
setTimeLeft(0);
handleTimerComplete();
} else {
setTimeLeft(remaining);
}
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [isRunning, handleTimerComplete, playNotificationSound, stopTitleBlink]);
// Mise à jour du titre de l'onglet
useEffect(() => {
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
const modeLabels = { focus: 'Focus', shortBreak: 'Pause', longBreak: 'Pause longue' };
const emoji = mode === 'focus' ? '🍅' : '☕';
document.title = `${emoji} ${timeString} - ${modeLabels[mode]} | FocusPomodoro`;
}, [timeLeft, mode]);
// Raccourcis clavier
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
switch (e.code) {
case 'Space':
e.preventDefault();
if (!isRunning) {
// Démarrage
endTimeRef.current = Date.now() + timeLeft * 1000;
} else {
// Pause
endTimeRef.current = null;
}
setIsRunning(prev => !prev);
break;
case 'KeyR':
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
const duration = getDuration(mode);
setTimeLeft(duration);
totalTime.current = duration;
setIsRunning(false);
endTimeRef.current = null;
stopTitleBlink();
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [mode, getDuration, isRunning, timeLeft, stopTitleBlink]);
const changeMode = useCallback((newMode: TimerMode) => {
setMode(newMode);
const duration = getDuration(newMode);
setTimeLeft(duration);
totalTime.current = duration;
setIsRunning(false);
endTimeRef.current = null;
stopTitleBlink();
}, [getDuration, stopTitleBlink]);
const toggleTimer = useCallback(() => {
if (!isRunning) {
requestNotificationPermission();
// Démarrage : définir l'heure de fin
endTimeRef.current = Date.now() + timeLeft * 1000;
} else {
// Pause : effacer l'heure de fin
endTimeRef.current = null;
}
setIsRunning(prev => !prev);
}, [isRunning, requestNotificationPermission, timeLeft]);
const resetTimer = useCallback(() => {
const duration = getDuration(mode);
setTimeLeft(duration);
totalTime.current = duration;
setIsRunning(false);
endTimeRef.current = null;
stopTitleBlink();
}, [mode, getDuration, stopTitleBlink]);
const updateSettings = useCallback((newSettings: Partial<TimerSettings>) => {
setSettings(prev => {
const updated = { ...prev, ...newSettings };
// Si on modifie la durée du mode actuel, on met à jour le timer
if (!isRunning) {
let newDuration = timeLeft;
if (mode === 'focus' && newSettings.focusDuration) {
newDuration = newSettings.focusDuration * 60;
} else if (mode === 'shortBreak' && newSettings.shortBreakDuration) {
newDuration = newSettings.shortBreakDuration * 60;
} else if (mode === 'longBreak' && newSettings.longBreakDuration) {
newDuration = newSettings.longBreakDuration * 60;
}
setTimeLeft(newDuration);
totalTime.current = newDuration;
}
return updated;
});
}, [setSettings, isRunning, mode, timeLeft]);
const skipSession = useCallback(() => {
handleTimerComplete();
}, [handleTimerComplete]);
return {
mode,
timeLeft,
isRunning,
progress,
sessionCount,
settings,
statistics,
changeMode,
toggleTimer,
resetTimer,
updateSettings,
skipSession,
};
}
+73
View File
@@ -0,0 +1,73 @@
import { useCallback } from 'react';
import { Todo, Priority } from '../types';
import { useLocalStorage } from './useLocalStorage';
export function useTodos() {
const [todos, setTodos] = useLocalStorage<Todo[]>('pomodoro-todos', []);
const addTodo = useCallback((text: string, priority: Priority = 'medium') => {
if (!text.trim()) return;
const newTodo: Todo = {
id: Date.now().toString(),
text: text.trim(),
completed: false,
priority,
pomodorosSpent: 0,
createdAt: Date.now(),
};
setTodos(prev => [newTodo, ...prev]);
}, [setTodos]);
const toggleTodo = useCallback((id: string) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}, [setTodos]);
const deleteTodo = useCallback((id: string) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, [setTodos]);
const updateTodo = useCallback((id: string, updates: Partial<Todo>) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, ...updates } : todo
));
}, [setTodos]);
const incrementPomodoro = useCallback((id: string) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, pomodorosSpent: todo.pomodorosSpent + 1 } : todo
));
}, [setTodos]);
const clearCompleted = useCallback(() => {
setTodos(prev => prev.filter(todo => !todo.completed));
}, [setTodos]);
const reorderTodos = useCallback((fromIndex: number, toIndex: number) => {
setTodos(prev => {
const result = [...prev];
const [removed] = result.splice(fromIndex, 1);
result.splice(toIndex, 0, removed);
return result;
});
}, [setTodos]);
const completedCount = todos.filter(t => t.completed).length;
const activeCount = todos.filter(t => !t.completed).length;
return {
todos,
addTodo,
toggleTodo,
deleteTodo,
updateTodo,
incrementPomodoro,
clearCompleted,
reorderTodos,
completedCount,
activeCount,
};
}
+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
/* Smooth transitions for theme switching */
html {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
+46
View File
@@ -0,0 +1,46 @@
export type TimerMode = 'focus' | 'shortBreak' | 'longBreak';
export type Priority = 'low' | 'medium' | 'high';
export interface Todo {
id: string;
text: string;
completed: boolean;
priority: Priority;
pomodorosSpent: number;
createdAt: number;
}
export interface TimerSettings {
focusDuration: number;
shortBreakDuration: number;
longBreakDuration: number;
autoStartBreaks: boolean;
autoStartPomodoros: boolean;
dailyGoal: number;
}
export interface Statistics {
completedToday: number;
completedTotal: number;
currentStreak: number;
bestStreak: number;
lastCompletedDate: string | null;
}
export const DEFAULT_SETTINGS: TimerSettings = {
focusDuration: 25,
shortBreakDuration: 5,
longBreakDuration: 15,
autoStartBreaks: false,
autoStartPomodoros: false,
dailyGoal: 8,
};
export const DEFAULT_STATISTICS: Statistics = {
completedToday: 0,
completedTotal: 0,
currentStreak: 0,
bestStreak: 0,
lastCompletedDate: null,
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+17
View File
@@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [],
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import path from "path";
import { fileURLToPath } from "url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), viteSingleFile()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});