This commit is contained in:
Julien Froidefond
2025-12-19 09:59:22 +01:00
commit 165928d64e
24 changed files with 5099 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
---
globs: app/api/**/*.ts
---
# API Routes Rules
1. Routes MUST only use services for data access
2. Routes MUST handle input validation
3. Routes MUST return typed responses
4. Routes MUST use proper error handling
Example of correct API route:
```typescript
import { MyService } from "@/services/my-service";
export async function GET(request: Request) {
try {
const service = new MyService(pool);
const data = await service.getData();
return Response.json(data);
} catch (error) {
return Response.error();
}
}
```
❌ FORBIDDEN:
- Direct database queries
- Business logic implementation
- Untyped responses

View File

@@ -0,0 +1,167 @@
---
alwaysApply: true
description: Enforce business logic separation between frontend and backend
---
# Business Logic Separation Rules
## Core Principle: NO Business Logic in Frontend
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
### Components
- UI rendering and presentation logic
- Form validation (UI-level only, not business rules)
- User interaction handling (clicks, inputs, navigation)
- Visual state management (loading, errors, UI states)
- Data formatting for display purposes only
### Hooks
- React state management
- API call orchestration (using clients)
- UI-specific logic (modals, forms, animations)
- Data fetching and caching coordination
### Clients
- HTTP requests to API routes
- Request/response transformation (serialization only)
- Error handling and retry logic
- Authentication token management
## ❌ FORBIDDEN in Frontend
### Business Rules
```typescript
// ❌ BAD: Business logic in component
const TaskCard = ({ task }) => {
const canEdit = task.status === 'open' && task.assignee === currentUser.id;
const priority = task.dueDate < new Date() ? 'high' : 'normal';
// This is business logic!
}
// ✅ GOOD: Get computed values from backend
const TaskCard = ({ task }) => {
const { canEdit, priority } = task; // Computed by backend service
}
```
### Data Processing
```typescript
// ❌ BAD: Data transformation in frontend
const processJiraTasks = (tasks) => {
return tasks.map(task => ({
...task,
normalizedStatus: mapJiraStatus(task.status),
estimatedHours: calculateEstimate(task.storyPoints)
}));
}
// ✅ GOOD: Data already processed by backend service
const { processedTasks } = await tasksClient.getTasks();
```
### Domain Logic
```typescript
// ❌ BAD: Domain calculations in frontend
const calculateTeamVelocity = (sprints) => {
// Complex business calculation
}
// ✅ GOOD: Domain logic in service
// This belongs in services/team-analytics.ts
```
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
### Services Layer
- All business rules and domain logic
- Data validation and processing
- Integration with external APIs (Jira, macOS Reminders)
- Complex calculations and algorithms
- Data aggregation and analytics
- Permission and authorization logic
### API Routes
- Input validation and sanitization
- Service orchestration
- Response formatting
- Error handling and logging
- Authentication and authorization
## Implementation Pattern
### ✅ Correct Flow
```
User Action → Component → Client → API Route → Service → Database
↑ ↓
Pure UI Logic Business Logic
```
### ❌ Incorrect Flow
```
User Action → Component with Business Logic → Database
```
## Examples
### Task Status Management
```typescript
// ❌ BAD: In component
const updateTaskStatus = (taskId, newStatus) => {
if (newStatus === 'done' && !task.hasAllSubtasks) {
throw new Error('Cannot complete task with pending subtasks');
}
// Business rule in frontend!
}
// ✅ GOOD: In services/task-processor.ts
export const updateTaskStatus = async (taskId: string, newStatus: TaskStatus) => {
const task = await getTask(taskId);
// Business rules in service
if (newStatus === 'done' && !await hasAllSubtasksCompleted(taskId)) {
throw new BusinessError('Cannot complete task with pending subtasks');
}
return await updateTask(taskId, { status: newStatus });
}
```
### Team Analytics
```typescript
// ❌ BAD: In component
const TeamDashboard = () => {
const calculateBurndown = (tasks) => {
// Complex business calculation in component
}
}
// ✅ GOOD: In services/team-analytics.ts
export const getTeamBurndown = async (teamId: string, sprintId: string) => {
// All calculation logic in service
const tasks = await getSprintTasks(sprintId);
return calculateBurndownMetrics(tasks);
}
```
## Enforcement
When reviewing code:
1. **Components**: Should only contain JSX, event handlers, and UI state
2. **Hooks**: Should only orchestrate API calls and manage React state
3. **Clients**: Should only make HTTP requests and handle responses
4. **Services**: Should contain ALL business logic and data processing
5. **API Routes**: Should validate input and call appropriate services
## Red Flags
Watch for these patterns that indicate business logic in frontend:
- Complex calculations in components/hooks
- Business rule validation in forms
- Data transformation beyond display formatting
- Domain-specific constants and rules
- Integration logic with external systems
Remember: **The frontend is a thin presentation layer. All intelligence lives in the backend.**

