Merge pull request #167 from R0m1k3/antigravity

feat: Add recurring tasks management (frontend, backend, automation)
This commit is contained in:
LogiFlow authored and GitHub committed 2026-01-06 21:14:48 +01:00
commit 3f297f453a
822 files changed
+133208 -787

No files matched your search

@@ -0,0 +1,273 @@
import { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Calendar, RefreshCw, Trash2, Edit, Play } from 'lucide-react';
import { toast } from 'sonner';
import RecurringTodosService, { RecurringTodo } from '@/services/RecurringTodosService';
import { Badge } from "@/components/ui/badge";
export const RecurringTodosAdmin = () => {
const [recurringTodos, setRecurringTodos] = useState<RecurringTodo[]>([]);
const [loading, setLoading] = useState(false);
const [openModal, setOpenModal] = useState(false);
const [editingTodo, setEditingTodo] = useState<RecurringTodo | null>(null);
// Form state
const [text, setText] = useState('');
const [recurrenceType, setRecurrenceType] = useState<RecurringTodo['recurrence_type']>('weekly');
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
const [dayOfWeek, setDayOfWeek] = useState(1); // 1 = Monday
const [dayOfMonth, setDayOfMonth] = useState(1);
const [priority, setPriority] = useState(false);
const [nextOccurrence, setNextOccurrence] = useState(new Date().toISOString().split('T')[0]);
useEffect(() => {
loadRecurringTodos();
}, []);
const loadRecurringTodos = async () => {
try {
setLoading(true);
const data = await RecurringTodosService.getAll();
setRecurringTodos(data);
} catch (error) {
toast.error("Erreur lors du chargement des tâches récurrentes");
} finally {
setLoading(false);
}
};
const handleOpenModal = (todo: RecurringTodo | null = null) => {
if (todo) {
setEditingTodo(todo);
setText(todo.text);
setRecurrenceType(todo.recurrence_type);
setRecurrenceInterval(todo.recurrence_interval);
setDayOfWeek(todo.day_of_week || 1);
setDayOfMonth(todo.day_of_month || 1);
setPriority(todo.priority);
setNextOccurrence(new Date(todo.next_occurrence).toISOString().split('T')[0]);
} else {
setEditingTodo(null);
setText('');
setRecurrenceType('weekly');
setRecurrenceInterval(1);
setDayOfWeek(1); // Monday
setDayOfMonth(1);
setPriority(false);
setNextOccurrence(new Date().toISOString().split('T')[0]);
}
setOpenModal(true);
};
const calculateNextDate = () => {
// Basic logic to guess next date based on type if user changes type
// Not strictly implementing full logic here, just default to today or let user pick
};
const handleSubmit = async () => {
try {
const payload: any = {
text,
recurrence_type: recurrenceType,
recurrence_interval: recurrenceInterval,
priority,
next_occurrence: nextOccurrence
};
if (recurrenceType === 'weekly' || recurrenceType === 'biweekly') {
payload.day_of_week = dayOfWeek;
}
if (recurrenceType === 'monthly' || recurrenceType === 'yearly') {
payload.day_of_month = dayOfMonth;
}
if (editingTodo) {
await RecurringTodosService.update(editingTodo.id!, payload);
toast.success("Tâche récurrente modifiée");
} else {
await RecurringTodosService.create(payload);
toast.success("Tâche récurrente créée");
}
setOpenModal(false);
loadRecurringTodos();
} catch (error) {
toast.error("Erreur lors de l'enregistrement");
}
};
const handleDelete = async (id: number) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer cette tâche récurrente ?")) return;
try {
await RecurringTodosService.delete(id);
toast.success("Tâche supprimée");
loadRecurringTodos();
} catch (error) {
toast.error("Erreur lors de la suppression");
}
};
const handleGenerate = async () => {
try {
const result = await RecurringTodosService.generateNow();
toast.success(`${result.generated} tâche(s) générée(s)`);
if (result.generated > 0) {
// Also refresh recurring list to see updated next_occurrences
loadRecurringTodos();
}
} catch (error) {
toast.error("Erreur lors de la génération");
}
};
const formatRecurrence = (todo: RecurringTodo) => {
const intervalStr = todo.recurrence_interval > 1 ? `Tous les ${todo.recurrence_interval} ` : 'Chaque ';
switch (todo.recurrence_type) {
case 'daily': return todo.recurrence_interval > 1 ? `Tous les ${todo.recurrence_interval} jours` : 'Quotidien';
case 'weekly': return `${todo.recurrence_interval > 1 ? intervalStr + 'semaines' : 'Hebdomadaire'} (J${todo.day_of_week})`;
case 'biweekly': return 'Bi-mensuel (1 semaine sur 2)';
case 'monthly': return `${todo.recurrence_interval > 1 ? intervalStr + 'mois' : 'Mensuel'} (le ${todo.day_of_month})`;
case 'yearly': return `${todo.recurrence_interval > 1 ? intervalStr + 'ans' : 'Annuel'} (le ${todo.day_of_month})`;
default: return todo.recurrence_type;
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Button className="flex items-center gap-2" onClick={() => handleOpenModal()}>
<Plus className="h-4 w-4" />
Nouvelle tâche récurrente
</Button>
<Button variant="outline" className="flex items-center gap-2" onClick={handleGenerate}>
<Play className="h-4 w-4" />
Générer maintenant (Test)
</Button>
</div>
<div className="space-y-2">
{recurringTodos.map(todo => (
<Card key={todo.id}>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{todo.text}</span>
{todo.priority && <Badge variant="destructive" className="text-xs px-1 py-0 h-5">Prioritaire</Badge>}
{!todo.enabled && <Badge variant="secondary" className="text-xs px-1 py-0 h-5">Désactivé</Badge>}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<RefreshCw className="h-3 w-3" />
{formatRecurrence(todo)}
</div>
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
Prochaine: {new Date(todo.next_occurrence).toLocaleDateString()}
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => handleOpenModal(todo)}>
<Edit className="h-4 w-4 mr-2" />
Modifier
</Button>
<Button variant="destructive" size="sm" onClick={() => handleDelete(todo.id!)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
{recurringTodos.length === 0 && !loading && (
<p className="text-center text-muted-foreground py-8">Aucune tâche récurrente configurée</p>
)}
</div>
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{editingTodo ? 'Modifier la tâche récurrente' : 'Nouvelle tâche récurrente'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="text" className="text-right">Tâche</Label>
<Input id="text" value={text} onChange={(e) => setText(e.target.value)} className="col-span-3" placeholder="Ex: Payer loyer" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Priorité</Label>
<div className="flex items-center space-x-2 col-span-3">
<Checkbox id="priority" checked={priority} onCheckedChange={(c) => setPriority(!!c)} />
<label htmlFor="priority" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Haute priorité (sera marqué !)
</label>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">Fréquence</Label>
<Select value={recurrenceType} onValueChange={(v: any) => setRecurrenceType(v)}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Choisir..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Quotidien</SelectItem>
<SelectItem value="weekly">Hebdomadaire</SelectItem>
<SelectItem value="biweekly">Bi-mensuel (2 sem)</SelectItem>
<SelectItem value="monthly">Mensuel</SelectItem>
<SelectItem value="yearly">Annuel</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="interval" className="text-right">Intervalle</Label>
<Input id="interval" type="number" min="1" value={recurrenceInterval} onChange={(e) => setRecurrenceInterval(parseInt(e.target.value))} className="col-span-3" />
</div>
{(recurrenceType === 'weekly' || recurrenceType === 'biweekly') && (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="dayOfWeek" className="text-right">Jour Sem.</Label>
<Select value={dayOfWeek.toString()} onValueChange={(v) => setDayOfWeek(parseInt(v))}>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Lundi</SelectItem>
<SelectItem value="2">Mardi</SelectItem>
<SelectItem value="3">Mercredi</SelectItem>
<SelectItem value="4">Jeudi</SelectItem>
<SelectItem value="5">Vendredi</SelectItem>
<SelectItem value="6">Samedi</SelectItem>
<SelectItem value="0">Dimanche</SelectItem>
</SelectContent>
</Select>
</div>
)}
{(recurrenceType === 'monthly' || recurrenceType === 'yearly') && (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="dayOfMonth" className="text-right">Jour Mois</Label>
<Input id="dayOfMonth" type="number" min="1" max="31" value={dayOfMonth} onChange={(e) => setDayOfMonth(parseInt(e.target.value))} className="col-span-3" />
</div>
)}
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="nextDate" className="text-right">Prochaine</Label>
<Input id="nextDate" type="date" value={nextOccurrence} onChange={(e) => setNextOccurrence(e.target.value)} className="col-span-3" />
</div>
</div>
<DialogFooter>
<Button onClick={handleSubmit}>Enregistrer</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
+35
View File
@@ -0,0 +1,35 @@
const recurringTodosController = require('../../controllers/recurringTodosController');
const logger = require('../../config/logger');
function startScheduler() {
logger.info('Démarrage du planificateur de tâches...');
// Vérification toutes les heures
setInterval(async () => {
try {
logger.debug('Vérification périodique des tâches récurrentes...');
const result = await recurringTodosController.generateTodos(); // All users
if (result.generated > 0) {
logger.info(`Tâches récurrentes auto-générées: ${result.generated}`);
}
} catch (error) {
logger.error('Erreur dans le planificateur:', error);
}
}, 60 * 60 * 1000); // 1 heure
// Allow start check after a slight delay to ensure DB is ready
setTimeout(async () => {
try {
const result = await recurringTodosController.generateTodos();
if (result.generated > 0) {
logger.info(`Tâches récurrentes auto-générées (démarrage): ${result.generated}`);
} else {
logger.info('Aucune tâche récurrente à générer au démarrage.');
}
} catch (error) {
logger.error('Erreur dans le planificateur (démarrage):', error);
}
}, 5000); // 5 sec delay
}
module.exports = { startScheduler };
+7 -1
View File
@@ -45,6 +45,7 @@ import { StatsDashboard } from "@/components/StatsDashboard";
import { StatsModal } from "@/components/StatsModal";
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp";
import { useKeyboardShortcuts, type KeyboardShortcut } from "@/hooks/useKeyboardShortcuts";
import { RecurringTodosAdmin } from "@/components/admin/RecurringTodosAdmin";
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
@@ -2232,11 +2233,12 @@ const Index = () => {
</DialogHeader>
<Tabs value={adminTab} onValueChange={setAdminTab}>
<TabsList className="grid grid-cols-5 w-full">
<TabsList className="grid grid-cols-6 w-full">
<TabsTrigger value="users">Utilisateurs</TabsTrigger>
<TabsTrigger value="rss">Flux RSS</TabsTrigger>
<TabsTrigger value="calendar">Google Calendar</TabsTrigger>
<TabsTrigger value="openrouter">OpenRouter</TabsTrigger>
<TabsTrigger value="recurring">Récurrentes</TabsTrigger>
<TabsTrigger value="settings">Paramètres</TabsTrigger>
</TabsList>
@@ -2506,6 +2508,10 @@ const Index = () => {
</Card>
</TabsContent>
<TabsContent value="recurring" className="mt-4">
<RecurringTodosAdmin />
</TabsContent>
<TabsContent value="settings" className="mt-4">
<Card>
<CardHeader>
+81
View File
@@ -0,0 +1,81 @@
import axios from 'axios';
export interface RecurringTodo {
id?: number;
text: string;
recurrence_type: 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly';
recurrence_interval: number;
day_of_week?: number;
day_of_month?: number;
priority: boolean;
next_occurrence: string;
last_generated?: string;
enabled: boolean;
created_at?: string;
}
class RecurringTodosService {
private baseURL = '/api/recurring-todos';
private getAuthHeader() {
const token = localStorage.getItem('token');
return {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
}
async getAll(): Promise<RecurringTodo[]> {
try {
const response = await axios.get(this.baseURL, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error fetching recurring todos:', error);
throw error;
}
}
async create(data: Partial<RecurringTodo>): Promise<RecurringTodo> {
try {
const response = await axios.post(this.baseURL, data, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error creating recurring todo:', error);
throw error;
}
}
async update(id: number, data: Partial<RecurringTodo>): Promise<RecurringTodo> {
try {
const response = await axios.put(`${this.baseURL}/${id}`, data, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error updating recurring todo:', error);
throw error;
}
}
async delete(id: number): Promise<boolean> {
try {
await axios.delete(`${this.baseURL}/${id}`, this.getAuthHeader());
return true;
} catch (error) {
console.error('Error deleting recurring todo:', error);
throw error;
}
}
async generateNow(): Promise<{ generated: number }> {
try {
const response = await axios.post(`${this.baseURL}/generate`, {}, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error generating recurring todos:', error);
throw error;
}
}
}
export default new RecurringTodosService();
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
// Mock toast utils
vi.mock('@/utils/toast', () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}))
// We need to import AuthService after mocking localStorage
import AuthService from '../AuthService'
describe('AuthService', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.getItem.mockReturnValue(null)
})
describe('isAuthenticated', () => {
it('should return false when no token is stored', () => {
expect(AuthService.isAuthenticated()).toBe(false)
})
})
describe('getUser', () => {
it('should return null when no user is stored', () => {
expect(AuthService.getUser()).toBe(null)
})
})
describe('getToken', () => {
it('should return null/undefined when no token is stored', () => {
expect(AuthService.getToken()).toBeFalsy()
})
})
describe('getHeaders', () => {
it('should return headers with Authorization and Content-Type', () => {
const headers = AuthService.getHeaders()
expect(headers).toHaveProperty('Authorization')
expect(headers).toHaveProperty('Content-Type', 'application/json')
})
it('should include Bearer token in Authorization header', () => {
const headers = AuthService.getHeaders()
expect(headers.Authorization).toMatch(/^Bearer /)
})
})
describe('login', () => {
it('should return false on failed login', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ message: 'Invalid credentials' }),
})
const result = await AuthService.login('wrong', 'password')
expect(result).toBe(false)
})
it('should return true and store token on successful login', async () => {
const mockUser = { id: 1, username: 'admin', is_admin: true }
const mockToken = 'test-jwt-token'
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: mockToken, user: mockUser }),
})
const result = await AuthService.login('admin', 'password')
expect(result).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith('token', mockToken)
expect(localStorageMock.setItem).toHaveBeenCalledWith('user', JSON.stringify(mockUser))
})
})
describe('logout', () => {
it('should remove token and user from localStorage', () => {
AuthService.logout()
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
expect(localStorageMock.removeItem).toHaveBeenCalledWith('user')
})
})
describe('checkTokenValidity', () => {
it('should return false when response is not ok', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false })
const result = await AuthService.checkTokenValidity()
expect(result).toBe(false)
})
})
})
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock AuthService
vi.mock('../AuthService', () => ({
default: {
getHeaders: vi.fn().mockReturnValue({
Authorization: 'Bearer test-token',
'Content-Type': 'application/json',
}),
},
}))
// Mock toast utils
vi.mock('@/utils/toast', () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}))
import NotesService from '../NotesService'
describe('NotesService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getNotes', () => {
it('should fetch notes and return them with default arrays', async () => {
const mockNotes = [
{ id: 1, title: 'Note 1', content: 'Content 1' },
{ id: 2, title: 'Note 2', content: 'Content 2', todos: [{ id: 1, text: 'Task', completed: false }] },
]
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockNotes),
})
const result = await NotesService.getNotes()
expect(global.fetch).toHaveBeenCalledWith(
'/api/notes?archived=false',
expect.objectContaining({
headers: expect.any(Object),
})
)
expect(result).toHaveLength(2)
expect(result[0].todos).toEqual([])
expect(result[0].images).toEqual([])
expect(result[1].todos).toHaveLength(1)
})
it('should return empty array on error', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
})
const result = await NotesService.getNotes()
expect(result).toEqual([])
})
})
describe('getCounts', () => {
it('should return counts from API', async () => {
const mockCounts = { active: 5, archived: 2 }
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockCounts),
})
const result = await NotesService.getCounts()
expect(result).toEqual(mockCounts)
})
it('should return zero counts on error', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
})
const result = await NotesService.getCounts()
expect(result).toEqual({ active: 0, archived: 0 })
})
})
describe('createNote', () => {
it('should create a note and return it', async () => {
const mockNote = { id: 1, title: 'New Note', content: '', todos: [], images: [] }
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockNote),
})
const result = await NotesService.createNote('New Note')
expect(global.fetch).toHaveBeenCalledWith(
'/api/notes',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: 'New Note', content: '', todos: [] }),
})
)
expect(result).toEqual(mockNote)
})
it('should return null on error', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false })
const result = await NotesService.createNote('Test')
expect(result).toBeNull()
})
})
describe('deleteNote', () => {
it('should delete a note and return true', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ message: 'Deleted' }),
})
const result = await NotesService.deleteNote(1)
expect(global.fetch).toHaveBeenCalledWith(
'/api/notes/1',
expect.objectContaining({ method: 'DELETE' })
)
expect(result).toBe(true)
})
it('should return false on error', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false })
const result = await NotesService.deleteNote(1)
expect(result).toBe(false)
})
})
describe('updateNote', () => {
it('should return null if note has no id', async () => {
const result = await NotesService.updateNote({ title: 'Test', content: '', todos: [], images: [] })
expect(result).toBeNull()
})
it('should update note and return it', async () => {
const note = { id: 1, title: 'Updated', content: 'New content', todos: [], images: [] }
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ note }),
})
const result = await NotesService.updateNote(note)
expect(result).toEqual(note)
})
})
})
+52
View File
@@ -0,0 +1,52 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// Mock window.matchMedia for components that use media queries
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => { },
removeListener: () => { },
addEventListener: () => { },
removeEventListener: () => { },
dispatchEvent: () => false,
}),
})
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
// Mock IntersectionObserver
class IntersectionObserverMock {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: IntersectionObserverMock,
})
// Mock ResizeObserver
class ResizeObserverMock {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
Object.defineProperty(window, 'ResizeObserver', {
writable: true,
configurable: true,
value: ResizeObserverMock,
})