fix(routine): resolove routine blocker logic bug and white screen
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
feat(gamification): add streak bonuses, tooltip, and improved history details fix(ep): resolve double counting ep bug ui: update app icon and translations
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 492 KiB After Width: | Height: | Size: 419 KiB |
+225
-198
@@ -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 (
|
||||
<Switch>
|
||||
<Route path="/setup" component={SetupWizard} />
|
||||
<Route component={SetupWizard} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBlocked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background w-full">
|
||||
<Switch>
|
||||
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
|
||||
{/* Catch all redirects to nothing, the hook handles the push to routine page */}
|
||||
<Route component={() => <div />} />
|
||||
</Switch>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/forgot-password" component={ForgotPasswordPage} />
|
||||
<Route path="/reset-password" component={ResetPasswordPage} />
|
||||
<Route component={AuthPage} />
|
||||
</Switch>
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{user && (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b pl-4 pr-6">
|
||||
<SidebarTrigger />
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => setLocation('/notifications')}>
|
||||
{/* Bell icon would go here */}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-yellow-500 font-bold">★ {user.xp}</span>
|
||||
<span className="text-sm text-orange-500 font-bold">🔥 {user.currentStreak}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 max-w-screen-2xl mx-auto w-full relative">
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<FocusMode
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
onNavigateToTasks={() => setLocation('/tasks')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/tasks">
|
||||
<TasksWithCalendar
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/calendar">
|
||||
<CalendarView
|
||||
tasks={tasks}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onTaskClick={handleTaskClick}
|
||||
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/kanban">
|
||||
<KanbanBoard
|
||||
tasks={tasks}
|
||||
onTaskStatusChange={handleTaskStatusChange}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskClick={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/weeklist">
|
||||
<WeekListView
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/templates">
|
||||
<ProjectTemplate
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateTasks={(newTasks) => {
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => [...old, ...newTasks]);
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
}}
|
||||
onNavigateToSettings={() => setLocation('/settings')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/achievements">
|
||||
{user ? <AchievementsPage user={user} /> : <AuthPage />}
|
||||
</Route>
|
||||
|
||||
<Route path="/unscheduled">
|
||||
{user ? (
|
||||
<UnscheduledTasksPage
|
||||
user={user}
|
||||
onToggleCompletion={(taskId, currentStatus) => handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')}
|
||||
onDelete={(id) => setDeleteTaskId(id)}
|
||||
onUpdate={handleTaskUpdate}
|
||||
onSelect={setSelectedTask}
|
||||
/>
|
||||
) : (
|
||||
<AuthPage />
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/leaderboard">
|
||||
<LeaderboardPage />
|
||||
</Route>
|
||||
<Route path="/ai">
|
||||
<AiChatPage />
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Route */}
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<Route path="/admin/users" component={AdminUserManagement} />
|
||||
<Route path="/admin/settings" component={AdminSettings} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-8 right-8 z-[100]">
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-14 w-14 rounded-full shadow-2xl bg-gradient-to-r from-violet-600 to-indigo-600 hover:scale-110 transition-transform duration-200"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
data-testid="fab-create-task"
|
||||
>
|
||||
<Plus className="h-6 w-6 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
<CommandPalette
|
||||
onNavigate={(path) => setLocation(path.startsWith('/') ? path : `/${path}`)}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
|
||||
<PomodoroOverlay
|
||||
isOpen={showPomodoro}
|
||||
onClose={() => setShowPomodoro(false)}
|
||||
taskId={activePomodoroTaskId}
|
||||
taskTitle={tasks.find(t => t.id === activePomodoroTaskId)?.title || 'Quick Focus'}
|
||||
onCompleteTask={(id) => handleTaskUpdate(id, { status: 'done', isTracking: false })}
|
||||
/>
|
||||
|
||||
<Toaster />
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSave={handleCreateTask}
|
||||
/>
|
||||
|
||||
<TaskDetailsModal
|
||||
isOpen={isTaskDetailsOpen}
|
||||
onClose={handleTaskDetailsClose}
|
||||
task={selectedTask}
|
||||
onSave={handleTaskDetailsSave}
|
||||
labels={labels}
|
||||
onNavigate={(id) => {
|
||||
const t = tasks.find(x => x.id === id);
|
||||
if (t) setSelectedTask(t);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteTaskId !== null} onOpenChange={(open) => !open && setDeleteTaskId(null)}>
|
||||
<AlertDialogContent data-testid="dialog-delete-confirmation">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteConfirmation.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteConfirmation.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-testid="button-cancel-delete">
|
||||
{t('deleteConfirmation.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmTaskDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
data-testid="button-confirm-delete"
|
||||
>
|
||||
{t('deleteConfirmation.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset>
|
||||
<div className="flex flex-col min-h-screen bg-background">
|
||||
{/* Mobile Header trigger */}
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 md:hidden">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="font-semibold">{t('app.title')}</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-4 md:p-6 max-w-screen-2xl mx-auto w-full relative">
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<FocusMode
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
onNavigateToTasks={() => setLocation('/tasks')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/tasks">
|
||||
<TasksWithCalendar
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/calendar">
|
||||
<CalendarView
|
||||
tasks={tasks}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onTaskClick={handleTaskClick}
|
||||
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/kanban">
|
||||
<KanbanBoard
|
||||
tasks={tasks}
|
||||
onTaskStatusChange={handleTaskStatusChange}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskClick={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/weeklist">
|
||||
<WeekListView
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/templates">
|
||||
<ProjectTemplate
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateTasks={(newTasks) => {
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => [...old, ...newTasks]);
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
}}
|
||||
onNavigateToSettings={() => setLocation('/settings')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/achievements">
|
||||
{user ? <AchievementsPage user={user} /> : <AuthPage />}
|
||||
</Route>
|
||||
|
||||
<Route path="/unscheduled">
|
||||
{user ? (
|
||||
<UnscheduledTasksPage
|
||||
user={user}
|
||||
onToggleCompletion={(taskId, currentStatus) => handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')}
|
||||
onDelete={(id) => setDeleteTaskId(id)}
|
||||
onUpdate={handleTaskUpdate}
|
||||
onSelect={setSelectedTask}
|
||||
/>
|
||||
) : (
|
||||
<AuthPage />
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/leaderboard">
|
||||
<LeaderboardPage />
|
||||
</Route>
|
||||
<Route path="/ai">
|
||||
<AiChatPage />
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Route */}
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<Route path="/admin/users" component={AdminUserManagement} />
|
||||
<Route path="/admin/settings" component={AdminSettings} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-8 right-8 z-[100]">
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-14 w-14 rounded-full shadow-2xl bg-gradient-to-r from-violet-600 to-indigo-600 hover:scale-110 transition-transform duration-200"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
data-testid="fab-create-task"
|
||||
>
|
||||
<Plus className="h-6 w-6 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
<CommandPalette
|
||||
onNavigate={(path) => setLocation(path.startsWith('/') ? path : `/${path}`)}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
|
||||
<PomodoroOverlay
|
||||
isOpen={showPomodoro}
|
||||
onClose={() => setShowPomodoro(false)}
|
||||
taskId={activePomodoroTaskId}
|
||||
taskTitle={tasks.find(t => t.id === activePomodoroTaskId)?.title || 'Quick Focus'}
|
||||
onCompleteTask={(id) => handleTaskUpdate(id, { status: 'done', isTracking: false })}
|
||||
/>
|
||||
|
||||
<Toaster />
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSave={handleCreateTask}
|
||||
/>
|
||||
|
||||
<TaskDetailsModal
|
||||
isOpen={isTaskDetailsOpen}
|
||||
onClose={handleTaskDetailsClose}
|
||||
task={selectedTask}
|
||||
onSave={handleTaskDetailsSave}
|
||||
labels={labels}
|
||||
onNavigate={(id) => {
|
||||
const t = tasks.find(x => x.id === id);
|
||||
if (t) setSelectedTask(t);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteTaskId !== null} onOpenChange={(open) => !open && setDeleteTaskId(null)}>
|
||||
<AlertDialogContent data-testid="dialog-delete-confirmation">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteConfirmation.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteConfirmation.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-testid="button-cancel-delete">
|
||||
{t('deleteConfirmation.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmTaskDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
data-testid="button-confirm-delete"
|
||||
>
|
||||
{t('deleteConfirmation.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-orange-600 bg-orange-500/10 px-2 py-1 rounded-full">
|
||||
<Flame className="size-3 fill-orange-600" />
|
||||
<span className="text-xs font-bold">
|
||||
{t('gamification.streak', { count: streak })}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-orange-500/10 rounded-md border border-orange-500/20 cursor-help">
|
||||
<Flame className="w-4 h-4 text-orange-500 fill-orange-500" />
|
||||
<span className="text-sm font-bold text-orange-600 dark:text-orange-400">{streak}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('gamification.streakTooltip', 'Log in daily to increase your streak!')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('gamification.streakBonus', 'Weekly & Monthly bonuses available.')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -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<User>({ queryKey: ["/api/user"] });
|
||||
const { data: settings } = useQuery<Record<string, string>>({ 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();
|
||||
}
|
||||
@@ -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<Record<string, string>>({
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.routines.title', 'Routine Configuration')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settings.routines.description', 'Configure global morning and evening routine schedules.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Morning Routine */}
|
||||
<div className="flex flex-col gap-4 border-b pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-5 w-5 text-orange-500" />
|
||||
<Label htmlFor="morning_enabled" className="text-base font-medium">
|
||||
{t('settings.routines.morningLabel', 'Morning Routine')}
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="morning_enabled"
|
||||
checked={morningEnabled}
|
||||
onCheckedChange={setMorningEnabled}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{morningEnabled && (
|
||||
<div className="pl-7">
|
||||
<Label htmlFor="morning_time" className="mb-2 block text-sm text-muted-foreground">{t('settings.routines.time', 'Start Time')}</Label>
|
||||
<Input
|
||||
id="morning_time"
|
||||
type="time"
|
||||
value={morningTime}
|
||||
onChange={(e) => setMorningTime(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Evening Routine */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5 text-indigo-500" />
|
||||
<Label htmlFor="evening_enabled" className="text-base font-medium">
|
||||
{t('settings.routines.eveningLabel', 'Evening Routine')}
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="evening_enabled"
|
||||
checked={eveningEnabled}
|
||||
onCheckedChange={setEveningEnabled}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{eveningEnabled && (
|
||||
<div className="pl-7">
|
||||
<Label htmlFor="evening_time" className="mb-2 block text-sm text-muted-foreground">{t('settings.routines.time', 'Start Time')}</Label>
|
||||
<Input
|
||||
id="evening_time"
|
||||
type="time"
|
||||
value={eveningTime}
|
||||
onChange={(e) => setEveningTime(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<Button onClick={() => mutation.mutate()} disabled={isLoading || mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('settings.routines.save', 'Save Routines')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) => (
|
||||
<div key={event.id} className="flex items-center justify-between py-3 border-b last:border-0 hover:bg-muted/50 px-2 rounded-md transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-full ${event.source === 'task_completion' ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' :
|
||||
event.source === 'daily_streak' ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400' :
|
||||
'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
<div className={`p-2 rounded-full ${event.source === 'complete_task_late' ? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' :
|
||||
(event.source === 'task_completion' || event.source === 'complete_task') ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' :
|
||||
event.source === 'daily_streak' ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400' :
|
||||
'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}`}>
|
||||
{event.source === 'task_completion' ? <CheckCircle2 className="h-4 w-4" /> :
|
||||
event.source === 'daily_streak' ? <Flame className="h-4 w-4" /> :
|
||||
<Trophy className="h-4 w-4" />}
|
||||
{event.source === 'complete_task_late' ? <Clock className="h-4 w-4" /> :
|
||||
event.source.includes('complete') ? <CheckCircle2 className="h-4 w-4" /> :
|
||||
event.source === 'daily_streak' ? <Flame className="h-4 w-4" /> :
|
||||
<Trophy className="h-4 w-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{t(`gamification.source.${event.source}`, { defaultValue: event.source }) as string}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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' && <span className="ml-2 text-xs text-red-500 font-normal">({t('gamification.source.complete_task_late')})</span>}
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<span>{new Date(event.createdAt).toLocaleString()}</span>
|
||||
{event.details?.taskTitle && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{t(`gamification.source.${event.source}`, { defaultValue: event.source })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-green-600 dark:text-green-400">
|
||||
|
||||
@@ -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() {
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('settings.admin.tabs.ai')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routines" className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t('settings.admin.tabs.routines', 'Routines')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
{t('settings.admin.tabs.mcp')}
|
||||
@@ -59,6 +65,10 @@ export default function AdminSettings() {
|
||||
<AiSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routines">
|
||||
<RoutineSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp">
|
||||
<McpSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rest-express",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
+46
-2
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
+120
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+45
-47
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -194,6 +194,26 @@
|
||||
- [x] Implement Swipe Actions (Framer Motion) <!-- id: 81 -->
|
||||
- [x] Final Native/UX Verification <!-- id: 82 -->
|
||||
- [x] Verify PWA Installability <!-- id: 83 -->
|
||||
- [x] **Debugging & Polish** <!-- id: 5 -->
|
||||
- [x] **Fix EP Counting Bug**: Experience Points are multiplying instead of adding (Fix logic in `server/gamification.ts`) <!-- id: 6 -->
|
||||
- [x] **Improve EP History**: Display specific Task Name in the EP History board (Add `details` JSON to `xpEvents` schema) <!-- id: 7 -->
|
||||
- [x] Update `schema.ts` <!-- id: 8 -->
|
||||
- [x] Update `gamification.ts` to log task details <!-- id: 9 -->
|
||||
- [x] Update `AchievementsPage.tsx` to display details <!-- id: 10 -->
|
||||
- [x] **Real XP Data**: Connect XP Activity chart to real user data instead of mock data <!-- id: 11 -->
|
||||
- [x] Implement `getWeeklyAnalytics` etc. in `gamification.ts` <!-- id: 12 -->
|
||||
- [x] Wire up API endpoints in `routes.ts` <!-- id: 13 -->
|
||||
- [x] **Visuals**: Redesign Favicon/App Icon to match dark mode "TaskFlow" branding <!-- id: 14 -->
|
||||
|
||||
- [ ] **Routine & Gamification Fixes** <!-- id: 17 -->
|
||||
- [ ] **Critical Bug**: Fix White Screen & Wrong Routine Redirection (Morning routine appearing at night) <!-- id: 18 -->
|
||||
- [ ] Debug `useRoutineBlocker` logic <!-- id: 19 -->
|
||||
- [ ] Debug `FocusRoutinePage` rendering <!-- id: 20 -->
|
||||
- [ ] **Gamification Enhancements** <!-- id: 21 -->
|
||||
- [ ] Add Weekly (7-day) & Monthly (30-day) Streak Bonuses <!-- id: 22 -->
|
||||
- [ ] Add Tooltip for Streak explanation <!-- id: 23 -->
|
||||
- [ ] Fix EP History: Show "Late" vs "On Time" clearly <!-- id: 24 -->
|
||||
- [ ] Ensure AI usage and all actions are visible in History <!-- id: 25 -->
|
||||
- [x] Verify NLP Parsing <!-- id: 84 -->
|
||||
- [x] Verify NLP Parsing <!-- id: 84 -->
|
||||
- [x] Verify Drag/Swipe Interactions <!-- id: 85 -->
|
||||
|
||||
Reference in New Issue
Block a user