31
.cursor/rules/clients.mdc Normal file
View File

@@ -0,0 +1,31 @@
---
globs: clients/**/*.ts
---
# HTTP Clients Rules
1. All HTTP calls MUST be in clients/domains/
2. Each domain MUST have its own client
3. Clients MUST use the base HTTP client
4. Clients MUST type their responses
Example of correct client:
```typescript
import { HttpClient } from "@/clients/base/http-client";
import { MyData } from "@/lib/types";
export class MyClient {
constructor(private httpClient: HttpClient) {}
async getData(): Promise<MyData[]> {
return this.httpClient.get("/api/data");
}
}
```
❌ FORBIDDEN:
- Direct fetch calls
- Business logic in clients
- Untyped responses

View File

@@ -0,0 +1,28 @@
---
globs: src/components/**/*.tsx
---
# Components Rules
1. UI components MUST be in src/components/ui/
2. Feature components MUST be in their feature folder
3. Components MUST use clients for data fetching
4. Components MUST be properly typed
Example of correct component:
```typescript
import { useMyClient } from '@/hooks/use-my-client';
export const MyComponent = () => {
const { data } = useMyClient();
return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>;
};
```
❌ FORBIDDEN:
- Direct service usage
- Direct database queries
- Direct fetch calls
- Untyped props

View File

