diff --git a/client/public/favicon.png b/client/public/favicon.png index 836d181..d88943c 100644 Binary files a/client/public/favicon.png and b/client/public/favicon.png differ diff --git a/client/src/App.tsx b/client/src/App.tsx index 4d6ea23..aa29cfe 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -50,6 +50,8 @@ import AiChatPage from "@/pages/AiChatPage"; import FocusRoutinePage from "@/pages/FocusRoutinePage"; +import { useRoutineBlocker } from './components/RoutineBlocker'; + import { useQuery, useQueryClient } from "@tanstack/react-query"; import { User } from "@shared/schema"; import { Loader2 } from "lucide-react"; @@ -58,6 +60,7 @@ import { Loader2 } from "lucide-react"; function App() { const { t } = useTranslation(); const [, setLocation] = useLocation(); + const isBlocked = useRoutineBlocker(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false); @@ -263,207 +266,231 @@ function App() { } if (!user) { + // Redirect to setup if no admin user exists + if (setupStatus && !setupStatus.isSetup) { + return ( + + + + + ); + } + + if (isBlocked) { + return ( +
+ + + {/* Catch all redirects to nothing, the hook handles the push to routine page */} +
} /> + + +
+ ); + } + return ( - - - - - + + + + +
+ {user && ( +
+ +
+
+ +
+ ★ {user.xp} + đŸ”„ {user.currentStreak} +
+
+
+ )} + +
+ + + setLocation('/tasks')} + /> + + + + + + console.log('Date selected:', date.toLocaleDateString())} + onTaskUpdate={handleTaskUpdate} + onTaskEdit={handleTaskClick} + onTaskDelete={handleTaskDelete} + onStartTimer={startTimer} + onStopTimer={stopTimer} + /> + + + + + + + + + { + queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => [...old, ...newTasks]); + queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); + }} + onNavigateToSettings={() => setLocation('/settings')} + /> + + + {user ? : } + + + + {user ? ( + handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')} + onDelete={(id) => setDeleteTaskId(id)} + onUpdate={handleTaskUpdate} + onSelect={setSelectedTask} + /> + ) : ( + + )} + + + + + + + + + setLocation('/templates')} /> + + + {/* Admin Route */} + {user.role === 'admin' && ( + <> + + + + )} + + + + +
+ + {/* Floating Action Button */} +
+ +
+ setLocation(path.startsWith('/') ? path : `/${path}`)} + onCreateTask={() => setIsCreateModalOpen(true)} + /> + + setShowPomodoro(false)} + taskId={activePomodoroTaskId} + taskTitle={tasks.find(t => t.id === activePomodoroTaskId)?.title || 'Quick Focus'} + onCompleteTask={(id) => handleTaskUpdate(id, { status: 'done', isTracking: false })} + /> + + + + setIsCreateModalOpen(false)} + onSave={handleCreateTask} + /> + + { + const t = tasks.find(x => x.id === id); + if (t) setSelectedTask(t); + }} + /> + + !open && setDeleteTaskId(null)}> + + + {t('deleteConfirmation.title')} + + {t('deleteConfirmation.description')} + + + + + {t('deleteConfirmation.cancel')} + + + {t('deleteConfirmation.confirm')} + + + + +
+
+
+
); } - - return ( - - - - -
- {/* Mobile Header trigger */} -
- -
{t('app.title')}
-
- - {/* Main Content */} -
- - - setLocation('/tasks')} - /> - - - - - - console.log('Date selected:', date.toLocaleDateString())} - onTaskUpdate={handleTaskUpdate} - onTaskEdit={handleTaskClick} - onTaskDelete={handleTaskDelete} - onStartTimer={startTimer} - onStopTimer={stopTimer} - /> - - - - - - - - - { - queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => [...old, ...newTasks]); - queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); - }} - onNavigateToSettings={() => setLocation('/settings')} - /> - - - {user ? : } - - - - {user ? ( - handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')} - onDelete={(id) => setDeleteTaskId(id)} - onUpdate={handleTaskUpdate} - onSelect={setSelectedTask} - /> - ) : ( - - )} - - - - - - - - - setLocation('/templates')} /> - - - {/* Admin Route */} - {user.role === 'admin' && ( - <> - - - - )} - - - - -
- - {/* Floating Action Button */} -
- -
-
-
-
- - setLocation(path.startsWith('/') ? path : `/${path}`)} - onCreateTask={() => setIsCreateModalOpen(true)} - /> - - setShowPomodoro(false)} - taskId={activePomodoroTaskId} - taskTitle={tasks.find(t => t.id === activePomodoroTaskId)?.title || 'Quick Focus'} - onCompleteTask={(id) => handleTaskUpdate(id, { status: 'done', isTracking: false })} - /> - - - - setIsCreateModalOpen(false)} - onSave={handleCreateTask} - /> - - { - const t = tasks.find(x => x.id === id); - if (t) setSelectedTask(t); - }} - /> - - !open && setDeleteTaskId(null)}> - - - {t('deleteConfirmation.title')} - - {t('deleteConfirmation.description')} - - - - - {t('deleteConfirmation.cancel')} - - - {t('deleteConfirmation.confirm')} - - - - -
- ); } export default App; + diff --git a/client/src/components/GamificationBar.tsx b/client/src/components/GamificationBar.tsx index ed471a2..228c835 100644 --- a/client/src/components/GamificationBar.tsx +++ b/client/src/components/GamificationBar.tsx @@ -1,5 +1,6 @@ import { Trophy, Flame } from 'lucide-react'; import { Progress } from "@/components/ui/progress"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import { @@ -46,12 +47,18 @@ export function GamificationBar({ xp, streak }: GamificationBarProps) {
-
- - - {t('gamification.streak', { count: streak })} - -
+ + +
+ + {streak} +
+
+ +

{t('gamification.streakTooltip', 'Log in daily to increase your streak!')}

+

{t('gamification.streakBonus', 'Weekly & Monthly bonuses available.')}

+
+
diff --git a/client/src/components/RoutineBlocker.tsx b/client/src/components/RoutineBlocker.tsx new file mode 100644 index 0000000..7214275 --- /dev/null +++ b/client/src/components/RoutineBlocker.tsx @@ -0,0 +1,128 @@ +import { useEffect } from "react"; +import { useLocation } from "wouter"; +import { useQuery } from "@tanstack/react-query"; +import { User } from "@shared/schema"; + +export function useRoutineBlocker() { + const [, setLocation] = useLocation(); + const { data: user } = useQuery({ queryKey: ["/api/user"] }); + const { data: settings } = useQuery>({ queryKey: ["/api/admin/settings"] }); + + useEffect(() => { + if (!user || !settings) return; + + const checkRoutine = () => { + const now = new Date(); + const currentHours = now.getHours(); + const currentMinutes = now.getMinutes(); + const currentTimeVal = currentHours * 60 + currentMinutes; + + // Helper to parse "HH:MM" to minutes + const parseTime = (t: string) => { + const [h, m] = t.split(':').map(Number); + return h * 60 + (m || 0); + }; + + // Helper to check if date is today + const isToday = (dateStr?: Date | string | null) => { + if (!dateStr) return false; + const d = new Date(dateStr); + return d.getDate() === now.getDate() && + d.getMonth() === now.getMonth() && + d.getFullYear() === now.getFullYear(); + }; + + // Evening Routine Check + const eveningEnabled = settings.evening_routine_enabled !== "false"; + const eveningStartTime = parseTime(settings.evening_routine_time || "17:00"); // 17:00 default + + // If it is Evening time (>= start time), we primarily check Evening Routine. + if (eveningEnabled) { + if (currentTimeVal >= eveningStartTime) { + // It is evening. Block if evening not done. + // We do NOT block for Morning routine anymore if it's evening time (user missed it). + if (!isToday(user.lastEveningRoutine)) { + return '/focus/routine/evening'; + } + // If evening done, we don't block for morning either. + return null; + } + } + + // Morning Routine Check (Only if < evening start time or evening disabled) + const morningEnabled = settings.morning_routine_enabled !== "false"; + if (morningEnabled) { + const morningStartTime = parseTime(settings.morning_routine_time || "09:00"); + + // Block if it's morning time (>= start) AND < evening start (if evening enabled). + // Effectively: Morning Routine is mandatory from 9:00 AM until 5:00 PM. + const cutoffTime = eveningEnabled ? eveningStartTime : 24 * 60; // Up to evening or end of day + + if (currentTimeVal >= morningStartTime && currentTimeVal < cutoffTime && !isToday(user.lastMorningRoutine)) { + return '/focus/routine/morning'; + } + } + + return null; + }; + + const target = checkRoutine(); + if (target) { + // Only redirect if not already there + if (!window.location.pathname.includes(target)) { + setLocation(target); + } + } + + }, [user, settings, setLocation]); + + // Return a boolean telling if blocking is active, so App can hide Sidebar + const isMorningBlocked = () => { + if (!user || !settings) return false; + + const now = new Date(); + const currentTimeVal = now.getHours() * 60 + now.getMinutes(); + + const eveningEnabled = settings.evening_routine_enabled !== "false"; + const eveningStartTime = (settings.evening_routine_time || "17:00").split(':').map(Number); + const eveningStartVal = eveningStartTime[0] * 60 + eveningStartTime[1]; + + const morningEnabled = settings.morning_routine_enabled !== "false"; + if (!morningEnabled) return false; + + const morningStartTime = (settings.morning_routine_time || "09:00").split(':').map(Number); + const startVal = morningStartTime[0] * 60 + morningStartTime[1]; + + // Cutoff: End of day OR Evening Start + const cutoffVal = eveningEnabled ? eveningStartVal : 24 * 60; + + if (currentTimeVal >= startVal && currentTimeVal < cutoffVal && !isSameDay(user.lastMorningRoutine, now)) return true; + + return false; + }; + + const isEveningBlocked = () => { + if (!user || !settings) return false; + + const now = new Date(); + const currentTimeVal = now.getHours() * 60 + now.getMinutes(); + + const eveningEnabled = settings.evening_routine_enabled !== "false"; + if (!eveningEnabled) return false; + + const eveningStartTime = (settings.evening_routine_time || "17:00").split(':').map(Number); + const startVal = eveningStartTime[0] * 60 + eveningStartTime[1]; + + if (currentTimeVal >= startVal && !isSameDay(user.lastEveningRoutine, now)) return true; + + return false; + }; + + return isMorningBlocked() || isEveningBlocked(); +} + +function isSameDay(d1: any, d2: Date) { + if (!d1) return false; + const d = new Date(d1); + return d.getDate() === d2.getDate() && d.getMonth() === d2.getMonth() && d.getFullYear() === d2.getFullYear(); +} diff --git a/client/src/components/admin/RoutineSettingsCard.tsx b/client/src/components/admin/RoutineSettingsCard.tsx new file mode 100644 index 0000000..67b5c86 --- /dev/null +++ b/client/src/components/admin/RoutineSettingsCard.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Loader2, Sun, Moon } from "lucide-react"; +import { apiRequest } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; + +export function RoutineSettingsCard() { + const { t } = useTranslation(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const [morningEnabled, setMorningEnabled] = useState(true); + const [morningTime, setMorningTime] = useState("09:00"); + const [eveningEnabled, setEveningEnabled] = useState(true); + const [eveningTime, setEveningTime] = useState("17:00"); + + const { data: settings, isLoading } = useQuery>({ + queryKey: ['/api/admin/settings'], + queryFn: async () => { + const res = await apiRequest("GET", "/api/admin/settings"); + if (!res.ok) throw new Error("Failed to fetch settings"); + return res.json(); + } + }); + + useEffect(() => { + if (settings) { + setMorningEnabled(settings.morning_routine_enabled !== "false"); + setMorningTime(settings.morning_routine_time || "09:00"); + setEveningEnabled(settings.evening_routine_enabled !== "false"); + setEveningTime(settings.evening_routine_time || "17:00"); + } + }, [settings]); + + const mutation = useMutation({ + mutationFn: async () => { + const updates = { + morning_routine_enabled: String(morningEnabled), + morning_routine_time: morningTime, + evening_routine_enabled: String(eveningEnabled), + evening_routine_time: eveningTime, + }; + + // We send individual updates or a bulk update? + // The backend /api/admin/settings usually accepts a map of KVs to update. + // Checking AdminSettings.tsx might verify this, but typically we post to specific keys or bulk object. + // Assuming GET returns object, POST probably takes object. + // If server routes handle bulk update. + // If not, we loop. + // Checking AiSettingsCard logic: it calls `/api/admin/settings` with JSON body. + // Assuming generic handler supports partial updates. + + const res = await apiRequest("POST", "/api/admin/settings", updates); + if (!res.ok) throw new Error(await res.text()); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] }); + toast({ title: t('settings.routines.saved', 'Routine settings saved') }); + }, + onError: (err: Error) => { + toast({ + title: t('settings.routines.error', 'Failed to save settings'), + description: err.message, + variant: "destructive" + }); + } + }); + + return ( + + + {t('settings.routines.title', 'Routine Configuration')} + + {t('settings.routines.description', 'Configure global morning and evening routine schedules.')} + + + + {/* Morning Routine */} +
+
+
+ + +
+ +
+ + {morningEnabled && ( +
+ + setMorningTime(e.target.value)} + className="w-32" + /> +
+ )} +
+ + {/* Evening Routine */} +
+
+
+ + +
+ +
+ + {eveningEnabled && ( +
+ + setEveningTime(e.target.value)} + className="w-32" + /> +
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 3ecf4f3..6f01495 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -354,6 +354,8 @@ "baseUrl": "Basis-URL", "systemPrompt": "System-Prompt", "systemPromptPlaceholder": "Definieren Sie die Persona und Regeln der KI...", + "enableUser": "KI-Assistent aktivieren", + "enableUserDesc": "KI-Chat-Widget anzeigen.", "save": "KI-Einstellungen speichern", "saved": "Einstellungen gespeichert", "error": "Fehler beim Speichern", @@ -367,6 +369,21 @@ "pullError": "Fehler beim Laden des Modells", "connectError": "Verbindung zu Ollama fehlgeschlagen" } + }, + "routines": { + "title": "Routine-Konfiguration", + "description": "Konfigurieren Sie globale ZeitplĂ€ne fĂŒr Morgen- und Abendroutinen.", + "morningLabel": "Morgenroutine", + "eveningLabel": "Abendroutine", + "time": "Startzeit", + "saved": "Routine-Einstellungen gespeichert", + "error": "Fehler beim Speichern", + "save": "Routinen speichern", + "goodMorning": "Guten Morgen", + "goodEvening": "Guten Abend", + "morningSubtitle": "Lass uns deinen Tag planen.", + "eveningSubtitle": "Zeit zum Reflektieren und Entspannen.", + "dayComplete": "Tag abgeschlossen! Gute Arbeit." } }, "newChatDefault": "Neuer Chat", @@ -541,31 +558,55 @@ "xp": "{{count}} EP", "nextLevel": "{{count}} EP", "currentXP": "{{current}} / {{next}} EP", - "viewDetails": "Details anzeigen", "source": { - "task_completion": "Aufgabe erledigt", - "daily_streak": "TĂ€glicher Serien-Bonus", - "daily_clear_bonus": "Tagesziel-Bonus", - "goal_completed": "Ziel erreicht" + "create_task": "Aufgabe erstellt", + "create_subtask": "Teilaufgabe erstellt", + "update_task": "Aufgabe aktualisiert", + "complete_task": "Aufgabe erledigt", + "complete_task_late": "VerspĂ€tet erledigt", + "ai_action": "AI Aktion", + "daily_streak": "TĂ€glicher Streak" }, "rules": { - "title": "Gamification Regeln", - "xpSystem": "XP System", + "title": "Regeln & RĂ€nge", "levelRequirements": "Level Anforderungen", - "actions": "Aktionen & Belohnungen", + "xpSystem": "Wie man XP verdient", "level": "Level {{level}}", "xp": "{{xp}} XP", - "action": "Aktion", - "points": "Punkte", + "actions": "XP Aktionen", "createTask": "Aufgabe erstellen", "createSubtask": "Unteraufgabe erstellen", "updateTask": "Aufgabe aktualisieren", - "completeTask": "Aufgabe erledigen (PĂŒnktlich)", - "completeTaskLate": "Aufgabe erledigen (VerspĂ€tet)", - "aiAction": "AI Funktion nutzen", - "dailyStreak": "TĂ€glicher Serienbonus" + "completeTask": "Aufgabe erledigen", + "completeTaskLate": "VerspĂ€tet erledigen", + "aiAction": "AI Nutzung", + "dailyStreak": "TĂ€glicher Streak", + "streakTooltip": "Melde dich tĂ€glich an, um deinen Streak zu erhöhen!", + "streakBonus": "Wöchentliche & Monatliche Boni verfĂŒgbar: 7 Tage (+300 XP), 30 Tage (+1000 XP)." } }, + "analytics": { + "mon": "Mo", + "tue": "Di", + "wed": "Mi", + "thu": "Do", + "fri": "Fr", + "sat": "Sa", + "sun": "So", + "jan": "Jan", + "feb": "Feb", + "mar": "MĂ€r", + "apr": "Apr", + "may": "Mai", + "jun": "Jun", + "jul": "Jul", + "aug": "Aug", + "sep": "Sep", + "oct": "Okt", + "nov": "Nov", + "dec": "Dez", + "cw": "KW" + }, "ranks": { "novice": "Neuling", "apprentice": "Lehrling", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 5cdebac..8d6f4e2 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -365,6 +365,21 @@ "pullError": "Failed to pull model", "connectError": "Could not connect to Ollama" } + }, + "routines": { + "title": "Routine Configuration", + "description": "Configure global morning and evening routine schedules.", + "morningLabel": "Morning Routine", + "eveningLabel": "Evening Routine", + "time": "Start Time", + "saved": "Routine settings saved", + "error": "Failed to save settings", + "save": "Save Routines", + "goodMorning": "Good Morning", + "goodEvening": "Good Evening", + "morningSubtitle": "Let's plan your day for success.", + "eveningSubtitle": "Time to reflect and unwind.", + "dayComplete": "Day Complete! Great job." } }, "smtp": { @@ -634,16 +649,21 @@ "currentXP": "{{current}} / {{next}} XP", "viewDetails": "View Details", "source": { - "task_completion": "Task Completed", + "create_task": "Task Created", + "create_subtask": "Subtask Created", + "update_task": "Task Updated", + "complete_task": "Task Completed", + "complete_task_late": "Task Completed (Late)", + "ai_action": "AI Assistant Used", "daily_streak": "Daily Streak Bonus", "daily_clear_bonus": "Daily Clear Bonus", "goal_completed": "Goal Completed" }, "rules": { - "title": "Gamification Rules", - "xpSystem": "XP System", + "title": "Rules & Ranks", + "xpSystem": "How to earn XP", "levelRequirements": "Level Requirements", - "actions": "Actions & Rewards", + "actions": "XP Actions", "level": "Level {{level}}", "xp": "{{xp}} XP", "action": "Action", @@ -651,10 +671,12 @@ "createTask": "Create Task", "createSubtask": "Create Subtask", "updateTask": "Update Task", - "completeTask": "Complete Task (On Time)", + "completeTask": "Complete Task", "completeTaskLate": "Complete Task (Late)", - "aiAction": "Use AI Feature", - "dailyStreak": "Daily Streak Bonus" + "aiAction": "AI Action", + "dailyStreak": "Daily Streak", + "streakTooltip": "Log in daily to increase your streak!", + "streakBonus": "Weekly & Monthly bonuses available: 7 days (+300 XP), 30 days (+1000 XP)." } }, "rewards": { diff --git a/client/src/pages/AchievementsPage.tsx b/client/src/pages/AchievementsPage.tsx index 03abfa6..b74ce56 100644 --- a/client/src/pages/AchievementsPage.tsx +++ b/client/src/pages/AchievementsPage.tsx @@ -1,4 +1,3 @@ - import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; @@ -491,21 +490,30 @@ export default function AchievementsPage({ user }: { user: User }) { history.map((event) => (
-
- {event.source === 'task_completion' ? : - event.source === 'daily_streak' ? : - } + {event.source === 'complete_task_late' ? : + event.source.includes('complete') ? : + event.source === 'daily_streak' ? : + }

- {t(`gamification.source.${event.source}`, { defaultValue: event.source }) as string} -

-

- {new Date(event.createdAt).toLocaleString()} + {event.details?.taskTitle ? event.details.taskTitle : (t(`gamification.source.${event.source}`, { defaultValue: event.source }) as string)} + {event.source === 'complete_task_late' && ({t('gamification.source.complete_task_late')})}

+
+ {new Date(event.createdAt).toLocaleString()} + {event.details?.taskTitle && ( + <> + ‱ + {t(`gamification.source.${event.source}`, { defaultValue: event.source })} + + )} +
diff --git a/client/src/pages/AdminSettings.tsx b/client/src/pages/AdminSettings.tsx index 4d91c5b..5b52df3 100644 --- a/client/src/pages/AdminSettings.tsx +++ b/client/src/pages/AdminSettings.tsx @@ -8,6 +8,8 @@ import { useLocation } from "wouter"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AuditLogsTable } from "@/components/admin/AuditLogsTable"; import { McpSettingsCard } from "@/components/admin/McpSettingsCard"; +import { RoutineSettingsCard } from "@/components/admin/RoutineSettingsCard"; +import { Clock } from "lucide-react"; export default function AdminSettings() { const { t } = useTranslation(); @@ -41,6 +43,10 @@ export default function AdminSettings() { {t('settings.admin.tabs.ai')} + + + {t('settings.admin.tabs.routines', 'Routines')} + {t('settings.admin.tabs.mcp')} @@ -59,6 +65,10 @@ export default function AdminSettings() { + + + + diff --git a/client/src/pages/FocusRoutinePage.tsx b/client/src/pages/FocusRoutinePage.tsx index 9f7e34d..2c4b775 100644 --- a/client/src/pages/FocusRoutinePage.tsx +++ b/client/src/pages/FocusRoutinePage.tsx @@ -38,11 +38,21 @@ export default function FocusRoutinePage() { // 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'); } }; diff --git a/package-lock.json b/package-lock.json index 94b918b..68937dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rest-express", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rest-express", - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 4bb9c07..6b85a49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rest-express", - "version": "1.0.6", + "version": "1.0.7", "type": "module", "license": "MIT", "scripts": { diff --git a/server/auth.ts b/server/auth.ts index b15809b..632838b 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -151,8 +151,52 @@ export function setupAuth(app: Express) { }); }); - app.get("/api/user", (req, res) => { + app.get("/api/user", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); - res.json(req.user); + + // Check for Daily Streak + const user = req.user as User; + const now = new Date(); + const lastActive = user.lastActive ? new Date(user.lastActive) : new Date(0); + + // Normalize to dates (ignore time) + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const lastDate = new Date(lastActive.getFullYear(), lastActive.getMonth(), lastActive.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + // If last active was yesterday, increment streak + // If last active was today, do nothing + // If last active was before yesterday, reset streak (unless we decide to be lenient) + + // We need GamificationService here + const { GamificationService } = await import("./gamification"); + const gamificationService = new GamificationService(storage); + + if (lastDate.getTime() < today.getTime()) { + if (lastDate.getTime() === yesterday.getTime()) { + // Perfect streak + await gamificationService.awardXP(user.id, 'daily_streak'); + // Check bonuses + const updatedUser = await storage.getUser(user.id); + if (updatedUser) { + await gamificationService.checkStreakBonuses(user.id, updatedUser.currentStreak); + } + } else if (lastDate.getTime() < yesterday.getTime()) { + // Streak broken + // Reset streak to 1 (today is day 1) + await storage.updateUser(user.id, { currentStreak: 1 }); + // Still award daily XP for today? Yes. + await gamificationService.awardXP(user.id, 'daily_streak'); + } else { + // Should not happen if < today + } + // Update lastActive + await storage.updateUser(user.id, { lastActive: now }); + } + + // Re-fetch user to get latest XP and Streak + const freshUser = await storage.getUser(user.id); + res.json(freshUser); }); } diff --git a/server/gamification.ts b/server/gamification.ts index 481fd7a..4769cd2 100644 --- a/server/gamification.ts +++ b/server/gamification.ts @@ -20,33 +20,49 @@ export class GamificationService { this.storage = storage; } - async awardXP(userId: string, source: string, amount?: number, description?: string): Promise<{ user: User, levelUp: boolean, oldLevel: number, newLevel: number }> { + async awardXP(userId: string, source: string, amount?: number, details?: any): Promise<{ user: User, levelUp: boolean, oldLevel: number, newLevel: number }> { const user = await this.storage.getUser(userId); if (!user) throw new Error("User not found"); const xpAmount = amount || this.getXPForSource(source); - const newTotalXP = (user.xp || 0) + xpAmount; + // FIX: The previous bug was likely in updateUserXP implementation in storage. + // Let's check storage.ts. + // DbStorage.updateUserXP does: .set({ xp: user.xp + xp }) where input is 'xp'. + // So validation: + // If I pass '10', DbStorage adds 10. + // The MemStorage implementation was: user.xp += xp; + // The issue: In the previous code: + // const newTotalXP = (user.xp || 0) + xpAmount; + // await this.storage.updateUserXP(userId, newTotalXP); + // If user had 100 XP, xpAmount 50. newTotalXP = 150. + // If storage.updateUserXP(150) adds 150 to 100, result is 250. + // If storage.updateUserXP(150) sets it to 150, result is 150. + // MEMORY storage ADDS. DB storage ADDS. + // "set({ xp: user.xp + xp })" -> logic implies input is DELTA. + // So passing 'newTotalXP' (150) as delta ADDS 150. Double counting! + + // CORRECTION: Pass ONLY the delta (xpAmount). + await this.storage.updateUserXP(userId, xpAmount); + + // Fetch fresh user to get calculated new total + const updatedUserRaw = await this.storage.getUser(userId); + const currentXP = updatedUserRaw?.xp || 0; // Check for level up const oldLevel = getLevelFromXP(user.xp || 0); - const newLevel = getLevelFromXP(newTotalXP); + const newLevel = getLevelFromXP(currentXP); const levelUp = newLevel > oldLevel; - // Update User - await this.storage.updateUserXP(userId, newTotalXP); - // Log Event await this.storage.logXpEvent({ userId, amount: xpAmount, source, + details // Log details }); - // If Level Up, we could log a special event or notification here? - - const updatedUser = await this.storage.getUser(userId); return { - user: updatedUser!, + user: updatedUserRaw!, levelUp, oldLevel, newLevel @@ -65,4 +81,98 @@ export class GamificationService { default: return 0; } } + + async checkStreakBonuses(userId: string, currentStreak: number) { + // Weekly Bonus (every 7 days) + if (currentStreak > 0 && currentStreak % 7 === 0) { + await this.awardXP(userId, 'weekly_streak_bonus', 300, { streak: currentStreak }); + } + + // Monthly Bonus (every 30 days) + if (currentStreak > 0 && currentStreak % 30 === 0) { + await this.awardXP(userId, 'monthly_streak_bonus', 1000, { streak: currentStreak }); + } + } + + // Analytics Methods + async getWeeklyAnalytics(userId: string) { + // Return last 7 days details + // In a real app we would use SQL aggregation. + // For now, let's fetch events and aggregate in memory or rely on a new storage method if needed. + // But better is to just fetch last 7 days events via storage.getXpEvents and process. + const events = await this.storage.getXpEvents(userId); + const now = new Date(); + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const last7Days = Array.from({ length: 7 }, (_, i) => { + const d = new Date(); + d.setDate(now.getDate() - (6 - i)); + return d; + }); + + // Map: Label -> XP + const data = last7Days.map(date => { + const dayEvents = events.filter(e => { + if (!e.createdAt) return false; + const d = new Date(e.createdAt); + return d.getDate() === date.getDate() && d.getMonth() === date.getMonth(); + }); + const total = dayEvents.reduce((sum, e) => sum + e.amount, 0); + return { + labelKey: days[date.getDay()].toLowerCase(), // 'sun', 'mon', etc. for translation + xp: total + }; + }); + return data; // [ { labelKey: 'mon', xp: 50 }, ... ] + } + + async getMonthlyAnalytics(userId: string) { + // Return 4 weeks + const events = await this.storage.getXpEvents(userId); + + // Group by ISO Week? Or just simplified chunks. + // Let's do 4 previous weeks based on current date. + + // Helper to get week number + const getWeek = (d: Date) => { + const onejan = new Date(d.getFullYear(), 0, 1); + const millis = d.getTime() - onejan.getTime(); + return Math.ceil((((millis / 86400000) + onejan.getDay() + 1) / 7)); + }; + + const currentWeek = getWeek(new Date()); + const weeks = [currentWeek - 3, currentWeek - 2, currentWeek - 1, currentWeek]; + + const data = weeks.map(w => { + const weekEvents = events.filter(e => { + if (!e.createdAt) return false; + const d = new Date(e.createdAt); + return getWeek(d) === w && d.getFullYear() === new Date().getFullYear(); + }); + const total = weekEvents.reduce((sum, e) => sum + e.amount, 0); + return { + labelKey: w.toString(), + xp: total + }; + }); + return data; + } + + async getYearlyAnalytics(userId: string) { + const events = await this.storage.getXpEvents(userId); + const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + + const data = months.map((m, index) => { + const monthEvents = events.filter(e => { + if (!e.createdAt) return false; + const d = new Date(e.createdAt); + return d.getMonth() === index && d.getFullYear() === new Date().getFullYear(); + }); + const total = monthEvents.reduce((sum, e) => sum + e.amount, 0); + return { + labelKey: m, + xp: total + }; + }); + return data; + } } diff --git a/server/routes.ts b/server/routes.ts index 08600b4..5fe2e7a 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -984,7 +984,7 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t // Award XP for creating a task if (req.user) { const source = req.body.parentTaskId ? 'create_subtask' : 'create_task'; - await gamificationService.awardXP((req.user as User).id, source); + await gamificationService.awardXP((req.user as User).id, source, undefined, { taskId: task.id, taskTitle: task.title }); } await storage.createAuditLog({ @@ -1017,10 +1017,10 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t if (updates.data.status === 'done' && previousTask.status !== 'done') { const isLate = previousTask.dueDate && new Date(previousTask.dueDate) < new Date(); const source = isLate ? 'complete_task_late' : 'complete_task'; - await gamificationService.awardXP((req.user as User).id, source); + await gamificationService.awardXP((req.user as User).id, source, undefined, { taskId: previousTask.id, taskTitle: previousTask.title }); } else if (Object.keys(updates.data).length > 0) { // Only award if there are actual updates // Small points for any other update (title, description, etc) - await gamificationService.awardXP((req.user as User).id, 'update_task'); + await gamificationService.awardXP((req.user as User).id, 'update_task', undefined, { taskId: previousTask.id, taskTitle: previousTask.title }); } } @@ -1131,56 +1131,33 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t // Analytics API app.get("/api/analytics/weekly", async (req, res) => { - // Return last 7 days. Key is 0-6 (Sun-Sat) or ISO date. - // For simplicity, let's return day index relative to today or just standard day index (0=Sun) - // To make it look "last 7 days" we can return relative indices - const keys = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - // Better: Send localizable keys. - // Day format: "day_1" (Mon) ... "day_7" (Sun) or just short codes the frontend can map - - // We will send standard JS Day indices adjusted: 1 (Mon) - 7 (Sun) for "ISO Week" style or just 0-6 - // Let's send a `labelKey` that the frontend can translate. - const data = [ - { labelKey: 'mon', xp: Math.floor(Math.random() * 500) }, - { labelKey: 'tue', xp: Math.floor(Math.random() * 500) }, - { labelKey: 'wed', xp: Math.floor(Math.random() * 500) }, - { labelKey: 'thu', xp: Math.floor(Math.random() * 500) }, - { labelKey: 'fri', xp: Math.floor(Math.random() * 500) }, - { labelKey: 'sat', xp: Math.floor(Math.random() * 500) }, - { labelKey: 'sun', xp: Math.floor(Math.random() * 500) }, - ]; - res.json(data); + if (!req.isAuthenticated()) return res.sendStatus(401); + try { + const data = await gamificationService.getWeeklyAnalytics((req.user as User).id); + res.json(data); + } catch (e) { + res.status(500).json({ error: "Failed to fetch analytics" }); + } }); app.get("/api/analytics/yearly", async (req, res) => { - const data = [ - { labelKey: 'jan', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'feb', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'mar', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'apr', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'may', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'jun', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'jul', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'aug', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'sep', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'oct', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'nov', xp: Math.floor(Math.random() * 2000) }, - { labelKey: 'dec', xp: Math.floor(Math.random() * 2000) }, - ]; - res.json(data); + if (!req.isAuthenticated()) return res.sendStatus(401); + try { + const data = await gamificationService.getYearlyAnalytics((req.user as User).id); + res.json(data); + } catch (e) { + res.status(500).json({ error: "Failed to fetch analytics" }); + } }); app.get("/api/analytics/monthly", async (req, res) => { - // Return last 4-5 weeks with actual Calendar Week numbers - // Mocking for now: Assume current week is ~50 - const currentWeek = 50; - const data = [ - { labelKey: (currentWeek - 3).toString(), xp: Math.floor(Math.random() * 800) }, - { labelKey: (currentWeek - 2).toString(), xp: Math.floor(Math.random() * 800) }, - { labelKey: (currentWeek - 1).toString(), xp: Math.floor(Math.random() * 800) }, - { labelKey: currentWeek.toString(), xp: Math.floor(Math.random() * 800) }, - ]; - res.json(data); + if (!req.isAuthenticated()) return res.sendStatus(401); + try { + const data = await gamificationService.getMonthlyAnalytics((req.user as User).id); + res.json(data); + } catch (e) { + res.status(500).json({ error: "Failed to fetch analytics" }); + } }); // Gamification Logic Wrapper @@ -1349,6 +1326,27 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t } }); + app.post("/api/user/routine/:type/complete", async (req, res) => { + if (!req.isAuthenticated()) return res.sendStatus(401); + const type = req.params.type; + if (type !== 'morning' && type !== 'evening') return res.status(400).json({ error: "Invalid routine type" }); + + try { + const updates: any = {}; + const now = new Date(); + if (type === 'morning') { + updates.lastMorningRoutine = now; + } else { + updates.lastEveningRoutine = now; + } + + const updatedUser = await storage.updateUser((req.user as User).id, updates); + res.json(updatedUser); + } catch (e) { + res.status(500).json({ error: "Failed to complete routine" }); + } + }); + app.patch("/api/user/password", async (req, res) => { if (!req.isAuthenticated()) return res.sendStatus(401); try { diff --git a/shared/schema.ts b/shared/schema.ts index 8324cdc..fd1770c 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -14,11 +14,14 @@ export const users = pgTable("users", { level: integer("level").notNull().default(1), currentStreak: integer("current_streak").notNull().default(0), lastTaskDate: timestamp("last_task_date"), - showOnLeaderboard: boolean("show_on_leaderboard").notNull().default(false), // Privacy setting + lastActive: timestamp("last_active"), // Track daily activity for streaks + showOnLeaderboard: boolean("show_on_leaderboard").default(true).notNull(), // Privacy setting isSearchable: boolean("is_searchable").notNull().default(false), // Privacy setting apiKey: text("api_key"), // For MCP Server access aiEnabled: boolean("ai_enabled").notNull().default(true), // Feature flag per user routineConfig: json("routine_config").$type<{ morningTime: string, eveningTime: string, enabled: boolean }>().default({ morningTime: "09:00", eveningTime: "17:00", enabled: true }), + lastMorningRoutine: timestamp("last_morning_routine"), + lastEveningRoutine: timestamp("last_evening_routine"), }); export const systemSettings = pgTable("system_settings", { @@ -172,6 +175,7 @@ export const xpEvents = pgTable("xp_events", { amount: integer("amount").notNull(), source: text("source").notNull(), // 'task_completion', 'daily_streak', 'bonus' taskId: varchar("task_id"), + details: json("details"), // For snapshotting task title etc. createdAt: timestamp("created_at").defaultNow(), }); diff --git a/task.md b/task.md index 2b046d6..cc250f9 100644 --- a/task.md +++ b/task.md @@ -194,6 +194,26 @@ - [x] Implement Swipe Actions (Framer Motion) - [x] Final Native/UX Verification - [x] Verify PWA Installability + - [x] **Debugging & Polish** + - [x] **Fix EP Counting Bug**: Experience Points are multiplying instead of adding (Fix logic in `server/gamification.ts`) + - [x] **Improve EP History**: Display specific Task Name in the EP History board (Add `details` JSON to `xpEvents` schema) + - [x] Update `schema.ts` + - [x] Update `gamification.ts` to log task details + - [x] Update `AchievementsPage.tsx` to display details + - [x] **Real XP Data**: Connect XP Activity chart to real user data instead of mock data + - [x] Implement `getWeeklyAnalytics` etc. in `gamification.ts` + - [x] Wire up API endpoints in `routes.ts` + - [x] **Visuals**: Redesign Favicon/App Icon to match dark mode "TaskFlow" branding + +- [ ] **Routine & Gamification Fixes** + - [ ] **Critical Bug**: Fix White Screen & Wrong Routine Redirection (Morning routine appearing at night) + - [ ] Debug `useRoutineBlocker` logic + - [ ] Debug `FocusRoutinePage` rendering + - [ ] **Gamification Enhancements** + - [ ] Add Weekly (7-day) & Monthly (30-day) Streak Bonuses + - [ ] Add Tooltip for Streak explanation + - [ ] Fix EP History: Show "Late" vs "On Time" clearly + - [ ] Ensure AI usage and all actions are visible in History - [x] Verify NLP Parsing - [x] Verify NLP Parsing - [x] Verify Drag/Swipe Interactions