feat: Enhance task filtering, smart scheduling, audit logs and translations
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -45,6 +45,7 @@ interface TaskCardProps {
|
||||
onDelete?: () => void;
|
||||
onStatusChange?: (status: Task['status']) => void;
|
||||
onUpdate?: (updates: Partial<Task>) => void;
|
||||
onAutoSchedule?: () => void;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void })
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, isDragging }: TaskCardProps) {
|
||||
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, onAutoSchedule, isDragging }: TaskCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
@@ -332,7 +333,7 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
<TooltipContent>
|
||||
{isBlocked ? (
|
||||
<p className="text-destructive font-medium">
|
||||
Blocked by: {blockingTasks.map(t => t?.title).join(", ")}
|
||||
{t('taskCard.blockedBy', { tasks: blockingTasks.map(t => t?.title).join(", ") })}
|
||||
</p>
|
||||
) : (
|
||||
<p>{t('taskCard.toggleComplete')}</p>
|
||||
@@ -490,6 +491,20 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
{t('taskCard.editTask')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onAutoSchedule && !task.dueDate && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAutoSchedule();
|
||||
}}
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
data-testid={`menu-schedule-${task.id}`}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.autoSchedule', 'Auto-Schedule')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -219,26 +219,32 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Select
|
||||
value={estimatedDuration ? estimatedDuration.toString() : "0"}
|
||||
onValueChange={(val) => setEstimatedDuration(val === "0" ? undefined : parseInt(val))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('taskCreation.duration')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
|
||||
<SelectItem value="15">15m</SelectItem>
|
||||
<SelectItem value="30">30m</SelectItem>
|
||||
<SelectItem value="45">45m</SelectItem>
|
||||
<SelectItem value="60">1h</SelectItem>
|
||||
<SelectItem value="90">1.5h</SelectItem>
|
||||
<SelectItem value="120">2h</SelectItem>
|
||||
<SelectItem value="240">4h</SelectItem>
|
||||
<SelectItem value="480">8h (1 Day)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('taskCreation.duration')} ({t('planning.minutes')})</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="5"
|
||||
placeholder={t('planning.durationPlaceholder')}
|
||||
value={estimatedDuration || ''}
|
||||
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="w-full"
|
||||
data-testid="input-duration"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[15, 30, 45, 60, 90, 120].map((mins) => (
|
||||
<Badge
|
||||
key={mins}
|
||||
variant={estimatedDuration === mins ? "default" : "outline"}
|
||||
className="cursor-pointer text-[10px] px-1.5 py-0.5"
|
||||
onClick={() => setEstimatedDuration(mins)}
|
||||
>
|
||||
{mins}m
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
|
||||
@@ -326,15 +332,15 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setRecurrenceInterval(v === 'none' ? undefined : v);
|
||||
setIsRecurring(v !== 'none');
|
||||
}}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-8" data-testid="recurrence-trigger">
|
||||
<SelectValue placeholder={t('taskCreation.recurrence.none')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('taskCreation.recurrence.none')}</SelectItem>
|
||||
<SelectItem value="daily">{t('taskCreation.recurrence.daily')}</SelectItem>
|
||||
<SelectItem value="weekly">{t('taskCreation.recurrence.weekly')}</SelectItem>
|
||||
<SelectItem value="monthly">{t('taskCreation.recurrence.monthly')}</SelectItem>
|
||||
<SelectItem value="yearly">{t('taskCreation.recurrence.yearly')}</SelectItem>
|
||||
<SelectItem value="none" data-testid="recurrence-option-none">{t('taskCreation.recurrence.none')}</SelectItem>
|
||||
<SelectItem value="daily" data-testid="recurrence-option-daily">{t('taskCreation.recurrence.daily')}</SelectItem>
|
||||
<SelectItem value="weekly" data-testid="recurrence-option-weekly">{t('taskCreation.recurrence.weekly')}</SelectItem>
|
||||
<SelectItem value="monthly" data-testid="recurrence-option-monthly">{t('taskCreation.recurrence.monthly')}</SelectItem>
|
||||
<SelectItem value="yearly" data-testid="recurrence-option-yearly">{t('taskCreation.recurrence.yearly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -611,26 +611,30 @@ export default function TaskDetailsModal({
|
||||
|
||||
{/* Estimated Duration */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')}</label>
|
||||
<Select
|
||||
value={editedEstimatedDuration ? editedEstimatedDuration.toString() : "0"}
|
||||
onValueChange={(val) => setEditedEstimatedDuration(val === "0" ? undefined : parseInt(val))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('taskCreation.duration')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
|
||||
<SelectItem value="15">15m</SelectItem>
|
||||
<SelectItem value="30">30m</SelectItem>
|
||||
<SelectItem value="45">45m</SelectItem>
|
||||
<SelectItem value="60">1h</SelectItem>
|
||||
<SelectItem value="90">1.5h</SelectItem>
|
||||
<SelectItem value="120">2h</SelectItem>
|
||||
<SelectItem value="240">4h</SelectItem>
|
||||
<SelectItem value="480">8h (1 Day)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')} ({t('planning.minutes')})</label>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="5"
|
||||
placeholder={t('planning.durationPlaceholder')}
|
||||
value={editedEstimatedDuration || ''}
|
||||
onChange={(e) => setEditedEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
data-testid="input-edit-duration"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[15, 30, 45, 60, 90, 120].map((mins) => (
|
||||
<Badge
|
||||
key={mins}
|
||||
variant={editedEstimatedDuration === mins ? "default" : "outline"}
|
||||
className="cursor-pointer text-xs"
|
||||
onClick={() => setEditedEstimatedDuration(mins)}
|
||||
>
|
||||
{mins}m
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
|
||||
@@ -35,7 +35,7 @@ interface TasksWithCalendarProps {
|
||||
}
|
||||
|
||||
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
|
||||
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
|
||||
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
|
||||
|
||||
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: TasksWithCalendarProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -100,6 +100,10 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
switch (filterBy) {
|
||||
case 'overdue':
|
||||
return isOverdue(task);
|
||||
case 'planned':
|
||||
return !!task.dueDate || !!task.startDate;
|
||||
case 'unplanned':
|
||||
return !task.dueDate && !task.startDate;
|
||||
case 'all':
|
||||
return true;
|
||||
default:
|
||||
@@ -163,6 +167,10 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
switch (filter) {
|
||||
case 'overdue':
|
||||
return tasks.filter(isOverdue).length;
|
||||
case 'planned':
|
||||
return tasks.filter(t => !!t.dueDate || !!t.startDate).length;
|
||||
case 'unplanned':
|
||||
return tasks.filter(t => !t.dueDate && !t.startDate).length;
|
||||
case 'all':
|
||||
return tasks.length;
|
||||
default:
|
||||
@@ -283,6 +291,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
<SelectItem value="inProgress">{t('taskList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
|
||||
<SelectItem value="done">{t('taskList.filter.done')} ({getFilterCount('done')})</SelectItem>
|
||||
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
|
||||
<SelectItem value="planned">{t('taskList.filter.planned')} ({getFilterCount('planned')})</SelectItem>
|
||||
<SelectItem value="unplanned">{t('taskList.filter.unplanned')} ({getFilterCount('unplanned')})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -321,7 +331,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2 mt-2">
|
||||
Task List
|
||||
{t('taskList.listHeader')}
|
||||
</div>
|
||||
{unscheduledTasks.map((task) => (
|
||||
<div
|
||||
@@ -592,7 +602,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
<div className="p-3 border-b bg-muted/30">
|
||||
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>{t('calendar.moreItems', { count: dayTasks.length - 2 })}</span>
|
||||
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
|
||||
<span className="text-[10px] font-normal opacity-75">{t('calendar.clickToEdit')}</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
|
||||
@@ -637,7 +647,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
? 'border-primary/50 text-primary/70'
|
||||
: 'border-muted-foreground/30 text-muted-foreground'
|
||||
}`}>
|
||||
{isHovered && draggedTask ? '✓ Drop here' : draggedTask ? 'Drop' : ''}
|
||||
{isHovered && draggedTask ? t('calendar.dropHere') : draggedTask ? t('calendar.drop') : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -60,16 +60,18 @@ export function AuditLogsTable() {
|
||||
{logs?.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{format(new Date(log.createdAt), "MMM d, HH:mm:ss")}
|
||||
{new Date(log.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.source === 'AI' ? 'secondary' : 'outline'}>
|
||||
{log.source}
|
||||
{t(`audit.source.${log.source}`, { defaultValue: log.source })}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{log.action}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{t(`audit.action.${log.action}`, { defaultValue: log.action })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.entityType}
|
||||
{t(`audit.entity.${log.entityType}`, { defaultValue: log.entityType })}
|
||||
{log.entityId && <span className="text-xs text-muted-foreground block truncate max-w-[100px]">{log.entityId}</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground w-1/3">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function DataExportCard({ user }: DataExportCardProps) {
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
const response = await apiRequest("POST", "/api/user/export", {
|
||||
const response = await apiRequest("POST", "/api/user/data-export", {
|
||||
includeTasks,
|
||||
includeLabels,
|
||||
includeSettings
|
||||
@@ -106,6 +106,7 @@ export function DataExportCard({ user }: DataExportCardProps) {
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || (!includeTasks && !includeLabels && !includeSettings)}
|
||||
data-testid="button-export-data"
|
||||
>
|
||||
{isExporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{t('settings.export.button')}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"noMatchingTasks": "Keine Aufgaben entsprechen deinen Filtern",
|
||||
"noUnscheduledTasks": "Keine ungeplanten Aufgaben. Ziehe Aufgaben in den Kalender unten, um sie zu planen!",
|
||||
"unscheduledTasksLabel": "Ungeplante Aufgaben - In den Kalender ziehen zum Planen",
|
||||
"listHeader": "Aufgabenliste",
|
||||
"calendarTitle": "Nächste {{count}} Tage",
|
||||
"filter": {
|
||||
"all": "Alle",
|
||||
@@ -73,7 +74,9 @@
|
||||
"todo": "Zu erledigen",
|
||||
"inProgress": "In Bearbeitung",
|
||||
"done": "Erledigt",
|
||||
"overdue": "Überfällig"
|
||||
"overdue": "Überfällig",
|
||||
"planned": "Geplant",
|
||||
"unplanned": "Ungeplant"
|
||||
},
|
||||
"sort": {
|
||||
"dueDate": "Fälligkeitsdatum",
|
||||
@@ -196,6 +199,7 @@
|
||||
"quickDelete": "Schnell löschen",
|
||||
"toggleComplete": "Aufgabe als erledigt/unerledigt markieren",
|
||||
"running": "läuft",
|
||||
"blockedBy": "Blockiert durch: {{tasks}}",
|
||||
"overdue": "Überfällig",
|
||||
"timeTracked": "{{hours}}Std {{minutes}}Min erfasst"
|
||||
},
|
||||
@@ -205,6 +209,9 @@
|
||||
"weekend": "Wochenende",
|
||||
"previousWeek": "Vorherige Woche",
|
||||
"moreItems": "+{{count}} weitere",
|
||||
"clickToEdit": "Klicken zum Bearbeiten",
|
||||
"dropHere": "✓ Hier ablegen",
|
||||
"drop": "Ablegen",
|
||||
"tasksDue": "{{count}} Aufgabe fällig",
|
||||
"tasksDue_plural": "{{count}} Aufgaben fällig"
|
||||
},
|
||||
@@ -305,6 +312,38 @@
|
||||
"saving": "Speichere...",
|
||||
"saveSuccess": "Einstellungen erfolgreich gespeichert"
|
||||
},
|
||||
"audit": {
|
||||
"action": {
|
||||
"CREATE": "Erstellt",
|
||||
"UPDATE": "Aktualisiert",
|
||||
"DELETE": "Gelöscht",
|
||||
"LOGIN": "Angemeldet",
|
||||
"LOGOUT": "Abmeldung",
|
||||
"Generate API Key": "API-Schlüssel generieren",
|
||||
"Revoke API Key": "API-Schlüssel widerrufen",
|
||||
"EXPORT_DATA": "Datenexport",
|
||||
"PURCHASE": "Kauf",
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"entity": {
|
||||
"USER": "Benutzer",
|
||||
"TASK": "Aufgabe",
|
||||
"LABEL": "Label",
|
||||
"SYSTEM_SETTINGS": "Systemeinst.",
|
||||
"AI_CONFIG": "KI-Konfig",
|
||||
"MCP": "MCP",
|
||||
"CONVERSATION": "Konversation",
|
||||
"REWARD": "Belohnung",
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"source": {
|
||||
"USER": "Benutzer",
|
||||
"ADMIN": "Admin",
|
||||
"AI": "KI",
|
||||
"SYSTEM": "System",
|
||||
"WEB": "Web"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"title": "Aufgabenlabels",
|
||||
"description": "Erstellen und verwalten Sie Labels, um Ihre Aufgaben zu organisieren",
|
||||
@@ -398,6 +437,18 @@
|
||||
"morningSubtitle": "Lass uns deinen Tag planen.",
|
||||
"eveningSubtitle": "Zeit zum Reflektieren und Entspannen.",
|
||||
"dayComplete": "Tag abgeschlossen! Gute Arbeit."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Zeitplan-Einstellungen",
|
||||
"description": "Definieren Sie Ihre Verfügbarkeit für die automatische Planung.",
|
||||
"start": "Startzeit",
|
||||
"end": "Endzeit",
|
||||
"days": "Aktive Tage",
|
||||
"saved": "Zeitplan gespeichert",
|
||||
"work": "Arbeitszeitplan",
|
||||
"personal": "Persönlicher Zeitplan",
|
||||
"workDesc": "Aufgaben mit dem Label 'Arbeit' werden in diesen Stunden geplant.",
|
||||
"personalDesc": "Aufgaben mit dem Label 'Persönlich' werden in diesen Stunden geplant. 'Neutrale' Aufgaben können beides nutzen."
|
||||
}
|
||||
},
|
||||
"newChatDefault": "Neuer Chat",
|
||||
@@ -671,6 +722,7 @@
|
||||
"successAccessDesc": "Benutzer kann nun alle deine Aufgaben sehen.",
|
||||
"errorAccess": "Fehler beim Gewähren des Zugriffs",
|
||||
"sharedWith": "Geteilt mit",
|
||||
"sharedWithMe": "Mit mir geteilt",
|
||||
"unshare": "Entfernen",
|
||||
"confirmShareTitle": "Aufgabe teilen",
|
||||
"confirmShareDesc": "Möchtest du diese Aufgabe wirklich mit {{username}} teilen?",
|
||||
@@ -827,5 +879,13 @@
|
||||
"enterCodeSentTo": "Geben Sie den 6-stelligen Code ein, der an ... gesendet wurde",
|
||||
"codeExpiresIn10": "Der Code läuft in 10 Minuten ab",
|
||||
"verify": "Verifizieren"
|
||||
},
|
||||
"planning": {
|
||||
"minutes": "Minuten",
|
||||
"durationPlaceholder": "z.B. 30",
|
||||
"startDate": "Startdatum",
|
||||
"estimatedDuration": "Geschätzte Dauer",
|
||||
"subtasks": "Teilaufgaben",
|
||||
"timeEstimate": "Zeitschätzung"
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,16 @@
|
||||
"noMatchingTasks": "No tasks match your filters",
|
||||
"noUnscheduledTasks": "No unscheduled tasks. Drag tasks to the calendar below to schedule them!",
|
||||
"unscheduledTasksLabel": "Unscheduled Tasks - Drag to calendar below to schedule",
|
||||
"listHeader": "Task List",
|
||||
"calendarTitle": "Next {{count}} Days",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"todo": "To Do",
|
||||
"inProgress": "In Progress",
|
||||
"done": "Done",
|
||||
"overdue": "Overdue"
|
||||
"overdue": "Overdue",
|
||||
"planned": "Planned",
|
||||
"unplanned": "Unplanned"
|
||||
},
|
||||
"sort": {
|
||||
"dueDate": "Due Date",
|
||||
@@ -196,6 +199,7 @@
|
||||
"quickDelete": "Quick delete",
|
||||
"toggleComplete": "Mark task as complete/incomplete",
|
||||
"running": "running",
|
||||
"blockedBy": "Blocked by: {{tasks}}",
|
||||
"overdue": "Overdue",
|
||||
"timeTracked": "{{hours}}h {{minutes}}m tracked"
|
||||
},
|
||||
@@ -205,6 +209,9 @@
|
||||
"weekend": "Weekend",
|
||||
"previousWeek": "Previous Week",
|
||||
"moreItems": "+{{count}} more",
|
||||
"clickToEdit": "Click to edit",
|
||||
"dropHere": "✓ Drop here",
|
||||
"drop": "Drop",
|
||||
"tasksDue": "{{count}} task due",
|
||||
"tasksDue_plural": "{{count}} tasks due"
|
||||
},
|
||||
@@ -305,6 +312,38 @@
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Settings saved successfully"
|
||||
},
|
||||
"audit": {
|
||||
"action": {
|
||||
"CREATE": "Created",
|
||||
"UPDATE": "Updated",
|
||||
"DELETE": "Deleted",
|
||||
"LOGIN": "Logged In",
|
||||
"LOGOUT": "Logout",
|
||||
"Generate API Key": "Generate API Key",
|
||||
"Revoke API Key": "Revoke API Key",
|
||||
"EXPORT_DATA": "Data Export",
|
||||
"PURCHASE": "Purchase",
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"entity": {
|
||||
"USER": "User",
|
||||
"TASK": "Task",
|
||||
"LABEL": "Label",
|
||||
"SYSTEM_SETTINGS": "System Settings",
|
||||
"AI_CONFIG": "AI Config",
|
||||
"MCP": "MCP",
|
||||
"CONVERSATION": "Conversation",
|
||||
"REWARD": "Reward",
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"source": {
|
||||
"USER": "User",
|
||||
"ADMIN": "Admin",
|
||||
"AI": "AI",
|
||||
"SYSTEM": "System",
|
||||
"WEB": "Web"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"title": "Task Labels",
|
||||
"description": "Create and manage labels to organize your tasks",
|
||||
@@ -405,6 +444,18 @@
|
||||
"eveningSubtitle": "Time to reflect and unwind.",
|
||||
"dayComplete": "Day Complete! Great job."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule Settings",
|
||||
"description": "Define your availability for smart scheduling.",
|
||||
"start": "Start Time",
|
||||
"end": "End Time",
|
||||
"days": "Active Days",
|
||||
"saved": "Schedule saved",
|
||||
"work": "Work Schedule",
|
||||
"personal": "Personal Schedule",
|
||||
"workDesc": "Tasks with 'Work' labels will be scheduled during these hours.",
|
||||
"personalDesc": "Tasks with 'Personal' labels will be scheduled during these hours. 'Neutral' tasks can use either."
|
||||
},
|
||||
"aiChat": {
|
||||
"title": "AI Assistant",
|
||||
"welcome": "How can I help you manage your tasks today?",
|
||||
@@ -609,6 +660,7 @@
|
||||
"successAccessDesc": "User can now view all your tasks.",
|
||||
"errorAccess": "Failed to grant access",
|
||||
"sharedWith": "Shared with",
|
||||
"sharedWithMe": "Shared with me",
|
||||
"unshare": "Unshare",
|
||||
"confirmShareTitle": "Share Task",
|
||||
"confirmShareDesc": "Are you sure you want to share this task with {{username}}?",
|
||||
@@ -832,5 +884,13 @@
|
||||
"enterCodeSentTo": "Enter the 6-digit code sent to",
|
||||
"codeExpiresIn10": "Code expires in 10 minutes",
|
||||
"verify": "Verify"
|
||||
},
|
||||
"planning": {
|
||||
"minutes": "Minutes",
|
||||
"durationPlaceholder": "e.g. 30",
|
||||
"startDate": "Start Date",
|
||||
"estimatedDuration": "Estimated Duration",
|
||||
"subtasks": "Subtasks",
|
||||
"timeEstimate": "Time Estimate"
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ import { cn } from "@/lib/utils";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
// import ReactMarkdown from "react-markdown";
|
||||
// import remarkGfm from "remark-gfm";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// Types
|
||||
type Conversation = {
|
||||
@@ -63,6 +63,22 @@ type Message = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const preprocessMarkdown = (text: string) => {
|
||||
if (!text) return "";
|
||||
|
||||
// 1. Ensure newlines before headers (###), horizontal rules (---), and list items
|
||||
let processed = text
|
||||
.replace(/([^\n])\n(#{1,6}\s)/g, '$1\n\n$2') // Header
|
||||
.replace(/([^\n])\n(\*{3,}|-{3,}|_{3,})/g, '$1\n\n$2') // HR
|
||||
.replace(/([^\n])\n(- |\* |\d+\. )/g, '$1\n\n$2'); // Lists
|
||||
|
||||
// 2. Ensure code blocks have newlines before/after
|
||||
processed = processed.replace(/([^\n])```/g, '$1\n```');
|
||||
processed = processed.replace(/```([^\n])/g, '```\n$1');
|
||||
|
||||
return processed;
|
||||
};
|
||||
|
||||
const TypewriterMessage = ({ content, onComplete }: { content: string, onComplete?: () => void }) => {
|
||||
const [displayedContent, setDisplayedContent] = useState("");
|
||||
const indexRef = useRef(0);
|
||||
@@ -93,8 +109,8 @@ const TypewriterMessage = ({ content, onComplete }: { content: string, onComplet
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
||||
{/* <ReactMarkdown remarkPlugins={[remarkGfm]}>{displayedContent}</ReactMarkdown> */}
|
||||
{displayedContent}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{preprocessMarkdown(displayedContent)}</ReactMarkdown>
|
||||
{/* {displayedContent} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -618,8 +634,8 @@ export default function AiChatPage() {
|
||||
<TypewriterMessage content={msg.content} onComplete={() => scrollToBottom()} />
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
||||
{/* <ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown> */}
|
||||
{msg.content}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{preprocessMarkdown(msg.content)}</ReactMarkdown>
|
||||
{/* {msg.content} */}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -143,44 +143,6 @@ export default function AuthPage() {
|
||||
// Error handling is done in the form submission handler to set field errors
|
||||
});
|
||||
|
||||
if (is2FARequired) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||
<Card className="w-full max-w-md shadow-xl border-border/50">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="mx-auto mb-4">
|
||||
<img src="/favicon.png" alt="Logo" className="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('auth.2faVerification')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('auth.enterCodeSentTo')} <span className="font-medium text-foreground">{twoFAUserId && twoFAEmail ? twoFAEmail : 'your email'}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerify2FA} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="123456"
|
||||
className="text-center text-2xl tracking-widest"
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">{t('auth.codeExpiresIn10')}</p>
|
||||
</div>
|
||||
<Button className="w-full" type="submit" disabled={verify2FAMutation.isPending || otpCode.length !== 6}>
|
||||
{verify2FAMutation.isPending ? t('common.verifying') : t('auth.verify')}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full" type="button" onClick={() => setIs2FARequired(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen grid lg:grid-cols-2 dark">
|
||||
<div className="hidden lg:flex flex-col justify-center items-center bg-zinc-900 p-12 text-white">
|
||||
@@ -196,68 +158,102 @@ export default function AuthPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-4 bg-background">
|
||||
<Card className="w-full max-w-md shadow-xl border-border/50">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="lg:hidden mx-auto mb-4">
|
||||
<img src="/favicon.png" alt="Logo" className="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('auth.welcomeBack')}</CardTitle>
|
||||
<CardDescription>
|
||||
{settings?.registration_enabled
|
||||
? t('auth.signInDesc')
|
||||
: t('auth.signInDescNoReg')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex w-full mb-6 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab("login")}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "login"
|
||||
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
{settings?.registration_enabled && (
|
||||
{is2FARequired ? (
|
||||
<Card className="w-full max-w-md shadow-xl border-border/50">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="lg:hidden mx-auto mb-4">
|
||||
<img src="/favicon.png" alt="Logo" className="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('auth.2faVerification')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('auth.enterCodeSentTo')} <span className="font-medium text-foreground">{twoFAUserId && twoFAEmail ? twoFAEmail : 'your email'}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerify2FA} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="123456"
|
||||
className="text-center text-2xl tracking-widest"
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">{t('auth.codeExpiresIn10')}</p>
|
||||
</div>
|
||||
<Button className="w-full" type="submit" disabled={verify2FAMutation.isPending || otpCode.length !== 6}>
|
||||
{verify2FAMutation.isPending ? t('common.verifying') : t('auth.verify')}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full" type="button" onClick={() => setIs2FARequired(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="w-full max-w-md shadow-xl border-border/50">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="lg:hidden mx-auto mb-4">
|
||||
<img src="/favicon.png" alt="Logo" className="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('auth.welcomeBack')}</CardTitle>
|
||||
<CardDescription>
|
||||
{settings?.registration_enabled
|
||||
? t('auth.signInDesc')
|
||||
: t('auth.signInDescNoReg')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex w-full mb-6 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab("register")}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "register"
|
||||
onClick={() => setActiveTab("login")}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "login"
|
||||
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t('auth.register')}
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{settings?.registration_enabled && (
|
||||
<button
|
||||
onClick={() => setActiveTab("register")}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "register"
|
||||
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t('auth.register')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === "login" ? (
|
||||
<AuthForm
|
||||
mode="login"
|
||||
onSubmit={(data) => loginMutation.mutate(data)}
|
||||
isLoading={loginMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
settings?.registration_enabled ? (
|
||||
{activeTab === "login" ? (
|
||||
<AuthForm
|
||||
mode="register"
|
||||
onSubmit={(data) => {
|
||||
registerMutation.mutate(data as InsertUser, {
|
||||
onError: (error) => {
|
||||
// Handled in form
|
||||
}
|
||||
})
|
||||
}}
|
||||
isLoading={registerMutation.isPending}
|
||||
registerMutation={registerMutation}
|
||||
mode="login"
|
||||
onSubmit={(data) => loginMutation.mutate(data)}
|
||||
isLoading={loginMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">{t('auth.registrationDisabled')}</div>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
settings?.registration_enabled ? (
|
||||
<AuthForm
|
||||
mode="register"
|
||||
onSubmit={(data) => {
|
||||
registerMutation.mutate(data as InsertUser, {
|
||||
onError: (error) => {
|
||||
// Handled in form
|
||||
}
|
||||
})
|
||||
}}
|
||||
isLoading={registerMutation.isPending}
|
||||
registerMutation={registerMutation}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">{t('auth.registrationDisabled')}</div>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,178 +1,242 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRoute, useLocation } from "wouter";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Task, User } from "@shared/schema";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sun, Moon, ArrowRight, CheckCircle2, ListTodo, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { User, Task } from "@shared/schema";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sun, Moon, CheckCircle2, ArrowRight, ListTodo, Calendar as CalendarIcon, NotebookPen, BrainCircuit } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { format } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import { triggerConfetti } from "@/lib/confetti";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
// Steps Configuration
|
||||
const MORNING_STEPS = [
|
||||
{ key: "review_yesterday", title: "Review Yesterday", description: "Did you complete everything?", icon: ListTodo },
|
||||
{ key: "plan_today", title: "Plan Today", description: "What are your top 3 priorities?", icon: CalendarIcon },
|
||||
{ key: "check_schedule", title: "Visualize Success", description: "Take a moment to visualize your day.", icon: BrainCircuit },
|
||||
];
|
||||
|
||||
const EVENING_STEPS = [
|
||||
{ key: "review_today", title: "Review Today", description: "Celebrate your wins!", icon: CheckCircle2 },
|
||||
{ key: "plan_tomorrow", title: "Plan Tomorrow", description: "Set yourself up for success.", icon: CalendarIcon },
|
||||
{ key: "clear_mind", title: "Clear Mind", description: "Jot down any lingering thoughts.", icon: NotebookPen },
|
||||
];
|
||||
|
||||
export default function FocusRoutinePage() {
|
||||
const [match, params] = useRoute("/focus/routine/:type");
|
||||
const type = params?.type as 'morning' | 'evening';
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const [match, params] = useRoute("/focus/routine/:type");
|
||||
const type = params?.type as "morning" | "evening";
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"] });
|
||||
const { data: tasks = [] } = useQuery<Task[]>({ queryKey: ["/api/tasks"] });
|
||||
|
||||
// Filter tasks
|
||||
const today = new Date();
|
||||
const todayTasks = tasks.filter(t => {
|
||||
if (!t.dueDate) return false;
|
||||
const d = new Date(t.dueDate);
|
||||
return d.getDate() === today.getDate() && d.getMonth() === today.getMonth();
|
||||
});
|
||||
|
||||
const completedToday = todayTasks.filter(t => t.status === 'done');
|
||||
const pendingTasks = tasks.filter(t => t.status !== 'done');
|
||||
|
||||
// Handlers
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
await apiRequest("POST", `/api/user/routine/${type}/complete`);
|
||||
// Invalidate user query to update lastMorningRoutine/lastEveningRoutine
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({ title: t('routine.error', 'Failed to save progress'), variant: 'destructive' });
|
||||
}
|
||||
|
||||
if (type === 'morning') {
|
||||
setLocation('/focus');
|
||||
} else {
|
||||
triggerConfetti(0.5, 0.5);
|
||||
toast({ title: t('routine.dayComplete', "Day Complete! Great job.") });
|
||||
// For evening, maybe logout or home? Or achievements
|
||||
setLocation('/achievements');
|
||||
}
|
||||
};
|
||||
const { toast } = useToast();
|
||||
|
||||
// Prevent hydration mismatch or early render
|
||||
if (!match || !['morning', 'evening'].includes(type)) {
|
||||
return <div className="p-8">Invalid routine type</div>;
|
||||
return <div className="p-8 text-center">Invalid routine type</div>;
|
||||
}
|
||||
|
||||
// Animation variants
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
in: { opacity: 1, y: 0 },
|
||||
out: { opacity: 0, y: -20 }
|
||||
const steps = type === "morning" ? MORNING_STEPS : EVENING_STEPS;
|
||||
const currentStep = steps[stepIndex];
|
||||
|
||||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"] });
|
||||
const { data: tasks } = useQuery<Task[]>({ queryKey: ["/api/tasks"] });
|
||||
|
||||
// Task Context
|
||||
const overdueTasks = tasks?.filter(t => t.status !== 'done' && t.dueDate && new Date(t.dueDate) < new Date()) || [];
|
||||
const todayTasks = tasks?.filter(t => t.status !== 'done' && ((t.dueDate && new Date(t.dueDate) <= new Date()) || !t.dueDate)) || [];
|
||||
const completedToday = tasks?.filter(t => {
|
||||
if (t.status !== 'done') return false;
|
||||
// Check if completed today (approximate based on status update or we need 'completedAt' field which we don't strictly preserve in schema except via AuditLog, but let's assume 'done' tasks are relevant)
|
||||
// Ideally we filter by 'last updated' or audit log, but for now just showing 'Done' tasks is okay as visual reinforcement.
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const completeRoutineMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiRequest("POST", `/api/user/routine/${type}/complete`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
if (type === 'evening') {
|
||||
triggerConfetti(0.5, 0.5);
|
||||
toast({ title: t('routine.dayComplete', "Day Complete! Sleep well.") });
|
||||
setLocation('/achievements'); // Or dashboard
|
||||
} else {
|
||||
toast({ title: t('routine.dayStarted', "Have a great day!") });
|
||||
setLocation('/');
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to complete routine", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
if (stepIndex < steps.length - 1) {
|
||||
setStepIndex(stepIndex + 1);
|
||||
} else {
|
||||
completeRoutineMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen w-full flex flex-col justify-center items-center p-4 transition-colors duration-1000 ${type === 'morning' ? 'bg-orange-50/50 dark:bg-orange-950/20' : 'bg-indigo-50/50 dark:bg-indigo-950/20'}`}>
|
||||
if (!user) return null;
|
||||
|
||||
const Icon = currentStep.icon;
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen w-full flex flex-col justify-center items-center p-6 transition-colors duration-1000 ${type === 'morning' ? 'bg-orange-50/50 dark:bg-orange-950/20' : 'bg-indigo-50/50 dark:bg-indigo-950/20'
|
||||
}`}>
|
||||
<motion.div
|
||||
initial="initial" animate="in" exit="out" variants={pageVariants}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-2xl"
|
||||
>
|
||||
<Card className="border-none shadow-2xl bg-background/80 backdrop-blur-sm">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 w-16 h-16 rounded-full flex items-center justify-center bg-primary/10">
|
||||
{type === 'morning' ? <Sun className="w-8 h-8 text-orange-500" /> : <Moon className="w-8 h-8 text-indigo-500" />}
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold">
|
||||
{type === 'morning' ? t('routine.goodMorning', 'Good Morning') : t('routine.goodEvening', 'Good Evening')}, {user?.username}
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{type === 'morning'
|
||||
? t('routine.morningSubtitle', "Let's plan your day for success.")
|
||||
: t('routine.eveningSubtitle', "Time to reflect and unwind.")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<div className="mb-8 text-center space-y-2">
|
||||
<div className="inline-flex items-center justify-center p-4 rounded-full bg-background shadow-sm mb-4">
|
||||
{type === "morning" ? <Sun className="w-8 h-8 text-orange-500" /> : <Moon className="w-8 h-8 text-indigo-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="pt-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{type === 'morning' ? (
|
||||
<MorningRoutine tasks={pendingTasks} onComplete={handleComplete} />
|
||||
) : (
|
||||
<EveningRoutine completed={completedToday} pending={pendingTasks} onComplete={handleComplete} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={stepIndex}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card className="border-none shadow-2xl bg-background/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Step {stepIndex + 1} of {steps.length}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLocation('/')} className="h-6 text-xs text-muted-foreground">
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${type === 'morning' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-600' : 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600'}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{currentStep.title}</CardTitle>
|
||||
<CardDescription>{currentStep.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-[300px] flex flex-col gap-4">
|
||||
|
||||
{/* DYNAMIC CONTENT BASED ON STEP */}
|
||||
{currentStep.key === "review_yesterday" && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
|
||||
{overdueTasks.length > 0 ? (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-100 dark:border-red-900">
|
||||
<h4 className="font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">
|
||||
<ListTodo className="w-4 h-4" />
|
||||
Overdue Tasks
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{overdueTasks.map(t => (
|
||||
<li key={t.id} className="text-sm flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||||
{t.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center space-y-3">
|
||||
<div className="inline-flex p-3 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600">
|
||||
<CheckCircle2 className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="font-medium">No overdue tasks from yesterday!</p>
|
||||
<p className="text-sm text-muted-foreground">Great job staying on track.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.key === "plan_today" && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
|
||||
<h4 className="font-medium">Your schedule for specific tasks:</h4>
|
||||
{todayTasks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{todayTasks.slice(0, 5).map(task => (
|
||||
<div key={task.id} className="flex items-center gap-3 p-3 border rounded-lg bg-card/50">
|
||||
<div className={`w-3 h-3 rounded-full ${task.priority === 'high' ? 'bg-red-500' : 'bg-blue-500'}`} />
|
||||
<span className="font-medium">{task.title}</span>
|
||||
{task.estimatedDuration && <span className="ml-auto text-xs text-muted-foreground">{task.estimatedDuration}m</span>}
|
||||
</div>
|
||||
))}
|
||||
{todayTasks.length > 5 && <p className="text-center text-xs text-muted-foreground">and {todayTasks.length - 5} more...</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 border-2 border-dashed rounded-lg">
|
||||
<p className="text-muted-foreground">No tasks specifically scheduled for today.</p>
|
||||
<Button variant="link" onClick={() => window.open('/', '_blank')}>Add Tasks</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/10 rounded-lg text-sm text-blue-700 dark:text-blue-300">
|
||||
💡 Tip: Pick just 3 absolute "Must Do" tasks for today.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.key === "review_today" && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-green-500 to-emerald-600 mb-2">
|
||||
{completedToday.length}
|
||||
</div>
|
||||
<p className="text-muted-foreground font-medium">Tasks Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm text-center italic">"Small progress is still progress."</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default / Text Input Steps */}
|
||||
{["clear_mind", "check_schedule", "plan_tomorrow"].includes(currentStep.key) && (
|
||||
<div className="flex-1 flex flex-col justify-center animate-in fade-in slide-in-from-bottom-4">
|
||||
{currentStep.key === "clear_mind" && (
|
||||
<div className="space-y-4">
|
||||
<Input placeholder="Note down any loose thoughts..." className="h-12 text-lg" />
|
||||
<Button variant="outline" className="w-full">Save to Inbox</Button>
|
||||
</div>
|
||||
)}
|
||||
{currentStep.key !== "clear_mind" && (
|
||||
<div className="text-center py-12 text-muted-foreground italic">
|
||||
Take 2 minutes to {currentStep.title.toLowerCase()}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t pt-6">
|
||||
<Button variant="ghost" disabled={stepIndex === 0} onClick={() => setStepIndex(stepIndex - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="gap-2 px-8" size="lg">
|
||||
{stepIndex === steps.length - 1 ? (
|
||||
<>Finish and Start <CheckCircle2 className="w-4 h-4" /></>
|
||||
) : (
|
||||
<>Next Step <ArrowRight className="w-4 h-4" /></>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MorningRoutine({ tasks, onComplete }: { tasks: Task[], onComplete: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<motion.div className="space-y-6">
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<ListTodo className="w-4 h-4" />
|
||||
{t('routine.tasksForToday', 'Tasks for Today')}
|
||||
</h3>
|
||||
<ScrollArea className="h-[300px] pr-4">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{t('routine.noTasks', 'No tasks scheduled yet. Add some!')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map(task => (
|
||||
<div key={task.id} className="flex items-center gap-3 p-3 bg-card border rounded-md">
|
||||
<div className={`w-1 h-8 rounded-full ${getPriorityColor(task.priority)}`} />
|
||||
<span className="flex-1 font-medium">{task.title}</span>
|
||||
{task.estimatedDuration && <span className="text-xs text-muted-foreground">{task.estimatedDuration}m</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button size="lg" onClick={onComplete} className="w-full sm:w-auto">
|
||||
{t('routine.startFocus', 'Start Focus Mode')} <ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function EveningRoutine({ completed, pending, onComplete }: { completed: Task[], pending: Task[], onComplete: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<motion.div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-100 dark:border-green-900 text-center">
|
||||
<div className="text-3xl font-bold text-green-600 mb-1">{completed.length}</div>
|
||||
<div className="text-sm text-green-700 dark:text-green-400">{t('routine.completed', 'Completed')}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-100 dark:border-orange-900 text-center">
|
||||
<div className="text-3xl font-bold text-orange-600 mb-1">{pending.length}</div>
|
||||
<div className="text-sm text-orange-700 dark:text-orange-400">{t('routine.open', 'Remaining')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button size="lg" onClick={onComplete} className="w-full sm:w-auto">
|
||||
{t('routine.endDay', 'End Day')} <CheckCircle2 className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPriorityColor(priority: string) {
|
||||
if (priority === 'high') return 'bg-red-500';
|
||||
if (priority === 'medium') return 'bg-yellow-500';
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
// End of file
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Task, Label, User } from '@shared/schema';
|
||||
import TaskCard from '@/components/TaskCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { CalendarOff } from 'lucide-react';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface UnscheduledTasksProps {
|
||||
user: User;
|
||||
@@ -15,6 +17,7 @@ interface UnscheduledTasksProps {
|
||||
|
||||
export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelete, onUpdate, onSelect }: UnscheduledTasksProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
@@ -40,6 +43,36 @@ export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelet
|
||||
// if I want it to be fully functional without duplicating handler logic.
|
||||
// Alternatively, I can implement the handlers here using mutations.
|
||||
|
||||
// Mutation for Auto Schedule
|
||||
const autoScheduleMutation = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
const res = await apiRequest("POST", "/api/ai/schedule", { taskId });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.success && data.scheduledDate) {
|
||||
toast({
|
||||
title: t('schedule.saved', 'Schedule saved'),
|
||||
description: t('taskDetails.scheduledFor', { date: new Date(data.scheduledDate).toLocaleString() })
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
} else {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: data.error || data.message || "Failed to schedule",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: err.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 overflow-y-auto h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -74,6 +107,7 @@ export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelet
|
||||
onDelete={() => onDelete(task.id)}
|
||||
onUpdate={(updates) => onUpdate(task.id, updates)}
|
||||
onEdit={() => onSelect(task)}
|
||||
onAutoSchedule={() => autoScheduleMutation.mutate(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+268
-10
@@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell, Loader2 } from 'lucide-react';
|
||||
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell, Loader2, Clock } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ShareAccessModal } from '@/components/ShareAccessModal';
|
||||
import { ChangePasswordModal } from '@/components/ChangePasswordModal';
|
||||
@@ -18,7 +18,7 @@ import { queryClient, apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLocation } from "wouter";
|
||||
import { useNotifications } from '@/hooks/use-notifications';
|
||||
// import { DataExportCard } from '@/components/user/DataExportCard';
|
||||
import { DataExportCard } from '@/components/user/DataExportCard';
|
||||
|
||||
const NotificationSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,6 +52,137 @@ const NotificationSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ScheduleSettings = ({ user }: { user: User }) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState<'work' | 'personal'>('work');
|
||||
|
||||
// Helper to safely get availability data
|
||||
const getAvailability = (type: 'work' | 'personal') => {
|
||||
// Cast to any because TS might not know about the JSON structure fully yet if types aren't perfectly synced in IDE
|
||||
const avail = user.availability as any;
|
||||
if (avail && avail[type]) {
|
||||
return avail[type];
|
||||
}
|
||||
// Fallback defaults
|
||||
if (type === 'work') return user.workHours || { start: "09:00", end: "17:00", days: [1, 2, 3, 4, 5] };
|
||||
return { start: "18:00", end: "22:00", days: [1, 2, 3, 4, 5, 0, 6] };
|
||||
};
|
||||
|
||||
// State for both schedules
|
||||
const [workSchedule, setWorkSchedule] = useState(getAvailability('work'));
|
||||
const [personalSchedule, setPersonalSchedule] = useState(getAvailability('personal'));
|
||||
|
||||
const currentSchedule = activeTab === 'work' ? workSchedule : personalSchedule;
|
||||
const setCurrentSchedule = (newSched: any) => {
|
||||
if (activeTab === 'work') setWorkSchedule(newSched);
|
||||
else setPersonalSchedule(newSched);
|
||||
};
|
||||
|
||||
const updateScheduleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
availability: {
|
||||
work: workSchedule,
|
||||
personal: personalSchedule
|
||||
}
|
||||
};
|
||||
const res = await apiRequest("PATCH", "/api/user/schedule", payload);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: t('settings.schedule.saved') });
|
||||
}
|
||||
});
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
const currentDays = currentSchedule.days || [];
|
||||
let newDays;
|
||||
if (currentDays.includes(day)) {
|
||||
newDays = currentDays.filter((d: number) => d !== day);
|
||||
} else {
|
||||
newDays = [...currentDays, day].sort();
|
||||
}
|
||||
setCurrentSchedule({ ...currentSchedule, days: newDays });
|
||||
};
|
||||
|
||||
const handleChange = (field: 'start' | 'end', value: string) => {
|
||||
setCurrentSchedule({ ...currentSchedule, [field]: value });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateScheduleMutation.mutate();
|
||||
};
|
||||
|
||||
const days = [
|
||||
{ id: 1, label: t('analytics.mon') },
|
||||
{ id: 2, label: t('analytics.tue') },
|
||||
{ id: 3, label: t('analytics.wed') },
|
||||
{ id: 4, label: t('analytics.thu') },
|
||||
{ id: 5, label: t('analytics.fri') },
|
||||
{ id: 6, label: t('analytics.sat') },
|
||||
{ id: 0, label: t('analytics.sun') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex space-x-4 border-b">
|
||||
<button
|
||||
className={`py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'work' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => setActiveTab('work')}
|
||||
>
|
||||
{t('settings.schedule.work', 'Work Schedule')}
|
||||
</button>
|
||||
<button
|
||||
className={`py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'personal' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => setActiveTab('personal')}
|
||||
>
|
||||
{t('settings.schedule.personal', 'Personal Schedule')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('settings.schedule.start')}</label>
|
||||
<Input type="time" value={currentSchedule.start} onChange={(e) => handleChange('start', e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('settings.schedule.end')}</label>
|
||||
<Input type="time" value={currentSchedule.end} onChange={(e) => handleChange('end', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('settings.schedule.days')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{days.map(day => (
|
||||
<Button
|
||||
key={day.id}
|
||||
variant={currentSchedule.days?.includes(day.id) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => toggleDay(day.id)}
|
||||
className="w-12 h-12 rounded-full p-0"
|
||||
>
|
||||
{day.label.slice(0, 2)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 p-4 rounded-md text-sm text-muted-foreground">
|
||||
{activeTab === 'work'
|
||||
? t('settings.schedule.workDesc', "Tasks with 'Work' labels will be scheduled during these hours.")
|
||||
: t('settings.schedule.personalDesc', "Tasks with 'Personal' labels will be scheduled during these hours. 'Neutral' tasks can use either.")}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={updateScheduleMutation.isPending}>
|
||||
{updateScheduleMutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SettingsProps {
|
||||
onNavigateToTemplates: () => void;
|
||||
}
|
||||
@@ -89,6 +220,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [labelColor, setLabelColor] = useState('#3B82F6');
|
||||
const [labelDomain, setLabelDomain] = useState('neutral');
|
||||
const [isShareAccessOpen, setIsShareAccessOpen] = useState(false);
|
||||
const [isShareLabelOpen, setIsShareLabelOpen] = useState(false);
|
||||
const [sharingLabel, setSharingLabel] = useState<Label | null>(null);
|
||||
@@ -118,6 +250,63 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
privacyMutation.mutate(updates);
|
||||
};
|
||||
|
||||
// 2FA Logic
|
||||
const [is2FADialogOpen, setIs2FADialogOpen] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [debugCode, setDebugCode] = useState<string | null>(null);
|
||||
|
||||
const generate2FAMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/auth/2fa/generate");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setDebugCode(data.debugCode); // For dev convenience
|
||||
setIs2FADialogOpen(true);
|
||||
toast({ title: t('auth.2faCodeSent'), description: t('auth.checkEmail') });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Failed to start 2FA setup", description: err.message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const verify2FAMutation = useMutation({
|
||||
mutationFn: async (code: string) => {
|
||||
const res = await apiRequest("POST", "/api/auth/verify-2fa", { userId: user.id, code });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIs2FADialogOpen(false);
|
||||
setOtpCode("");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "2FA Enabled Successfully" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Verification failed", description: err.message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const disable2FAMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiRequest("POST", "/api/auth/2fa/disable");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "2FA Disabled" });
|
||||
}
|
||||
});
|
||||
|
||||
const handle2FAToggle = (checked: boolean) => {
|
||||
if (checked) {
|
||||
generate2FAMutation.mutate(); // Starts flow, opens dialog on success
|
||||
} else {
|
||||
if (confirm("Are you sure you want to disable 2FA? This will reduce your account security.")) {
|
||||
disable2FAMutation.mutate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Fetch labels
|
||||
const { data: labelsData, isLoading: labelsLoading } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels']
|
||||
@@ -126,13 +315,14 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
|
||||
// Create label mutation
|
||||
const createLabelMutation = useMutation({
|
||||
mutationFn: (data: { name: string; color: string }) =>
|
||||
mutationFn: (data: { name: string; color: string; domain: string }) =>
|
||||
apiRequest('POST', '/api/labels', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
|
||||
setIsLabelDialogOpen(false);
|
||||
setLabelName('');
|
||||
setLabelColor('#3B82F6');
|
||||
setLabelDomain('neutral');
|
||||
toast({
|
||||
title: t('settings.labels.created'),
|
||||
description: t('settings.labels.createdDescription'),
|
||||
@@ -142,7 +332,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
|
||||
// Update label mutation
|
||||
const updateLabelMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string; name: string; color: string }) =>
|
||||
mutationFn: ({ id, ...data }: { id: string; name: string; color: string; domain: string }) =>
|
||||
apiRequest('PATCH', `/api/labels/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
|
||||
@@ -150,6 +340,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
setEditingLabel(null);
|
||||
setLabelName('');
|
||||
setLabelColor('#3B82F6');
|
||||
setLabelDomain('neutral');
|
||||
toast({
|
||||
title: t('settings.labels.updated'),
|
||||
description: t('settings.labels.updatedDescription'),
|
||||
@@ -177,9 +368,10 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
id: editingLabel.id,
|
||||
name: labelName,
|
||||
color: labelColor,
|
||||
domain: labelDomain,
|
||||
});
|
||||
} else {
|
||||
createLabelMutation.mutate({ name: labelName, color: labelColor });
|
||||
createLabelMutation.mutate({ name: labelName, color: labelColor, domain: labelDomain });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,6 +379,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
setEditingLabel(label);
|
||||
setLabelName(label.name);
|
||||
setLabelColor(label.color);
|
||||
setLabelDomain(label.domain || 'neutral');
|
||||
setIsLabelDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -349,9 +542,39 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!user?.is2faEnabled}
|
||||
onCheckedChange={(checked) => handlePrivacyUpdate({ is2faEnabled: checked } as any)}
|
||||
onCheckedChange={(checked) => handle2FAToggle(checked)}
|
||||
disabled={generate2FAMutation.isPending || disable2FAMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={is2FADialogOpen} onOpenChange={setIs2FADialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('auth.verify2FATitle', 'Verify 2FA')}</DialogTitle>
|
||||
<CardDescription>
|
||||
Enter the code sent to your email to enable 2FA.
|
||||
{debugCode && <div className="mt-2 p-2 bg-muted rounded text-xs font-mono">Debug Code: {debugCode}</div>}
|
||||
</CardDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Input
|
||||
placeholder="123456"
|
||||
className="text-center text-2xl tracking-widest"
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => verify2FAMutation.mutate(otpCode)}
|
||||
disabled={verify2FAMutation.isPending || otpCode.length !== 6}
|
||||
>
|
||||
{verify2FAMutation.isPending ? "Verifying..." : "Verify & Enable"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button variant="outline" onClick={() => setIsShareAccessOpen(true)}>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
@@ -363,6 +586,22 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
|
||||
<ShareAccessModal open={isShareAccessOpen} onOpenChange={setIsShareAccessOpen} />
|
||||
|
||||
{/* Schedule Settings */}
|
||||
<Card data-testid="card-schedule-settings">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
{t('settings.schedule.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settings.schedule.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user && <ScheduleSettings user={user} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -448,6 +687,19 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Select value={labelDomain} onValueChange={setLabelDomain}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Context (Domain)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="work">Work</SelectItem>
|
||||
<SelectItem value="personal">Personal</SelectItem>
|
||||
<SelectItem value="neutral">Neutral</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">Used for smart scheduling.</p>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -456,6 +708,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
setEditingLabel(null);
|
||||
setLabelName('');
|
||||
setLabelColor('#3B82F6');
|
||||
setLabelDomain('neutral');
|
||||
}}
|
||||
className="flex-1"
|
||||
data-testid="button-cancel-label"
|
||||
@@ -496,9 +749,14 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
|
||||
{label.name}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
|
||||
{label.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{label.domain || 'neutral'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{label.creatorId === user?.id && (
|
||||
@@ -586,7 +844,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
</Card>
|
||||
|
||||
{/* Data Export */}
|
||||
{/* <DataExportCard user={user} /> */}
|
||||
<DataExportCard user={user} />
|
||||
|
||||
{/* Admin Section */}
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user