@@ -0,0 +1,167 @@
---
alwaysApply: true
description: CSS Variables theme system best practices
---
# CSS Variables Theme System
## Core Principle: Pure CSS Variables for Theming
This project uses **CSS Variables exclusively** for theming. No Tailwind `dark:` classes or conditional CSS classes.
## ✅ Architecture Pattern
### CSS Structure
```css
:root {
/* Light theme (default values) */
--background: #f1f5f9;
--foreground: #0f172a;
--primary: #0891b2;
--success: #059669;
--destructive: #dc2626;
--accent: #d97706;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #059669;
--blue: #2563eb;
--gray: #6b7280;
--gray-light: #e5e7eb;
}
.dark {
/* Dark theme (override values) */
--background: #1e293b;
--foreground: #f1f5f9;
--primary: #06b6d4;
--success: #10b981;
--destructive: #ef4444;
--accent: #f59e0b;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #10b981;
--blue: #3b82f6;
--gray: #9ca3af;
--gray-light: #374151;
}
```
### Theme Application
- **Single source of truth**: [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) applies theme class to `document.documentElement`
- **No duplication**: Theme is applied only once, not in multiple places
- **SSR safe**: Initial theme from server-side preferences
## ✅ Component Usage Patterns
### Correct: Using CSS Variables
```tsx
// ✅ GOOD: CSS Variables in className
<div className="bg-[var(--card)] text-[var(--foreground)] border-[var(--border)]">
// ✅ GOOD: CSS Variables in style prop
<div style={{ color: 'var(--primary)', backgroundColor: 'var(--card)' }}>
// ✅ GOOD: CSS Variables with color-mix for transparency
<div style={{
backgroundColor: 'color-mix(in srgb, var(--primary) 10%, transparent)',
borderColor: 'color-mix(in srgb, var(--primary) 20%, var(--border))'
}}>
```
### ❌ Forbidden: Tailwind Dark Mode Classes
```tsx
// ❌ BAD: Tailwind dark: classes
<div className="bg-white dark:bg-gray-800 text-black dark:text-white">
// ❌ BAD: Conditional classes
<div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
// ❌ BAD: Hardcoded colors
<div className="bg-red-500 text-blue-600">
```
## ✅ Color System
### Semantic Color Tokens
- `--background`: Main background color
- `--foreground`: Main text color
- `--card`: Card/panel background
- `--card-hover`: Card hover state
- `--card-column`: Column background (darker than cards)
- `--border`: Border color
- `--input`: Input field background
- `--primary`: Primary brand color
- `--primary-foreground`: Text on primary background
- `--muted`: Muted text color
- `--muted-foreground`: Secondary text color
- `--accent`: Accent color (orange/amber)
- `--destructive`: Error/danger color (red)
- `--success`: Success color (green)
- `--purple`: Purple accent
- `--yellow`: Yellow accent
- `--green`: Green accent
- `--blue`: Blue accent
- `--gray`: Gray color
- `--gray-light`: Light gray background
### Color Mixing Patterns
```css
/* Background with transparency */
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
/* Border with transparency */
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
/* Text with opacity */
color: color-mix(in srgb, var(--destructive) 80%, transparent);
```
## ✅ Theme Context Usage
### ThemeProvider Setup
```tsx
// In layout.tsx
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
{children}
</ThemeProvider>
```
### Component Usage
```tsx
import { useTheme } from '@/contexts/ThemeContext';
function MyComponent() {
const { theme, toggleTheme, setTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Switch to {theme === 'dark' ? 'light' : 'dark'} theme
</button>
);
}
```
## ✅ Future Extensibility
This system is designed to support:
- **Custom color themes**: Easy to add new color variables
- **User preferences**: Colors can be dynamically changed
- **Theme presets**: Multiple predefined themes
- **Accessibility**: High contrast modes
## 🚨 Anti-patterns to Avoid
1. **Don't mix approaches**: Never use both CSS variables and Tailwind dark: classes
2. **Don't duplicate theme application**: Theme should be applied only in ThemeContext
3. **Don't hardcode colors**: Always use semantic color tokens
4. **Don't use conditional classes**: Use CSS variables instead
5. **Don't forget transparency**: Use `color-mix()` for semi-transparent colors
## 📁 Key Files
- [globals.css](mdc:src/app/globals.css) - CSS Variables definitions
- [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) - Theme management
- [UserPreferencesContext.tsx](mdc:src/contexts/UserPreferencesContext.tsx) - Preferences sync
- [layout.tsx](mdc:src/app/layout.tsx) - Theme provider setup
Remember: **CSS Variables are the single source of truth for theming. Keep it pure and consistent.**

View File

@@ -0,0 +1,30 @@
---
alwaysApply: true
---
# Project Structure Rules
1. Backend:
- [src/services/](mdc:src/services/) - ALL database access
- [src/app/api/](mdc:src/app/api/) - API routes using services
2. Frontend:
- [src/clients/](mdc:src/clients/) - HTTP clients
- [src/components/](mdc:src/components/) - React components (organized by domain)
- [src/hooks/](mdc:src/hooks/) - React hooks
3. Shared:
- [src/lib/](mdc:src/lib/) - Types and utilities
- [scripts/](mdc:scripts/) - Utility scripts
Key Files:
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
❌ FORBIDDEN:
- Database access outside src/services/
- HTTP calls outside src/clients/
- Business logic in src/components/

View File

