diff --git a/DURATION_FEATURE.md b/DURATION_FEATURE.md new file mode 100644 index 0000000..688a832 --- /dev/null +++ b/DURATION_FEATURE.md @@ -0,0 +1,421 @@ +# Documentazione Feature: Durata Task + +**Data implementazione**: 12 Febbraio 2026 +**Versione**: 2.1.0 +**Migration**: `13_20260212145848_add_duration_to_tasks.py` + +--- + +## 📋 Panoramica + +Aggiunto il campo `duration_minutes` alla tabella `Tasks` per consentire agli utenti di specificare la durata stimata di un task in minuti. + +--- + +## 🔧 Modifiche al Database + +### Campo Aggiunto +- **Nome**: `duration_minutes` +- **Tipo**: `INTEGER` +- **Nullable**: `TRUE` +- **Default**: `NULL` +- **Range valido**: 1-10080 minuti (1 minuto - 7 giorni) + +### Migration SQL +```sql +ALTER TABLE "Tasks" ADD "duration_minutes" INT; +``` + +--- + +## 📡 Modifiche agli Endpoint API + +### ✅ Endpoint Modificati + +Tutti gli endpoint esistenti supportano ora il campo `duration_minutes` in modo **retrocompatibile**. + +#### **POST /tasks** - Crea nuovo task + +**Request Body (aggiornato)**: +```json +{ + "title": "Meeting con team", + "description": "Sprint planning", + "user": "username", + "category_id": 1, + "priority": "Alta", + "status": "In sospeso", + "end_time": "2026-02-15T10:00:00Z", + "duration_minutes": 60 +} +``` + +**Campo nuovo**: +- `duration_minutes` (opzionale): Durata del task in minuti + - **Min**: 1 + - **Max**: 10080 (7 giorni) + - **Tipo**: Integer + - **Default**: `null` + +**Response** (invariata): +```json +{ + "task_id": 123, + "status_code": 201 +} +``` + +--- + +#### **PUT /tasks/{task_id}** - Modifica task esistente + +**Request Body (aggiornato)**: +```json +{ + "title": "Meeting aggiornato", + "duration_minutes": 90 +} +``` + +**Campo nuovo**: +- `duration_minutes` (opzionale): Nuova durata del task in minuti + - Validazione: 1-10080 minuti + +**Response** (invariata): +```json +{ + "message": "Task updated successfully", + "task_id": 123 +} +``` + +--- + +#### **GET /tasks** - Lista tutti i task + +**Response (aggiornato)**: +```json +[ + { + "task_id": 123, + "user_id": 1, + "title": "Meeting con team", + "description": "Sprint planning", + "start_time": "2026-02-12T14:30:00Z", + "end_time": "2026-02-15T10:00:00Z", + "duration_minutes": 60, + "notification_sent": false, + "category_id": 5, + "priority": "Alta", + "status": "In sospeso", + "is_recurring": false, + "recurrence_pattern": null, + ... + } +] +``` + +**Campo nuovo in response**: +- `duration_minutes`: Durata del task in minuti (può essere `null`) + +--- + +#### **GET /tasks/by-category-id/{category_id}** - Task per categoria + +**Response**: Stesso formato di `GET /tasks` (include `duration_minutes`) + +--- + +#### **GET /tasks/{category_name}** - Task per nome categoria (deprecato) + +**Response**: Stesso formato di `GET /tasks` (include `duration_minutes`) + +--- + +### ✅ Endpoint NON Modificati + +I seguenti endpoint **non sono stati modificati** ma continuano a funzionare normalmente: + +- `DELETE /tasks/{task_id}` - Nessun cambiamento +- `GET /tasks/stats/overview` - Nessun cambiamento (possibile estensione futura) +- `GET /tasks/debug/simple` - Nessun cambiamento +- `GET /tasks/debug/performance` - Nessun cambiamento + +--- + +## 📝 Esempi di Utilizzo + +### Esempio 1: Creare task con durata di 1 ora +```bash +curl -X POST http://localhost:8080/tasks \ + -H "X-API-Key: YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Code review", + "description": "Review PR #234", + "user": "developer", + "category_id": 3, + "priority": "Media", + "status": "In sospeso", + "duration_minutes": 60 + }' +``` + +### Esempio 2: Creare task senza durata (retrocompatibile) +```bash +curl -X POST http://localhost:8080/tasks \ + -H "X-API-Key: YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Task generico", + "description": "Nessuna durata specificata", + "user": "developer", + "category_id": 3, + "priority": "Bassa", + "status": "In sospeso" + }' +``` + +### Esempio 3: Modificare solo la durata di un task +```bash +curl -X PUT http://localhost:8080/tasks/123 \ + -H "X-API-Key: YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "duration_minutes": 120 + }' +``` + +### Esempio 4: Rimuovere la durata (impostare a null) +```bash +curl -X PUT http://localhost:8080/tasks/123 \ + -H "X-API-Key: YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "duration_minutes": null + }' +``` + +--- + +## 🔢 Valori di Durata Comuni + +| Durata | Minuti | Esempio | +|--------|--------|---------| +| 15 minuti | `15` | Quick stand-up | +| 30 minuti | `30` | Breve meeting | +| 1 ora | `60` | Sessione di lavoro | +| 2 ore | `120` | Workshop | +| Mezza giornata | `240` | 4 ore di lavoro | +| 1 giorno | `1440` | Giornata intera | +| 1 settimana | `10080` | Progetto lungo | + +--- + +## ✅ Validazioni + +### Validazione Input (Pydantic) + +```python +duration_minutes: Optional[int] = Field( + None, + ge=1, # Minimo: 1 minuto + le=10080, # Massimo: 10080 minuti (7 giorni) + description="Durata del task in minuti (max 7 giorni)" +) +``` + +### Errori di Validazione + +**Valore sotto il minimo**: +```json +{ + "detail": [ + { + "type": "greater_than_equal", + "loc": ["body", "duration_minutes"], + "msg": "Input should be greater than or equal to 1" + } + ] +} +``` + +**Valore sopra il massimo**: +```json +{ + "detail": [ + { + "type": "less_than_equal", + "loc": ["body", "duration_minutes"], + "msg": "Input should be less than or equal to 10080" + } + ] +} +``` + +--- + +## 🔄 Retrocompatibilità + +### ✅ Garantita al 100% + +1. **Task esistenti**: Tutti i task esistenti hanno `duration_minutes = NULL` +2. **Client vecchi**: Client che non inviano `duration_minutes` continuano a funzionare +3. **Endpoint invariati**: Nessun endpoint richiede obbligatoriamente `duration_minutes` +4. **Response sempre inclusa**: Il campo è sempre presente nelle response (può essere `null`) + +### Breaking Changes + +❌ **Nessuna breaking change** + +--- + +## 🔍 Note sul Sistema di Notifiche + +### ⚠️ IMPORTANTE + +Il campo `duration_minutes` è **completamente indipendente** dal sistema di notifiche. + +**Le notifiche si basano su**: +- `end_time` - Data/ora di scadenza del task +- `notification_sent` - Flag per evitare invii duplicati + +**`duration_minutes` NON influenza**: +- Quando vengono inviate le notifiche +- La logica dei trigger PostgreSQL +- Il sistema di retry delle notifiche + +`duration_minutes` è un campo **puramente informativo** che indica quanto tempo stimato serve per completare il task. + +--- + +## 🚀 Possibili Estensioni Future + +### 1. Calcolo Automatico End Time +```python +# Se duration_minutes è specificato ma end_time no: +if duration_minutes and not end_time: + end_time = start_time + timedelta(minutes=duration_minutes) +``` + +### 2. Statistiche Durata +```python +GET /tasks/stats/duration +# Response: +{ + "average_duration_by_category": {...}, + "total_estimated_time": 1440, + "completed_tasks_avg_duration": 85 +} +``` + +### 3. Time Tracking Effettivo +```python +# Nuovo campo: +actual_duration_minutes = fields.IntField(null=True) + +# Confronto stima vs realtà +{ + "estimated": 60, + "actual": 75, + "variance": "+25%" +} +``` + +### 4. Alert Superamento Tempo +```python +# Notifica se task sta impiegando più tempo del previsto +if current_time - start_time > duration_minutes: + send_notification("Task sta superando il tempo stimato") +``` + +### 5. Integrazione Google Calendar +```python +# Usa duration_minutes per calcolare end_time dell'evento +event = { + "start": {"dateTime": start_time}, + "end": {"dateTime": start_time + timedelta(minutes=duration_minutes)} +} +``` + +--- + +## 📊 Schema Database Completo + +### Tabella Tasks (estratto campi rilevanti) + +```sql +CREATE TABLE "Tasks" ( + task_id SERIAL PRIMARY KEY, + user_id INT NOT NULL, + title VARCHAR(100) NOT NULL, + description TEXT, + start_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMPTZ, + duration_minutes INT, -- ← NUOVO CAMPO + notification_sent BOOLEAN DEFAULT FALSE, + priority VARCHAR(5) DEFAULT 'Media', + status VARCHAR(10) DEFAULT 'In sospeso', + ... +); +``` + +--- + +## 🧪 Testing + +### Test Eseguiti + +✅ Validazione minima (1 minuto) +✅ Validazione massima (10080 minuti) +✅ Valore NULL accettato (campo opzionale) +✅ Task con durata valida (60 minuti) +✅ ModifyTask con duration_minutes +✅ Retrocompatibilità con task esistenti + +### Comandi Test Manuali + +```bash +# Test creazione task con durata +uv run python -c " +from src.app.schemas.task import TaskIn +task = TaskIn( + title='Test', + description='Test durata', + user='test', + category_id=1, + priority='Media', + status='In sospeso', + duration_minutes=60 +) +print(f'✓ Task creato: {task.duration_minutes} minuti') +" +``` + +--- + +## 📚 File Modificati + +1. **src/app/models/task.py** - Aggiunto campo al model Tortoise ORM +2. **src/app/schemas/task.py** - Aggiornati TaskIn, ModifyTask, TaskOut +3. **pyproject.toml** - Aggiunta configurazione `[tool.aerich]` +4. **migrations/models/13_20260212145848_add_duration_to_tasks.py** - Migration creata + +--- + +## 🔗 Riferimenti + +- **Migration file**: `migrations/models/13_20260212145848_add_duration_to_tasks.py` +- **Model**: `src/app/models/task.py:27` +- **Schema Input**: `src/app/schemas/task.py:58-60` +- **Schema Output**: `src/app/schemas/task.py:78` +- **Endpoint**: `src/app/api/routes/tasks.py` + +--- + +## 📞 Support + +Per domande o problemi relativi a questa feature, contatta il team di sviluppo. + +**Versione documento**: 1.0 +**Ultimo aggiornamento**: 12 Febbraio 2026 diff --git a/src/components/Calendar20/AgendaView.tsx b/src/components/Calendar20/AgendaView.tsx index d5c69b8..9ee5409 100644 --- a/src/components/Calendar20/AgendaView.tsx +++ b/src/components/Calendar20/AgendaView.tsx @@ -102,6 +102,17 @@ const AgendaView: React.FC = ({ ? t('calendar20.allDay') : `${item.startDayjs.format('HH:mm')} - ${item.endDayjs.format('HH:mm')}`; + // Format duration from server data if available + const durationStr = (() => { + const mins = item.duration_minutes; + if (!mins || mins <= 0) return null; + if (mins < 60) return `${mins} min`; + const h = Math.floor(mins / 60); + const m = mins % 60; + if (m === 0) return h === 1 ? '1 ora' : `${h} ore`; + return `${h}h ${m}min`; + })(); + return ( = ({ > {item.title} - {timeStr} + + {timeStr} + {durationStr && ( + + + {durationStr} + + )} + {item.category_name && ( @@ -292,6 +311,27 @@ const styles = StyleSheet.create({ fontWeight: '400', fontFamily: 'System', }, + taskTimeRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 4, + gap: 8, + }, + durationBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 3, + backgroundColor: '#f5f5f5', + borderRadius: 8, + paddingHorizontal: 6, + paddingVertical: 2, + }, + durationText: { + fontSize: 12, + color: '#666666', + fontFamily: 'System', + fontWeight: '400', + }, }); export default React.memo(AgendaView); diff --git a/src/components/Calendar20/Calendar20View.tsx b/src/components/Calendar20/Calendar20View.tsx index 921ca08..29ea0f9 100644 --- a/src/components/Calendar20/Calendar20View.tsx +++ b/src/components/Calendar20/Calendar20View.tsx @@ -64,7 +64,11 @@ const Calendar20View: React.FC = ({ onClose }) => { const displayColor = colorService.getColor(categoryName); const startDayjs = task.start_time ? dayjs(task.start_time) : dayjs(); const endDayjs = task.end_time ? dayjs(task.end_time) : startDayjs; - const durationMinutes = endDayjs.diff(startDayjs, 'minute'); + const computedDuration = endDayjs.diff(startDayjs, 'minute'); + // Use server-provided duration_minutes if available, otherwise compute from dates + const durationMinutes = (task.duration_minutes && task.duration_minutes > 0) + ? task.duration_minutes + : computedDuration; const isMultiDay = !startDayjs.isSame(endDayjs, 'day'); const isAllDay = durationMinutes >= 1440 || (!task.start_time && !!task.end_time); @@ -261,7 +265,9 @@ const Calendar20View: React.FC = ({ onClose }) => { description: string, dueDate: string, priority: number, - categoryNameParam?: string + categoryNameParam?: string, + recurrence?: any, + durationMinutes?: number | null ) => { const { addTask } = await import('../../services/taskService'); const priorityString = priority === 1 ? 'Bassa' : priority === 2 ? 'Media' : 'Alta'; @@ -277,6 +283,10 @@ const Calendar20View: React.FC = ({ onClose }) => { status: 'In sospeso', category_name: category, }; + // Add duration_minutes if provided (API v2.1.0) + if (durationMinutes !== undefined && durationMinutes !== null) { + newTask.duration_minutes = durationMinutes; + } try { await addTask(newTask); } catch (error) { diff --git a/src/components/Notes/NotesCanvas.tsx b/src/components/Notes/NotesCanvas.tsx index 140cdab..dfa9e6e 100644 --- a/src/components/Notes/NotesCanvas.tsx +++ b/src/components/Notes/NotesCanvas.tsx @@ -16,6 +16,7 @@ import Animated, { import Svg, { Defs, Pattern, Rect, Circle } from 'react-native-svg'; import { useNotesState } from '../../context/NotesContext'; import { StickyNote } from './StickyNote'; +import { canvasViewport } from '../../utils/canvasViewport'; // Context per gestire il focus globale delle note export const NotesFocusContext = React.createContext<{ @@ -133,6 +134,13 @@ export const NotesCanvas: React.FC = () => { lastTranslateX.value = translateX.value; lastTranslateY.value = translateY.value; lastScale.value = scale.value; + + // Update shared viewport state for note positioning + canvasViewport.translateX = translateX.value; + canvasViewport.translateY = translateY.value; + canvasViewport.scale = scale.value; + canvasViewport.screenWidth = screenWidth; + canvasViewport.screenHeight = screenHeight; isPinching.current = false; gestureState.current.initialDistance = null; diff --git a/src/components/Notes/StickyNote.tsx b/src/components/Notes/StickyNote.tsx index aec11f8..64e25a9 100644 --- a/src/components/Notes/StickyNote.tsx +++ b/src/components/Notes/StickyNote.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { View, Text, @@ -6,6 +6,7 @@ import { TextInput, TouchableOpacity, Keyboard, + Modal, } from 'react-native'; import Animated, { useSharedValue, @@ -52,6 +53,7 @@ export const StickyNote: React.FC = ({ note, canvasScale }) => const [editText, setEditText] = useState(note.text); const [showColorPicker, setShowColorPicker] = useState(false); const [currentColor, setCurrentColor] = useState(note.color); + const justPressedButtonRef = useRef(false); console.log('StickyNote render:', note.id, 'position:', note.position, 'text:', note.text); @@ -69,6 +71,11 @@ export const StickyNote: React.FC = ({ note, canvasScale }) => // Definiti PRIMA dei gesture per runOnJS const handleStartEditing = useCallback(() => { + // Ignora il tap se l'utente ha appena premuto un bottone (palette/delete) + if (justPressedButtonRef.current) { + justPressedButtonRef.current = false; + return; + } console.log('Tap detected on note:', note.id, '- toggling editor'); setIsEditing((prev) => { if (prev) { @@ -171,8 +178,12 @@ export const StickyNote: React.FC = ({ note, canvasScale }) => const handleLongPress = () => { try { - console.log('Long press detected on note:', note.id); + console.log('Palette pressed on note:', note.id); + justPressedButtonRef.current = true; + Keyboard.dismiss(); + setIsEditing(false); setShowColorPicker(true); + setTimeout(() => { justPressedButtonRef.current = false; }, 300); } catch (error) { console.error('Error in handleLongPress:', error); } @@ -183,6 +194,9 @@ export const StickyNote: React.FC = ({ note, canvasScale }) => // Aggiorna il colore locale immediatamente setCurrentColor(color); + // Salva il colore sul server + updateNote(note.id, { color }); + // Chiudi il picker setShowColorPicker(false); @@ -193,78 +207,96 @@ export const StickyNote: React.FC = ({ note, canvasScale }) => } }; + const handleCloseColorPicker = () => { + setShowColorPicker(false); + }; + const handleTextSave = useCallback(() => { if (editText.trim() !== note.text) { - updateNote(note.id, editText.trim()); + updateNote(note.id, { text: editText.trim() }); } Keyboard.dismiss(); setIsEditing(false); }, [editText, note.text, note.id, updateNote]); const handleDelete = () => { + justPressedButtonRef.current = true; deleteNote(note.id); + setTimeout(() => { justPressedButtonRef.current = false; }, 300); }; - const ColorPicker: React.FC = () => ( - - - {COLORS.map((color) => ( - handleColorChange(color)} - /> - ))} - - setShowColorPicker(false)} - > - Chiudi - - - ); - return ( - - - - - - - - - - + <> + + + + + + + + + + + + + {isEditing ? ( + + ) : ( + + + {note.text} + + + )} + + - {isEditing ? ( - - ) : ( - - - {note.text} - + + + + Scegli un colore + + {COLORS.map((color) => ( + handleColorChange(color)} + /> + ))} - )} - - {showColorPicker && } - - - + + Chiudi + + + + + ); }; @@ -329,30 +361,40 @@ const styles = StyleSheet.create({ textAlignVertical: 'top', padding: 0, }, - colorPicker: { - position: 'absolute', - top: 30, - left: -10, - right: -10, + colorPickerOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.4)', + justifyContent: 'center', + alignItems: 'center', + }, + colorPickerModal: { backgroundColor: 'white', - borderRadius: 8, - padding: 8, - elevation: 8, + borderRadius: 16, + padding: 20, + width: 260, + alignItems: 'center', + elevation: 10, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, - shadowRadius: 4, + shadowRadius: 8, + }, + colorPickerTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 16, }, colorGrid: { flexDirection: 'row', flexWrap: 'wrap', - justifyContent: 'space-between', + justifyContent: 'center', + gap: 8, }, colorOption: { - width: 30, - height: 30, - borderRadius: 15, - margin: 2, + width: 38, + height: 38, + borderRadius: 19, borderWidth: 2, borderColor: '#ddd', }, @@ -362,14 +404,16 @@ const styles = StyleSheet.create({ transform: [{ scale: 1.1 }], }, closeColorPicker: { - marginTop: 8, - padding: 8, + marginTop: 16, + paddingVertical: 10, + paddingHorizontal: 24, backgroundColor: '#f0f0f0', - borderRadius: 4, + borderRadius: 8, alignItems: 'center', }, closeText: { - fontSize: 12, + fontSize: 14, color: '#666', + fontWeight: '500', }, }); \ No newline at end of file diff --git a/src/components/Task/AddTask.tsx b/src/components/Task/AddTask.tsx index d35c85c..6fe4ec7 100644 --- a/src/components/Task/AddTask.tsx +++ b/src/components/Task/AddTask.tsx @@ -34,7 +34,8 @@ export type AddTaskProps = { dueDate: string, priority: number, categoryName?: string, - recurrence?: RecurrenceConfigType + recurrence?: RecurrenceConfigType, + durationMinutes?: number | null ) => void; categoryName?: string; initialDate?: string; // Nuova prop per la data iniziale @@ -77,6 +78,18 @@ const AddTask: React.FC = ({ end_type: "never", }); + // Duration state + const [durationMinutes, setDurationMinutes] = useState(null); + const [customDuration, setCustomDuration] = useState(""); + + const DURATION_PRESETS = [ + { label: "15 min", value: 15 }, + { label: "30 min", value: 30 }, + { label: "1 ora", value: 60 }, + { label: "2 ore", value: 120 }, + { label: "4 ore", value: 240 }, + ]; + // Gestisce l'animazione all'apertura del modale useEffect(() => { if (visible) { @@ -140,6 +153,8 @@ const AddTask: React.FC = ({ interval: 1, end_type: "never", }); + setDurationMinutes(null); + setCustomDuration(""); }; const handleSave = () => { @@ -177,6 +192,7 @@ const AddTask: React.FC = ({ : categoryName || "", // Aggiungere il nome della categoria user: "", // Campo richiesto dal server completed: false, + duration_minutes: durationMinutes || null, // Durata stimata in minuti }; // Add recurring task fields if enabled @@ -212,7 +228,8 @@ const AddTask: React.FC = ({ dueDate, priority, allowCategorySelection ? localCategory : categoryName, - isRecurring ? recurrenceConfig : undefined + isRecurring ? recurrenceConfig : undefined, + durationMinutes ); } else if (categoryName) { // Solo se non c'è onSave, usa addTaskToList per compatibilità @@ -463,6 +480,67 @@ const AddTask: React.FC = ({ + {/* Duration selector */} + Durata stimata (opzionale) + + {DURATION_PRESETS.map((preset) => ( + { + if (durationMinutes === preset.value) { + setDurationMinutes(null); + setCustomDuration(""); + } else { + setDurationMinutes(preset.value); + setCustomDuration(""); + } + }} + > + + {preset.label} + + + ))} + + + { + const numericText = text.replace(/[^0-9]/g, ""); + setCustomDuration(numericText); + const val = parseInt(numericText, 10); + if (val >= 1 && val <= 10080) { + setDurationMinutes(val); + } else if (numericText === "") { + setDurationMinutes(null); + } + }} + /> + {durationMinutes && ( + { + setDurationMinutes(null); + setCustomDuration(""); + }} + > + + + )} + + {/* Recurring Task Toggle */} {t('recurring.toggle')} @@ -750,6 +828,55 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: "#e1e5e9", }, + durationContainer: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginBottom: 12, + }, + durationChip: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 20, + borderWidth: 1.5, + borderColor: "#e1e5e9", + backgroundColor: "#ffffff", + }, + durationChipActive: { + borderColor: "#000000", + backgroundColor: "#f8f8f8", + borderWidth: 2, + }, + durationChipText: { + fontSize: 14, + color: "#666666", + fontFamily: "System", + fontWeight: "400", + }, + durationChipTextActive: { + color: "#000000", + fontWeight: "500", + }, + customDurationRow: { + flexDirection: "row", + alignItems: "center", + marginBottom: 24, + }, + customDurationInput: { + flex: 1, + borderWidth: 1.5, + borderColor: "#e1e5e9", + borderRadius: 16, + padding: 14, + fontSize: 15, + backgroundColor: "#ffffff", + fontFamily: "System", + color: "#000000", + }, + clearDurationButton: { + marginLeft: 8, + padding: 4, + }, }); export default AddTask; diff --git a/src/components/Task/BasicComponents.tsx b/src/components/Task/BasicComponents.tsx index 3dfbaa7..bb75c18 100644 --- a/src/components/Task/BasicComponents.tsx +++ b/src/components/Task/BasicComponents.tsx @@ -5,6 +5,18 @@ import { useTranslation } from "react-i18next"; import { styles } from "./TaskStyles"; import { getDaysRemainingText, getDaysRemainingColor, getPriorityTextColor } from "./TaskUtils"; +// Helper per formattare la durata +const formatDuration = (minutes?: number | null): string | null => { + if (!minutes) return null; + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (remainingMinutes === 0) { + return hours === 1 ? '1 ora' : `${hours} ore`; + } + return `${hours}h ${remainingMinutes}min`; +}; + // Componente Checkbox riutilizzabile export const Checkbox = ({ checked, onPress, isOptimistic = false }) => ( { ); }; +// Componente per visualizzare la durata stimata +export const DurationDisplay = ({ durationMinutes }: { durationMinutes?: number | null }) => { + const formatted = formatDuration(durationMinutes); + if (!formatted) return null; + + return ( + + {formatted} + + ); +}; + // Componente per visualizzare i giorni rimanenti export const DaysRemaining = ({ endDate }) => { const daysRemainingText = getDaysRemainingText(endDate); diff --git a/src/components/Task/Task.tsx b/src/components/Task/Task.tsx index e76c5dd..5557cad 100644 --- a/src/components/Task/Task.tsx +++ b/src/components/Task/Task.tsx @@ -91,6 +91,9 @@ const Task = ({ const toggleExpand = () => { if (animationInProgress.current) return; + // Non permettere l'espansione se non c'è descrizione + if (!task.description || task.description.trim() === '') return; + animationInProgress.current = true; const isExpanding = !expanded; @@ -189,6 +192,7 @@ const Task = ({ end_time: editedTaskData.end_time, priority: editedTaskData.priority, status: editedTaskData.status, + duration_minutes: editedTaskData.duration_minutes, completed: editedTaskData.status === "Completato" ? true : task.completed }; @@ -199,7 +203,8 @@ const Task = ({ task.end_time !== updatedTask.end_time || task.priority !== updatedTask.priority || task.status !== updatedTask.status || - task.completed !== updatedTask.completed; + task.completed !== updatedTask.completed || + task.duration_minutes !== updatedTask.duration_minutes; if (hasChanged) { console.log("Salvataggio modifiche per task:", updatedTask); @@ -381,19 +386,6 @@ const Task = ({ descriptionRef={descriptionRef} /> - {/* Pulsante di espansione */} - - - - {/* Modal menu azioni */} = ({ task, onPress }) => { return 'Ricorrente'; }; + // Format duration for display + const formatDuration = (minutes?: number | null): string | null => { + if (!minutes) return null; + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (remainingMinutes === 0) { + return hours === 1 ? '1 ora' : `${hours} ore`; + } + return `${hours}h ${remainingMinutes}min`; + }; + // Determina il colore in base alla priorità (gradiente di scurezza) const priorityColors: Record = { 'Alta': '#000000', // Nero per alta priorità @@ -149,21 +161,15 @@ const TaskCard: React.FC = ({ task, onPress }) => { - - - {task.start_time || task.end_time || task.next_occurrence ? ( - - ) : ( - - )} - - {task.is_recurring && task.next_occurrence ? 'Prossima: ' : ''} - {formatTaskTime(task.start_time, task.end_time, task.next_occurrence)} - - + + {/* Duration display */} + {formatDuration(task.duration_minutes) && ( + + + {formatDuration(task.duration_minutes)} + + + )} ); @@ -246,17 +252,6 @@ const styles = StyleSheet.create({ fontWeight: "400", fontFamily: "System", }, - taskTimeInfo: { - flexDirection: "row", - alignItems: "center", - }, - taskTimeText: { - fontSize: 12, - color: "#999999", - marginLeft: 6, - fontFamily: "System", - fontWeight: "300", - }, recurrenceDescription: { fontSize: 12, color: "#007AFF", @@ -272,6 +267,18 @@ const styles = StyleSheet.create({ fontWeight: "300", fontStyle: "italic", }, + durationInfo: { + flexDirection: "row", + alignItems: "center", + marginTop: 4, + }, + durationInfoText: { + fontSize: 12, + color: "#666666", + marginLeft: 6, + fontFamily: "System", + fontWeight: "400", + }, }); export default TaskCard; \ No newline at end of file diff --git a/src/components/Task/TaskEditModal.tsx b/src/components/Task/TaskEditModal.tsx index 9854b26..b17f626 100644 --- a/src/components/Task/TaskEditModal.tsx +++ b/src/components/Task/TaskEditModal.tsx @@ -1,10 +1,18 @@ import React, { useState, useEffect } from "react"; import { View, Text, TouchableOpacity, Modal, TextInput, ScrollView, Alert } from "react-native"; import DateTimePicker from '@react-native-community/datetimepicker'; -import { MaterialIcons } from "@expo/vector-icons"; +import { MaterialIcons, Ionicons } from "@expo/vector-icons"; import { styles } from "./TaskStyles"; import { PrioritySelector, StatusSelector, DatePickerButton, TimePickerButton } from "./FormComponents"; +const DURATION_PRESETS = [ + { label: "15 min", value: 15 }, + { label: "30 min", value: 30 }, + { label: "1 ora", value: 60 }, + { label: "2 ore", value: 120 }, + { label: "4 ore", value: 240 }, +]; + // Componente per il modal di modifica const TaskEditModal = ({ visible, @@ -19,12 +27,14 @@ const TaskEditModal = ({ end_time: "", priority: "", status: "", + duration_minutes: null as number | null, }); const [showDatePicker, setShowDatePicker] = useState(false); const [showTimePicker, setShowTimePicker] = useState(false); const [pickerMode, setPickerMode] = useState('date'); const [dateType, setDateType] = useState('end'); // 'start' o 'end' + const [customDuration, setCustomDuration] = useState(""); // Quando il modale diventa visibile, inizializza i campi useEffect(() => { @@ -36,7 +46,9 @@ const TaskEditModal = ({ end_time: task.end_time, priority: task.priority, status: task.status || "In sospeso", + duration_minutes: task.duration_minutes ?? null, }); + setCustomDuration(task.duration_minutes ? String(task.duration_minutes) : ""); } }, [visible, task]); @@ -192,6 +204,66 @@ const TaskEditModal = ({ value={editedTask.status} onChange={(status) => setEditedTask({...editedTask, status})} /> + + Durata stimata (opzionale) + + {DURATION_PRESETS.map((preset) => ( + { + if (editedTask.duration_minutes === preset.value) { + setEditedTask({...editedTask, duration_minutes: null}); + setCustomDuration(""); + } else { + setEditedTask({...editedTask, duration_minutes: preset.value}); + setCustomDuration(""); + } + }} + > + + {preset.label} + + + ))} + + + { + const numericText = text.replace(/[^0-9]/g, ""); + setCustomDuration(numericText); + const val = parseInt(numericText, 10); + if (val >= 1 && val <= 10080) { + setEditedTask({...editedTask, duration_minutes: val}); + } else if (numericText === "") { + setEditedTask({...editedTask, duration_minutes: null}); + } + }} + /> + {editedTask.duration_minutes && ( + { + setEditedTask({...editedTask, duration_minutes: null}); + setCustomDuration(""); + }} + > + + + )} + diff --git a/src/components/Task/TaskHeader.tsx b/src/components/Task/TaskHeader.tsx index 222a4d5..c5d6c23 100644 --- a/src/components/Task/TaskHeader.tsx +++ b/src/components/Task/TaskHeader.tsx @@ -1,7 +1,7 @@ import React from "react"; import { View, Pressable } from "react-native"; import { styles } from "./TaskStyles"; -import { Checkbox, TaskTitle, DateDisplay, DaysRemaining } from "./BasicComponents"; +import { Checkbox, TaskTitle, DateDisplay, DaysRemaining, DurationDisplay } from "./BasicComponents"; // Componente per l'intestazione del task (checkbox, titolo, info) const TaskHeader = ({ @@ -40,15 +40,12 @@ const TaskHeader = ({ numberOfLines={expanded ? undefined : 1} priority={task.priority} /> - - - - - {/* Giorni rimanenti (spostato a destra) */} + {/* Giorni rimanenti e durata (spostato a destra) */} + ); diff --git a/src/components/Task/TaskStyles.tsx b/src/components/Task/TaskStyles.tsx index a10450e..5e632aa 100644 --- a/src/components/Task/TaskStyles.tsx +++ b/src/components/Task/TaskStyles.tsx @@ -6,7 +6,7 @@ export const styles = StyleSheet.create({ backgroundColor: "#ffffff", borderRadius: 16, padding: 16, - marginVertical: 8, + marginVertical: 4, marginHorizontal: 16, borderWidth: 1, borderColor: "#e1e5e9", @@ -97,6 +97,7 @@ export const styles = StyleSheet.create({ marginLeft: 'auto', marginRight: 8, justifyContent: 'center', + alignItems: 'flex-end', }, daysRemaining: { fontSize: 13, @@ -393,4 +394,67 @@ export const styles = StyleSheet.create({ color: '#000000', paddingHorizontal: 16, }, + // Duration styles + durationContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 12, + }, + durationChip: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 20, + borderWidth: 1.5, + borderColor: '#e1e5e9', + backgroundColor: '#ffffff', + }, + durationChipActive: { + borderColor: '#000000', + backgroundColor: '#f8f8f8', + borderWidth: 2, + }, + durationChipText: { + fontSize: 14, + color: '#666666', + fontFamily: 'System', + fontWeight: '400', + }, + durationChipTextActive: { + color: '#000000', + fontWeight: '500', + }, + customDurationRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 24, + }, + customDurationInput: { + flex: 1, + borderWidth: 1.5, + borderColor: '#e1e5e9', + borderRadius: 16, + padding: 14, + fontSize: 15, + backgroundColor: '#ffffff', + fontFamily: 'System', + color: '#000000', + }, + clearDurationButton: { + marginLeft: 8, + padding: 4, + }, + // Duration display (for TaskHeader/BasicComponents) + durationContainer2: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 4, + }, + durationText: { + fontSize: 12, + color: '#666666', + marginLeft: 4, + fontWeight: '400', + fontFamily: 'System', + }, }); \ No newline at end of file diff --git a/src/components/TaskList/TaskListContainer.tsx b/src/components/TaskList/TaskListContainer.tsx index 86998a4..84a9e8c 100644 --- a/src/components/TaskList/TaskListContainer.tsx +++ b/src/components/TaskList/TaskListContainer.tsx @@ -312,7 +312,8 @@ export const TaskListContainer = ({ dueDate: string, priority: number, categoryName?: string, - recurrence?: any + recurrence?: any, + durationMinutes?: number | null ) => { const priorityString = priority === 1 ? "Bassa" : priority === 2 ? "Media" : "Alta"; @@ -327,6 +328,11 @@ export const TaskListContainer = ({ status: "In sospeso", }; + // Add duration_minutes if provided (API v2.1.0) + if (durationMinutes !== undefined && durationMinutes !== null) { + taskData.duration_minutes = durationMinutes; + } + // Add recurring task fields if this is a recurring task (NEW API v2.2.0) if (recurrence) { taskData.is_recurring = true; diff --git a/src/components/TaskList/types.ts b/src/components/TaskList/types.ts index e1c231d..b1b3c48 100644 --- a/src/components/TaskList/types.ts +++ b/src/components/TaskList/types.ts @@ -14,6 +14,7 @@ export interface Task { category_id?: number | string; category_name?: string; isOptimistic?: boolean; + duration_minutes?: number | null; } // Riferimento globale per i task condivisi tra componenti diff --git a/src/hooks/useNotes.ts b/src/hooks/useNotes.ts index 8e339e0..2cd9a48 100644 --- a/src/hooks/useNotes.ts +++ b/src/hooks/useNotes.ts @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { Dimensions } from "react-native"; import { Note, addNote, @@ -9,6 +8,7 @@ import { updateNotePosition, } from "../services/noteService"; import { useFocusEffect } from "@react-navigation/native"; +import { canvasViewport, CANVAS_SIZE as ACTUAL_CANVAS_SIZE } from "../utils/canvasViewport"; const COLORS = [ "#FFCDD2", // Rosa chiaro @@ -36,7 +36,7 @@ export interface NotesState { export interface NotesActions { addNote: (text: string) => Promise; - updateNote: (id: string, text: string) => Promise; + updateNote: (id: string, updates: { text?: string; color?: string }) => Promise; deleteNote: (id: string) => Promise; updateNotePosition: ( id: string, @@ -58,7 +58,6 @@ export function useNotes( nextZIndex: 1, }); - const { width, height } = Dimensions.get("window"); const abortControllerRef = useRef(null); const updateState = useCallback((updates: Partial) => { @@ -66,29 +65,26 @@ export function useNotes( }, []); const generateRandomPosition = useCallback(() => { - const padding = 50; const noteWidth = 200; const noteHeight = 160; - // Griglia 50x50 con punti ogni 40px = 2000x2000px totali - const GRID_SIZE = 40; - const GRID_POINTS = 50; - const CANVAS_SIZE = GRID_POINTS * GRID_SIZE; + // Calcola il centro del viewport corrente in coordinate canvas + const { translateX: tx, translateY: ty, scale: s, screenWidth: sw, screenHeight: sh } = canvasViewport; + const currentScale = s || 1; - // Genera posizioni al centro della griglia (intorno al punto 25,25) - const centerArea = CANVAS_SIZE / 2; - const areaSize = Math.min(width, height); // Area del viewport + const viewportCenterX = (-tx + sw / 2) / currentScale; + const viewportCenterY = (-ty + sh / 2) / currentScale; - const minX = centerArea - areaSize / 2 + padding; - const maxX = centerArea + areaSize / 2 - noteWidth - padding; - const minY = centerArea - areaSize / 2 + padding; - const maxY = centerArea + areaSize / 2 - noteHeight - padding; + // Aggiungi un piccolo offset casuale per evitare sovrapposizioni + const offsetRange = 40; + const x = viewportCenterX - noteWidth / 2 + (Math.random() * offsetRange * 2 - offsetRange); + const y = viewportCenterY - noteHeight / 2 + (Math.random() * offsetRange * 2 - offsetRange); return { - x: Math.random() * (maxX - minX) + minX, - y: Math.random() * (maxY - minY) + minY, + x: Math.max(0, Math.min(ACTUAL_CANVAS_SIZE - noteWidth, x)), + y: Math.max(0, Math.min(ACTUAL_CANVAS_SIZE - noteHeight, y)), }; - }, [width, height]); + }, []); const getRandomColor = useCallback(() => { return COLORS[Math.floor(Math.random() * COLORS.length)]; @@ -247,19 +243,33 @@ export function useNotes( ); const updateNoteAction = useCallback( - async (id: string, newText: string) => { - if (!id.trim() || !newText.trim()) return; + async (id: string, updates: { text?: string; color?: string }) => { + if (!id.trim()) return; + + // Valida che ci sia almeno un campo da aggiornare + if (!updates.text && !updates.color) return; + + // Prepara gli aggiornamenti + const noteUpdates: Partial = {}; + if (updates.text !== undefined) { + const trimmedText = updates.text.trim(); + if (!trimmedText) return; // Non permettere testo vuoto + noteUpdates.text = trimmedText; + } + if (updates.color !== undefined) { + noteUpdates.color = updates.color; + } // Aggiornamento ottimistico setState((prevState) => ({ ...prevState, notes: prevState.notes.map((note) => - note.id === id ? { ...note, text: newText.trim() } : note + note.id === id ? { ...note, ...noteUpdates } : note ), })); try { - await updateNote(id, { text: newText.trim() }); + await updateNote(id, noteUpdates); } catch (error: any) { console.error("Errore nell'aggiornamento della nota:", error); updateState({ error: "Impossibile aggiornare la nota" }); diff --git a/src/services/noteService.ts b/src/services/noteService.ts index 6be0140..c8993a1 100644 --- a/src/services/noteService.ts +++ b/src/services/noteService.ts @@ -52,7 +52,7 @@ const mapServerNoteToClientNote = (serverNote: ServerNote): Note => { // Assicurati che il title sia sempre una stringa const noteText = typeof serverNote.title === 'string' ? serverNote.title : ''; - if (noteText === '' && serverNote.title !== '') { + if (noteText === '' && serverNote.title != null && serverNote.title !== '') { console.warn('[DEBUG] mapServerNoteToClientNote: Non-string title converted to empty string:', serverNote.title); } const result = { diff --git a/src/services/taskService.ts b/src/services/taskService.ts index 3916690..467a9d6 100644 --- a/src/services/taskService.ts +++ b/src/services/taskService.ts @@ -45,6 +45,9 @@ export interface Task { next_occurrence?: string; // When the task is next due (ISO 8601) last_completed_at?: string; // When the task was last completed (ISO 8601) + // Task duration (API v2.1.0) + duration_minutes?: number | null; // Estimated duration in minutes (1-10080, i.e. 1 min to 7 days) + // DEPRECATED: Old recurring task system (kept for backward compatibility) is_generated_instance?: boolean; // Indicates if this task is a generated instance from a recurring template parent_template_id?: number; // ID of the parent recurring template (if this is an instance) @@ -413,6 +416,11 @@ export async function updateTask( status: status, }; + // Add duration_minutes if provided (API v2.1.0) - supports null to remove duration + if (updatedTask.duration_minutes !== undefined) { + taskData.duration_minutes = updatedTask.duration_minutes; + } + // Usa category_id se disponibile (preferito), altrimenti fallback su category_name if (updatedTask.category_id !== undefined) { taskData.category_id = updatedTask.category_id; @@ -713,6 +721,11 @@ export async function addTask(task: Task) { user: task.user || username, }; + // Add duration_minutes if provided (API v2.1.0) + if (task.duration_minutes !== undefined) { + data.duration_minutes = task.duration_minutes; + } + // Usa category_id se disponibile, altrimenti fallback su category_name if (task.category_id !== undefined) { data.category_id = task.category_id; diff --git a/src/utils/canvasViewport.ts b/src/utils/canvasViewport.ts new file mode 100644 index 0000000..b14e055 --- /dev/null +++ b/src/utils/canvasViewport.ts @@ -0,0 +1,20 @@ +import { Dimensions } from 'react-native'; + +// Canvas constants - must match NotesCanvas (GRID_POINTS=50, GRID_SIZE=60) +export const CANVAS_SIZE = 50 * 60; // 3000px + +const { width, height } = Dimensions.get('window'); +const initCenterOffset = (CANVAS_SIZE - width) / 2; + +/** + * Mutable object tracking the current canvas viewport state. + * Updated by NotesCanvas on pan/zoom release. + * Read by useNotes to position new notes at the viewport center. + */ +export const canvasViewport = { + translateX: -initCenterOffset, + translateY: -initCenterOffset, + scale: 1, + screenWidth: width, + screenHeight: height, +};