@@ -0,0 +1,113 @@
---
alwaysApply: true
description: Guide for when to use Server Actions vs API Routes in Next.js App Router
---
# Server Actions vs API Routes - Decision Guide
## ✅ USE SERVER ACTIONS for:
### Quick Actions & Mutations
- **TaskCard actions**: `updateTaskStatus()`, `updateTaskTitle()`, `deleteTask()`
- **Daily checkboxes**: `toggleCheckbox()`, `addCheckbox()`, `updateCheckbox()`
- **User preferences**: `updateTheme()`, `updateViewPreferences()`, `updateFilters()`
- **Simple CRUD**: `createTag()`, `updateTag()`, `deleteTag()`
### Characteristics of Server Action candidates:
- Simple, frequent mutations
- No complex business logic
- Used in interactive components (forms, buttons, toggles)
- Need immediate UI feedback with `useTransition`
- Benefit from automatic cache revalidation
## ❌ KEEP API ROUTES for:
### Complex Endpoints
- **Initial data fetching**: `GET /api/tasks` with complex filters
- **External integrations**: `POST /api/jira/sync` with complex logic
- **Analytics & reports**: Complex data aggregation
- **Public API**: Endpoints that might be called from mobile/external
### Characteristics that require API Routes:
- Complex business logic or data processing
- Multiple service orchestration
- Need for HTTP monitoring/logging
- External consumption (mobile apps, webhooks)
- Real-time features (WebSockets, SSE)
- File uploads or special content types
## 🔄 Implementation Pattern
### Server Actions Structure
```typescript
// actions/tasks.ts
'use server'
import { tasksService } from '@/services/tasks';
import { revalidatePath } from 'next/cache';
export async function updateTaskStatus(taskId: string, status: TaskStatus) {
try {
const task = await tasksService.updateTask(taskId, { status });
revalidatePath('/'); // Auto cache invalidation
return { success: true, data: task };
} catch (error) {
return { success: false, error: error.message };
}
}
```
### Component Usage with useTransition
```typescript
// components/TaskCard.tsx
'use client';
import { updateTaskStatus } from '@/actions/tasks';
import { useTransition } from 'react';
export function TaskCard({ task }) {
const [isPending, startTransition] = useTransition();
const handleStatusChange = (status) => {
startTransition(async () => {
const result = await updateTaskStatus(task.id, status);
if (!result.success) {
// Handle error
}
});
};
return (
<div className={isPending ? 'opacity-50' : ''}>
{/* UI with loading state */}
</div>
);
}
```
## 🏗️ Migration Strategy
When migrating from API Routes to Server Actions:
1. **Create server action** in `actions/` directory
2. **Update component** to use server action directly
3. **Remove API route** (PATCH, POST, DELETE for mutations)
4. **Simplify client** (remove mutation methods, keep GET only)
5. **Update hooks** to use server actions instead of HTTP calls
## 🎯 Benefits of Server Actions
- **🚀 Performance**: No HTTP serialization overhead
- **🔄 Cache intelligence**: Automatic revalidation with `revalidatePath()`
- **📦 Bundle reduction**: Less client-side HTTP code
- **⚡ UX**: Native loading states with `useTransition`
- **🎯 Simplicity**: Direct service calls, less boilerplate
## 🚨 Anti-patterns to Avoid
- Don't use server actions for complex data fetching
- Don't use server actions for endpoints that need HTTP monitoring
- Don't use server actions for public API endpoints
- Don't mix server actions with client-side state management for the same data
Remember: Server Actions are for **direct mutations**, API Routes are for **complex operations** and **public interfaces**.

View File

@@ -0,0 +1,42 @@
---
globs: src/services/*.ts
---
# Services Rules
1. Services MUST contain ALL PostgreSQL queries
2. Services are the ONLY layer allowed to communicate with the database
3. Each service MUST:
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
- Implement proper transaction management
- Handle errors and logging
- Validate data before insertion
- Have a clear interface
Example of correct service implementation:
```typescript
export class MyService {
constructor(private pool: Pool) {}
async myMethod(): Promise<Result> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
// ... queries
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}
```
❌ FORBIDDEN:
- Direct database queries outside src/services
- Raw SQL in API routes
- Database logic in components

View File

@@ -0,0 +1,54 @@
---
alwaysApply: true
description: Automatic TODO tracking and task completion management
---
# TODO Task Tracking Rules
## Automatic Task Completion
Whenever you complete a task or implement a feature mentioned in [TODO.md](mdc:TODO.md), you MUST:
1. **Immediately update the TODO.md** by changing `- [ ]` to `- [x]` for the completed task
2. **Use the exact task description** from the TODO - don't modify the text
3. **Update related sub-tasks** if completing a parent task affects them
4. **Add completion timestamp** in a comment if the task was significant
## Task Completion Examples
### ✅ Correct completion marking:
```markdown
- [x] Initialiser Next.js avec TypeScript
- [x] Configurer ESLint, Prettier
- [x] Setup structure de dossiers selon les règles du workspace
```
### ✅ With timestamp for major milestones:
```markdown
- [x] Créer `services/database.ts` - Pool de connexion DB <!-- Completed 2025-01-15 -->
```
## When to Update TODO.md
Update the TODO immediately after:
- Creating/modifying files mentioned in tasks
- Implementing features described in tasks
- Completing configuration steps
- Finishing any work item listed in the TODO
## Task Dependencies
When completing tasks, consider:
- **Parent tasks**: Mark parent complete only when ALL sub-tasks are done
- **Blocking tasks**: Some tasks may unblock others - mention this in updates
- **Phase completion**: Note when entire phases are completed
## Progress Tracking
Always maintain visibility of:
- Current phase progress
- Next logical task to tackle
- Any blockers or issues encountered
- Completed vs remaining work ratio
This ensures the TODO.md remains an accurate reflection of project progress and helps maintain momentum.

4
.eslintrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "next/core-web-vitals"
}

66
.gitignore vendored Normal file
View File

@@ -0,0 +1,66 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# pnpm
.pnpm-store/
.pnpm-store
node_modules/.pnpm
.pnpm-debug.log*
# Note: pnpm-lock.yaml should be committed
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
Thumbs.db
.DS_Store
# logs
logs
*.log
# temporary files
*.tmp
*.temp
.pnpm-store

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
shamefully-hoist=true

31
README.md Normal file
View File

@@ -0,0 +1,31 @@
# People Randomizr
Application Next.js pour extraire aléatoirement un certain nombre de personnes à partir d'un fichier CSV.
## Installation
```bash
npm install
```
## Développement
```bash
npm run dev
```
Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur.
## Fonctionnalités
- Affichage de toutes les personnes du CSV
- Extraction aléatoire d'un nombre configurable de personnes
- Affichage des résultats avec nom, description et type
## Structure
- `app/page.tsx` - Page principale avec l'interface utilisateur
- `app/api/people/route.ts` - API route pour charger les données du CSV
- `lib/csv-parser.ts` - Parser pour le fichier CSV
- `group-dev-ad.csv` - Fichier source des données

18
app/api/people/route.ts Normal file
View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { parseCSV } from '@/lib/csv-parser';
import { join } from 'path';
export async function GET() {
try {
const filePath = join(process.cwd(), 'group-dev-ad.csv');
const people = await parseCSV(filePath);
return NextResponse.json(people);
} catch (error) {
console.error('Error parsing CSV:', error);
return NextResponse.json(
{ error: 'Failed to parse CSV file' },
{ status: 500 }
);
}
}

83
app/globals.css Normal file
View File

@@ -0,0 +1,83 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900;
background-attachment: fixed;
}
}
@layer utilities {
.glass {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-strong {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.glow-cyan {
box-shadow: 0 0 20px rgba(6, 182, 212, 0.5), 0 0 40px rgba(6, 182, 212, 0.3);
}
.glow-purple {
box-shadow: 0 0 20px rgba(168, 85, 247, 0.5), 0 0 40px rgba(168, 85, 247, 0.3);
}
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 via-purple-400 to-pink-400;
}
.border-gradient {
border-image: linear-gradient(135deg, rgba(6, 182, 212, 0.5), rgba(168, 85, 247, 0.5)) 1;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(6, 182, 212, 0.5), rgba(168, 85, 247, 0.5));
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(6, 182, 212, 0.7), rgba(168, 85, 247, 0.7));
}
@keyframes blob {
0%, 100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -50px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
}

20
app/layout.tsx Normal file
View File

@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "People Randomizr",
description: "Extract random people from your team",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body>{children}</body>
</html>
);
}

333
app/page.tsx Normal file
View File

@@ -0,0 +1,333 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { parseCSV, Person } from '@/lib/csv-parser';
export default function Home() {
const [people, setPeople] = useState<Person[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCount, setSelectedCount] = useState<number>(5);
const [selectedPostes, setSelectedPostes] = useState<Set<string>>(new Set());
const [randomPeople, setRandomPeople] = useState<Person[]>([]);
const [showResults, setShowResults] = useState(false);
const [searchPoste, setSearchPoste] = useState<string>('');
useEffect(() => {
async function loadData() {
try {
const response = await fetch('/api/people');
const data = await response.json();
setPeople(data);
} catch (error) {
console.error('Error loading data:', error);
} finally {
setLoading(false);
}
}
loadData();
}, []);
// Compter le nombre de personnes par poste
const posteCounts = useMemo(() => {
const counts: Record<string, number> = {};
people.forEach(person => {
if (person.description) {
counts[person.description] = (counts[person.description] || 0) + 1;
}
});
return counts;
}, [people]);
// Extraire tous les postes uniques triés par nombre décroissant
const allPostes = useMemo(() => {
const postes = new Set<string>();
people.forEach(person => {
if (person.description) {
postes.add(person.description);
}
});
return Array.from(postes).sort((a, b) => {
const countA = posteCounts[a] || 0;
const countB = posteCounts[b] || 0;
// Tri décroissant par nombre, puis alphabétique si égal
if (countA !== countB) {
return countB - countA;
}
return a.localeCompare(b);
});
}, [people, posteCounts]);
// Filtrer les postes selon la recherche
const filteredPostes = useMemo(() => {
if (!searchPoste.trim()) {
return allPostes;
}
const searchLower = searchPoste.toLowerCase();
return allPostes.filter(poste =>
poste.toLowerCase().includes(searchLower)
);
}, [allPostes, searchPoste]);
// Filtrer les personnes selon les postes sélectionnés
const filteredPeople = useMemo(() => {
if (selectedPostes.size === 0) {
return people;
}
return people.filter(person =>
person.description && selectedPostes.has(person.description)
);
}, [people, selectedPostes]);
const handlePosteToggle = (poste: string) => {
const newSelected = new Set(selectedPostes);
if (newSelected.has(poste)) {
newSelected.delete(poste);
} else {
newSelected.add(poste);
}
setSelectedPostes(newSelected);
setShowResults(false);
};
const handleSelectAll = () => {
if (selectedPostes.size === allPostes.length) {
setSelectedPostes(new Set());
} else {
setSelectedPostes(new Set(allPostes));
}
setShowResults(false);
};
const handleRandomize = () => {
if (selectedCount <= 0 || selectedCount > filteredPeople.length) {
alert(`Veuillez entrer un nombre entre 1 et ${filteredPeople.length}`);
return;
}
const shuffled = [...filteredPeople].sort(() => Math.random() - 0.5);
const selected = shuffled.slice(0, selectedCount);
setRandomPeople(selected);
setShowResults(true);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="relative">
<div className="w-16 h-16 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin"></div>
<div className="absolute inset-0 w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
</div>
</div>
);
}
return (
<div className="min-h-screen py-8 px-4 relative overflow-hidden">
{/* Effets de fond animés */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
</div>
<div className="max-w-6xl mx-auto relative z-10">
<div className="text-center mb-12">
<h1 className="text-6xl font-black mb-4 text-gradient tracking-tight">
People Randomizr
</h1>
<p className="text-cyan-300 text-lg font-light">Sélection intelligente Extraction aléatoire</p>
</div>
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-cyan-500/20">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
Filtres par poste
</h2>
<div className="flex items-center gap-4">
<div className="px-4 py-2 glass rounded-lg border border-cyan-500/30">
<span className="text-sm text-cyan-300">
<span className="font-bold text-cyan-400 text-lg">{selectedPostes.size}</span>
<span className="text-purple-300"> / </span>
<span className="text-white">{allPostes.length}</span>
<span className="text-gray-400 ml-2">sélectionné{selectedPostes.size > 1 ? 's' : ''}</span>
</span>
</div>
<button
onClick={handleSelectAll}
className="px-5 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-cyan-600 to-purple-600 rounded-lg hover:from-cyan-500 hover:to-purple-500 transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 border border-cyan-400/30"
>
{selectedPostes.size === allPostes.length ? 'Tout désélectionner' : 'Tout sélectionner'}
</button>
</div>
</div>
{/* Barre de recherche */}
<div className="mb-6">
<div className="relative">
<input
type="text"
placeholder="🔍 Rechercher un poste..."
value={searchPoste}
onChange={(e) => setSearchPoste(e.target.value)}
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white placeholder-gray-400 border border-cyan-500/20 transition-all duration-300"
/>
{searchPoste && (
<button
onClick={() => setSearchPoste('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
>
</button>
)}
</div>
</div>
{/* Badges de filtres */}
<div className="max-h-64 overflow-y-auto p-3 mb-6 custom-scrollbar">
<div className="flex flex-wrap gap-3">
{filteredPostes.length === 0 ? (
<div className="w-full text-center py-8">
<p className="text-gray-400 text-sm">Aucun poste trouvé</p>
</div>
) : (
filteredPostes.map((poste) => {
const isSelected = selectedPostes.has(poste);
const count = posteCounts[poste] || 0;
return (
<button
key={poste}
onClick={() => handlePosteToggle(poste)}
className={`
px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-300
flex items-center gap-2 border-2 relative overflow-hidden group
${isSelected
? 'bg-gradient-to-r from-cyan-600 to-purple-600 text-white border-cyan-400 shadow-lg glow-cyan transform scale-105'
: 'glass text-gray-300 border-cyan-500/20 hover:border-cyan-400/50 hover:bg-cyan-500/10 hover:text-white'
}
`}
>
{isSelected && (
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 animate-pulse"></div>
)}
<span className="relative z-10">{poste}</span>
<span className={`
px-2.5 py-1 rounded-lg text-xs font-bold relative z-10
${isSelected
? 'bg-white/20 text-white backdrop-blur-sm'
: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
}
`}>
{count}
</span>
</button>
);
})
)}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-6 items-end">
<div className="flex-1">
<label htmlFor="count" className="block text-sm font-semibold text-cyan-300 mb-3">
Nombre de personnes à extraire
</label>
<input
id="count"
type="number"
min="1"
max={filteredPeople.length}
value={selectedCount}
onChange={(e) => setSelectedCount(parseInt(e.target.value) || 1)}
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white bg-white/5 border border-cyan-500/20 transition-all duration-300"
/>
<p className="text-sm text-gray-400 mt-2 font-medium">
{selectedPostes.size === 0 ? (
<>📊 Total disponible: <span className="text-cyan-400 font-bold">{people.length}</span> personnes</>
) : (
<>🎯 Total filtré: <span className="text-cyan-400 font-bold">{filteredPeople.length}</span> personne{filteredPeople.length > 1 ? 's' : ''} <span className="text-gray-500">(sur {people.length})</span></>
)}
</p>
</div>
<button
onClick={handleRandomize}
disabled={filteredPeople.length === 0}
className="px-8 py-4 bg-gradient-to-r from-cyan-600 via-purple-600 to-pink-600 text-white font-bold rounded-xl hover:from-cyan-500 hover:via-purple-500 hover:to-pink-500 focus:outline-none transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-cyan-600 disabled:hover:via-purple-600 disabled:hover:to-pink-600 border border-cyan-400/30 relative overflow-hidden group"
>
<span className="relative z-10 flex items-center gap-2">
<span>🎲</span>
<span>Extraire aléatoirement</span>
</span>
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</button>
</div>
</div>
{showResults && randomPeople.length > 0 && (
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-purple-500/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-1 h-8 bg-gradient-to-b from-cyan-400 to-purple-400 rounded-full"></div>
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
Résultats sélectionnés
</h2>
<span className="px-3 py-1 glass rounded-lg text-cyan-300 font-semibold border border-cyan-500/30">
{randomPeople.length} personne{randomPeople.length > 1 ? 's' : ''}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{randomPeople.map((person, index) => (
<div
key={`${person.nom}-${index}`}
className="glass rounded-xl p-5 hover:bg-white/10 border border-cyan-500/20 hover:border-cyan-400/50 transition-all duration-300 hover:shadow-lg hover:shadow-cyan-500/20 group"
style={{ animationDelay: `${index * 50}ms` }}
>
<h3 className="font-bold text-lg text-white mb-2 group-hover:text-cyan-300 transition-colors">
{person.nom}
</h3>
{person.description && (
<p className="text-sm text-gray-300 mb-3">{person.description}</p>
)}
<span className="inline-block px-3 py-1 text-xs rounded-lg bg-gradient-to-r from-cyan-600/30 to-purple-600/30 text-cyan-300 border border-cyan-500/30 font-semibold">
{person.type}
</span>
</div>
))}
</div>
</div>
)}
<div className="glass-strong rounded-2xl p-8 shadow-2xl border border-purple-500/20">
<div className="flex items-center gap-3 mb-6">
<div className="w-1 h-8 bg-gradient-to-b from-purple-400 to-pink-400 rounded-full"></div>
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">
{selectedPostes.size === 0
? `Toutes les personnes`
: `Personnes filtrées`
}
</h2>
<span className="px-3 py-1 glass rounded-lg text-purple-300 font-semibold border border-purple-500/30">
{filteredPeople.length} / {people.length}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-96 overflow-y-auto custom-scrollbar p-2">
{filteredPeople.map((person, index) => (
<div
key={`${person.nom}-${index}`}
className={`glass rounded-xl p-4 hover:bg-white/10 border transition-all duration-300 hover:shadow-lg ${
selectedPostes.size > 0 && selectedPostes.has(person.description)
? 'border-cyan-400/50 bg-cyan-500/10 hover:border-cyan-400 hover:shadow-cyan-500/20'
: 'border-purple-500/20 hover:border-purple-400/50'
}`}
>
<h3 className="font-semibold text-white mb-1">{person.nom}</h3>
{person.description && (
<p className="text-sm text-gray-400 mt-1">{person.description}</p>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}

73
lib/csv-parser.ts Normal file
View File

@@ -0,0 +1,73 @@
export interface Person {
nom: string;
description: string;
type: string;
}
export async function parseCSV(filePath: string): Promise<Person[]> {
const fs = await import('fs');
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
// Skip header line
const dataLines = lines.slice(1);
const people: Person[] = [];
for (const line of dataLines) {
if (!line.trim()) continue;
// Parse CSV line (handling quoted fields)
const fields = parseCSVLine(line);
if (fields.length >= 7) {
const nom = fields[4]?.trim() || '';
const description = fields[5]?.trim() || '';
const type = fields[6]?.trim() || '';
// Skip empty names and service accounts
if (nom && !nom.startsWith('svc.') && !nom.startsWith('!') && nom !== 'datascience') {
people.push({
nom,
description,
type,
});
}
}
}
return people;
}
function parseCSVLine(line: string): string[] {
const fields: string[] = [];
let currentField = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
// Escaped quote
currentField += '"';
i++;
} else {
// Toggle quote state
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// Field separator
fields.push(currentField);
currentField = '';
} else {
currentField += char;
}
}
// Add last field
fields.push(currentField);
return fields.map(field => field.trim());
}

5
next.config.js Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "people-randomizr",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "^14"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"eslint": "^8",
"eslint-config-next": "^14"
}
}

3724
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

15
tailwind.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}