mirror of
https://github.com/CarGDev/pomodoro.git
synced 2025-09-18 18:58:27 +00:00
feat: upgrade to React 19, add keyboard shortcuts, implement Zustand global state, fix environment variables, and improve UI components
- Upgrade React to v19.1.1 and update all related packages - Add comprehensive keyboard shortcuts (Space, R, S, Ctrl+D) with visual indicators - Implement Zustand global state management for shortcuts and app state - Fix .env file loading with dotenv package and proper Vite configuration - Add text wrapping to all card components to prevent overflow - Improve theme toggle visibility and styling in sidebar - Update button layouts to use flex-direction: row - Add hover effects and consistent styling across components - Fix infinite loop issues in keyboard shortcuts hook - Update Vite config to properly handle .env files and source directories - Add proper TypeScript configuration for React 19 JSX transform
This commit is contained in:
@@ -10,7 +10,5 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
|
||||
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
@@ -7,12 +7,20 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/lib/theme";
|
||||
import { useStore } from "@/lib/store";
|
||||
import { getOrCreateDeviceId } from "@/lib/device";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { ShortcutIndicator } from "@/components/atoms/ShortcutIndicator";
|
||||
|
||||
import Home from "@/pages/Home";
|
||||
import History from "@/pages/History";
|
||||
import Settings from "@/pages/Settings";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
// Separate component for keyboard shortcuts to prevent infinite loops
|
||||
function KeyboardShortcutsProvider() {
|
||||
useKeyboardShortcuts();
|
||||
return null;
|
||||
}
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
@@ -41,7 +49,9 @@ function App() {
|
||||
<ThemeProvider defaultTheme="light" storageKey="pomodorian-theme">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<KeyboardShortcutsProvider />
|
||||
<Router />
|
||||
<ShortcutIndicator />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
@@ -5,9 +5,9 @@ interface ProgressRingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProgressRing({
|
||||
progress,
|
||||
size = 256,
|
||||
export function ProgressRing({
|
||||
progress,
|
||||
size = 256,
|
||||
strokeWidth = 8,
|
||||
className = ""
|
||||
}: ProgressRingProps) {
|
||||
@@ -17,10 +17,10 @@ export function ProgressRing({
|
||||
const strokeDashoffset = circumference * (1 - progress);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`transform -rotate-90 ${className}`}
|
||||
width={size}
|
||||
height={size}
|
||||
<svg
|
||||
className={`transform -rotate-90 ${className}`}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
data-testid="progress-ring"
|
||||
>
|
||||
@@ -34,7 +34,7 @@ export function ProgressRing({
|
||||
fill="none"
|
||||
className="text-muted opacity-20"
|
||||
/>
|
||||
|
||||
|
||||
{/* Progress Circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
|
22
client/src/components/atoms/ShortcutIndicator.tsx
Normal file
22
client/src/components/atoms/ShortcutIndicator.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
|
||||
interface ShortcutIndicatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ShortcutIndicator({ className = "" }: ShortcutIndicatorProps) {
|
||||
// Get shortcuts state from Zustand store
|
||||
const { lastShortcut, isVisible } = useStore((state) => state.shortcuts);
|
||||
|
||||
if (!isVisible || !lastShortcut) return null;
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 bg-primary text-primary-foreground px-3 py-2 rounded-lg shadow-lg flex items-center space-x-2 z-50 animate-in slide-in-from-bottom-2 duration-300 ${className}`}>
|
||||
<Keyboard className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{lastShortcut}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -59,24 +59,24 @@ export function DurationSelector({
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
className={`w-full p-3 h-auto text-left group ${
|
||||
className={`w-full p-3 h-auto text-left group min-h-[80px] ${
|
||||
selectedPreset === preset.name ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
data-testid={`button-preset-${preset.name.toLowerCase()}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<div className={`font-medium ${
|
||||
<div className="flex items-center justify-between w-full h-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium break-words ${
|
||||
selectedPreset === preset.name ? 'text-primary' : 'group-hover:text-primary'
|
||||
} transition-colors`}>
|
||||
{preset.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex-shrink-0 ml-3 ${
|
||||
selectedPreset === preset.name
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground'
|
||||
|
@@ -65,18 +65,18 @@ export function SuggestionChips({ onSuggestionSelect, className = "" }: Suggesti
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="p-4 h-auto text-left group hover:border-primary hover:bg-primary/5 transition-all"
|
||||
className="p-4 h-auto text-left group hover:border-primary hover:bg-primary/5 transition-all min-h-[120px]"
|
||||
onClick={() => onSuggestionSelect(suggestion.minutes)}
|
||||
data-testid={`button-suggestion-${suggestion.minutes}`}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="w-full h-full flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium group-hover:text-primary transition-colors">
|
||||
<span className="font-medium group-hover:text-primary transition-colors break-words">
|
||||
{suggestion.minutes} min {suggestion.minutes >= 30 ? 'Deep Work' : suggestion.minutes <= 15 ? 'Sprint' : 'Focus'}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors flex-shrink-0 ml-2" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
{suggestion.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -10,7 +10,11 @@ interface SidebarProps {
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ className = "", isMobile = false, onNavigate }: SidebarProps) {
|
||||
export function Sidebar({
|
||||
className = "",
|
||||
isMobile = false,
|
||||
onNavigate,
|
||||
}: SidebarProps) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [location] = useLocation();
|
||||
const localSessions = useStore((state) => state.localSessions);
|
||||
@@ -18,23 +22,34 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
// Calculate today's stats
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todaySessions = localSessions.filter(session => {
|
||||
|
||||
const todaySessions = localSessions.filter((session) => {
|
||||
const sessionDate = new Date(session.startedAt);
|
||||
sessionDate.setHours(0, 0, 0, 0);
|
||||
return sessionDate.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
const todayFocusSessions = todaySessions.filter(s => s.type === 'focus');
|
||||
const completedToday = todayFocusSessions.filter(s => s.completed);
|
||||
const completionRate = todayFocusSessions.length > 0
|
||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||
: 0;
|
||||
const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
|
||||
const completedToday = todayFocusSessions.filter((s) => s.completed);
|
||||
const completionRate =
|
||||
todayFocusSessions.length > 0
|
||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||
: 0;
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", icon: Clock, label: "Timer", testId: "nav-timer" },
|
||||
{ path: "/history", icon: BarChart3, label: "History", testId: "nav-history" },
|
||||
{ path: "/settings", icon: Settings, label: "Settings", testId: "nav-settings" },
|
||||
{
|
||||
path: "/history",
|
||||
icon: BarChart3,
|
||||
label: "History",
|
||||
testId: "nav-history",
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
icon: Settings,
|
||||
label: "Settings",
|
||||
testId: "nav-settings",
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (path: string) => {
|
||||
@@ -44,9 +59,11 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`w-64 bg-card border-r border-border flex-shrink-0 sidebar-transition ${
|
||||
isMobile ? 'sidebar-mobile' : ''
|
||||
} ${className}`}>
|
||||
<aside
|
||||
className={`w-64 bg-card border-r border-border flex-shrink-0 sidebar-transition ${
|
||||
isMobile ? "sidebar-mobile" : ""
|
||||
} ${className}`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo/Brand */}
|
||||
<div className="p-6 border-b border-border">
|
||||
@@ -63,15 +80,15 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
|
||||
|
||||
return (
|
||||
<Link key={item.path} href={item.path}>
|
||||
<Button
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={`w-full justify-start space-x-3 ${
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
onClick={onNavigate}
|
||||
data-testid={item.testId}
|
||||
@@ -96,7 +113,10 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Completion Rate</span>
|
||||
<span className="font-medium text-chart-2" data-testid="stat-today-completion">
|
||||
<span
|
||||
className="font-medium text-chart-2"
|
||||
data-testid="stat-today-completion"
|
||||
>
|
||||
{completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -105,24 +125,50 @@ export function Sidebar({ className = "", isMobile = false, onNavigate }: Sideba
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between"
|
||||
className="w-full justify-between hover:bg-[#f1f5f9] focus-visible:bg-[#f1f5f9] focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2 transition-colors group"
|
||||
onClick={toggleTheme}
|
||||
data-testid="button-theme-toggle"
|
||||
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
>
|
||||
<span className="flex items-center space-x-3">
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
{theme === "dark" ? (
|
||||
<Sun className="w-5 h-5 group-hover:text-black transition-colors" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
<Moon className="w-5 h-5 group-hover:text-black transition-colors" />
|
||||
)}
|
||||
<span>{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
|
||||
<span className="group-hover:text-black transition-colors">
|
||||
{theme === "dark" ? "Light Mode" : "Dark Mode"}
|
||||
</span>
|
||||
</span>
|
||||
<div className={`w-10 h-6 rounded-full relative transition-colors ${
|
||||
theme === 'dark' ? 'bg-primary' : 'bg-secondary'
|
||||
}`}>
|
||||
<div className={`w-4 h-4 bg-white rounded-full absolute top-1 transition-transform ${
|
||||
theme === 'dark' ? 'translate-x-5' : 'translate-x-1'
|
||||
}`} />
|
||||
<div
|
||||
className={`w-11 h-6 rounded-full relative transition-all duration-200 ease-in-out ${
|
||||
theme === "dark"
|
||||
? "bg-primary shadow-inner"
|
||||
: "bg-zinc-200 border border-zinc-300"
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
"--track-off-bg": theme === "dark" ? "#3a3a3f" : "#e5e7eb",
|
||||
"--track-on-bg": theme === "dark" ? "#3b82f6" : "#4f46e5",
|
||||
"--thumb-bg": "#ffffff",
|
||||
"--thumb-border":
|
||||
theme === "dark"
|
||||
? "rgba(255,255,255,0.25)"
|
||||
: "rgba(0,0,0,0.15)",
|
||||
"--focus-ring":
|
||||
theme === "dark"
|
||||
? "0 0 0 3px rgba(59,130,246,0.35)"
|
||||
: "0 0 0 3px rgba(79,70,229,0.35)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full absolute top-0.5 transition-transform duration-200 ease-in-out shadow-sm border ${
|
||||
theme === "dark"
|
||||
? "translate-x-6 border-white/25"
|
||||
: "translate-x-0.5 border-black/15"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
className={cn("flex flex-col space-y-1.5 p-6 break-words", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-2xl font-semibold leading-none tracking-tight break-words",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -50,7 +50,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm text-muted-foreground break-words", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
<div ref={ref} className={cn("p-6 pt-0 break-words", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
className={cn("flex items-center p-6 pt-0 break-words", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
142
client/src/hooks/use-keyboard-shortcuts.ts
Normal file
142
client/src/hooks/use-keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
console.log('useKeyboardShortcuts hook called'); // Debug log
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Use refs to avoid dependency issues and prevent re-renders
|
||||
const shortcutsRef = useRef<any>(null);
|
||||
const showShortcutRef = useRef<any>(null);
|
||||
const hideShortcutRef = useRef<any>(null);
|
||||
const timerRef = useRef<any>(null);
|
||||
const setTimerRef = useRef<any>(null);
|
||||
|
||||
// Get store functions once and store in refs to prevent re-renders
|
||||
useEffect(() => {
|
||||
const unsubscribe = useStore.subscribe(
|
||||
(state) => {
|
||||
shortcutsRef.current = state.shortcuts;
|
||||
timerRef.current = state.timer;
|
||||
}
|
||||
);
|
||||
|
||||
// Get initial values
|
||||
const state = useStore.getState();
|
||||
shortcutsRef.current = state.shortcuts;
|
||||
showShortcutRef.current = state.showShortcut;
|
||||
hideShortcutRef.current = state.hideShortcut;
|
||||
timerRef.current = state.timer;
|
||||
setTimerRef.current = state.setTimer;
|
||||
|
||||
return unsubscribe;
|
||||
}, []); // Empty dependency array - only run once
|
||||
|
||||
// Memoize the key handler to prevent recreation
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
// Check if shortcuts are enabled using ref
|
||||
if (!shortcutsRef.current?.shortcutsEnabled) return;
|
||||
|
||||
// Prevent shortcuts from triggering when typing in input fields
|
||||
if (event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimer = timerRef.current;
|
||||
const currentSetTimer = setTimerRef.current;
|
||||
|
||||
if (!currentTimer || !currentSetTimer) return;
|
||||
|
||||
// Space: Start/Pause Timer
|
||||
if (event.code === 'Space' && !event.repeat) {
|
||||
event.preventDefault();
|
||||
if (currentTimer.isRunning) {
|
||||
currentSetTimer({ isRunning: false });
|
||||
showShortcutRef.current('Space');
|
||||
toast({
|
||||
title: "Timer Paused",
|
||||
description: "Press Space to resume",
|
||||
duration: 2000,
|
||||
});
|
||||
} else if (currentTimer.currentTime > 0) {
|
||||
currentSetTimer({ isRunning: true });
|
||||
showShortcutRef.current('Space');
|
||||
toast({
|
||||
title: "Timer Started",
|
||||
description: "Press Space to pause",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// R: Reset Timer
|
||||
if (event.code === 'KeyR' && !event.repeat) {
|
||||
event.preventDefault();
|
||||
currentSetTimer({
|
||||
currentTime: currentTimer.totalTime,
|
||||
isRunning: false,
|
||||
startedAt: undefined,
|
||||
interruptions: 0,
|
||||
});
|
||||
showShortcutRef.current('R');
|
||||
toast({
|
||||
title: "Timer Reset",
|
||||
description: "Timer has been reset to original duration",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
// S: Skip Break (only when on break)
|
||||
if (event.code === 'KeyS' && !event.repeat) {
|
||||
event.preventDefault();
|
||||
if (currentTimer.phase === 'break' && currentTimer.isRunning) {
|
||||
currentSetTimer({
|
||||
isRunning: false,
|
||||
currentTime: 0,
|
||||
});
|
||||
showShortcutRef.current('S');
|
||||
toast({
|
||||
title: "Break Skipped",
|
||||
description: "Break timer has been skipped",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl + D: Toggle Theme (improved detection)
|
||||
if (event.ctrlKey && (event.code === 'KeyD' || event.key === 'd' || event.key === 'D') && !event.repeat) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
showShortcutRef.current('Ctrl + D');
|
||||
toast({
|
||||
title: `Theme Changed`,
|
||||
description: `Switched to ${newTheme} mode`,
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-hide shortcut indicator after 2 seconds
|
||||
setTimeout(() => {
|
||||
hideShortcutRef.current();
|
||||
}, 2000);
|
||||
}, [theme, setTheme, toast]); // Removed store function dependencies
|
||||
|
||||
useEffect(() => {
|
||||
// Add event listener
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}
|
@@ -64,6 +64,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Card text wrapping improvements */
|
||||
.card-content {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Button text wrapping improvements */
|
||||
.button-content {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Table text wrapping improvements */
|
||||
.table-cell {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.timer-ring {
|
||||
transition: stroke-dasharray 0.3s ease-in-out;
|
||||
}
|
||||
@@ -98,3 +121,8 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
div,
|
||||
p {
|
||||
text-wrap: auto !important;
|
||||
}
|
||||
|
@@ -9,15 +9,16 @@ export class AudioManager {
|
||||
|
||||
private async initializeAudioContext() {
|
||||
try {
|
||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
this.audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
this.gainNode = this.audioContext.createGain();
|
||||
this.gainNode.connect(this.audioContext.destination);
|
||||
|
||||
|
||||
// Load default sounds
|
||||
await this.loadSound('chime', '/src/assets/sounds/chime.mp3');
|
||||
await this.loadSound('beep', '/src/assets/sounds/beep.mp3');
|
||||
await this.loadSound("chime", "/src/assets/sounds/chime.mp3");
|
||||
await this.loadSound("beep", "/src/assets/sounds/beep.mp3");
|
||||
} catch (error) {
|
||||
console.warn('Audio context initialization failed:', error);
|
||||
console.warn("Audio context initialization failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,12 +56,12 @@ export class AudioManager {
|
||||
|
||||
async playSound(soundName: string, volume: number = 0.7) {
|
||||
if (!this.audioContext || !this.gainNode) {
|
||||
console.warn('Audio context not available');
|
||||
console.warn("Audio context not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume audio context if it's suspended (required by browser autoplay policies)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
if (this.audioContext.state === "suspended") {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
|
||||
@@ -73,18 +74,21 @@ export class AudioManager {
|
||||
try {
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
|
||||
this.gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
|
||||
source.connect(this.gainNode);
|
||||
source.start();
|
||||
} catch (error) {
|
||||
console.warn('Failed to play sound:', error);
|
||||
console.warn("Failed to play sound:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume: number) {
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.setValueAtTime(Math.max(0, Math.min(1, volume)), this.audioContext?.currentTime || 0);
|
||||
this.gainNode.gain.setValueAtTime(
|
||||
Math.max(0, Math.min(1, volume)),
|
||||
this.audioContext?.currentTime || 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +97,12 @@ export class AudioManager {
|
||||
if (!this.audioContext) return false;
|
||||
|
||||
try {
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
if (this.audioContext.state === "suspended") {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to request audio permission:', error);
|
||||
console.warn("Failed to request audio permission:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@@ -1,22 +1,22 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function generateDeviceId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
export function getOrCreateDeviceId(): string {
|
||||
const stored = localStorage.getItem('pomodorian-device-id');
|
||||
const stored = localStorage.getItem("pomodorian-device-id");
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
|
||||
const newId = generateDeviceId();
|
||||
localStorage.setItem('pomodorian-device-id', newId);
|
||||
localStorage.setItem("pomodorian-device-id", newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
export function regenerateDeviceId(): string {
|
||||
const newId = generateDeviceId();
|
||||
localStorage.setItem('pomodorian-device-id', newId);
|
||||
localStorage.setItem("pomodorian-device-id", newId);
|
||||
return newId;
|
||||
}
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Session } from '@shared/schema';
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { Session } from "@shared/schema";
|
||||
|
||||
export interface TimerState {
|
||||
isRunning: boolean;
|
||||
currentTime: number; // seconds
|
||||
totalTime: number; // seconds
|
||||
phase: 'focus' | 'break';
|
||||
phase: "focus" | "break";
|
||||
sessionCount: number;
|
||||
interruptions: number;
|
||||
startedAt?: Date;
|
||||
@@ -21,29 +21,43 @@ export interface Settings {
|
||||
soundEnabled: boolean;
|
||||
soundVolume: number; // 0-1
|
||||
soundType: string;
|
||||
theme: 'light' | 'dark';
|
||||
theme: "light" | "dark";
|
||||
animationsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ShortcutsState {
|
||||
lastShortcut: string | null;
|
||||
isVisible: boolean;
|
||||
shortcutsEnabled: boolean;
|
||||
}
|
||||
|
||||
interface Store {
|
||||
// Timer state
|
||||
timer: TimerState;
|
||||
|
||||
|
||||
// Settings
|
||||
settings: Settings;
|
||||
|
||||
|
||||
// Local session history
|
||||
localSessions: Session[];
|
||||
|
||||
|
||||
// Device ID
|
||||
deviceId: string;
|
||||
|
||||
|
||||
// Shortcuts state
|
||||
shortcuts: ShortcutsState;
|
||||
|
||||
// Actions
|
||||
setTimer: (timer: Partial<TimerState>) => void;
|
||||
updateSettings: (settings: Partial<Settings>) => void;
|
||||
addLocalSession: (session: Session) => void;
|
||||
clearLocalSessions: () => void;
|
||||
setDeviceId: (deviceId: string) => void;
|
||||
|
||||
// Shortcuts actions
|
||||
showShortcut: (shortcut: string) => void;
|
||||
hideShortcut: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -54,8 +68,8 @@ const defaultSettings: Settings = {
|
||||
autoStartBreaks: true,
|
||||
soundEnabled: true,
|
||||
soundVolume: 0.7,
|
||||
soundType: 'chime',
|
||||
theme: 'light',
|
||||
soundType: "chime",
|
||||
theme: "light",
|
||||
animationsEnabled: true,
|
||||
};
|
||||
|
||||
@@ -63,18 +77,25 @@ const defaultTimer: TimerState = {
|
||||
isRunning: false,
|
||||
currentTime: 25 * 60,
|
||||
totalTime: 25 * 60,
|
||||
phase: 'focus',
|
||||
phase: "focus",
|
||||
sessionCount: 0,
|
||||
interruptions: 0,
|
||||
};
|
||||
|
||||
const defaultShortcuts: ShortcutsState = {
|
||||
lastShortcut: null,
|
||||
isVisible: false,
|
||||
shortcutsEnabled: true,
|
||||
};
|
||||
|
||||
export const useStore = create<Store>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
timer: defaultTimer,
|
||||
settings: defaultSettings,
|
||||
localSessions: [],
|
||||
deviceId: '',
|
||||
deviceId: "",
|
||||
shortcuts: defaultShortcuts,
|
||||
|
||||
setTimer: (timerUpdate) =>
|
||||
set((state) => ({
|
||||
@@ -100,14 +121,40 @@ export const useStore = create<Store>()(
|
||||
set(() => ({
|
||||
deviceId,
|
||||
})),
|
||||
|
||||
showShortcut: (shortcut: string) =>
|
||||
set((state) => ({
|
||||
shortcuts: {
|
||||
...state.shortcuts,
|
||||
lastShortcut: shortcut,
|
||||
isVisible: true,
|
||||
},
|
||||
})),
|
||||
|
||||
hideShortcut: () =>
|
||||
set((state) => ({
|
||||
shortcuts: {
|
||||
...state.shortcuts,
|
||||
isVisible: false,
|
||||
},
|
||||
})),
|
||||
|
||||
toggleShortcuts: () =>
|
||||
set((state) => ({
|
||||
shortcuts: {
|
||||
...state.shortcuts,
|
||||
shortcutsEnabled: !state.shortcuts.shortcutsEnabled,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'pomodorian-storage',
|
||||
name: "pomodorian-storage",
|
||||
partialize: (state) => ({
|
||||
settings: state.settings,
|
||||
localSessions: state.localSessions,
|
||||
deviceId: state.deviceId,
|
||||
shortcuts: state.shortcuts,
|
||||
}),
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@@ -29,7 +29,7 @@ export function ThemeProvider({
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
@@ -2,4 +2,10 @@ import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
const root = createRoot(rootElement);
|
||||
root.render(<App />);
|
||||
|
@@ -2,7 +2,13 @@ import { useState } from "react";
|
||||
import { MainLayout } from "@/components/templates/MainLayout";
|
||||
import { HistoryChart } from "@/components/organisms/HistoryChart";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
import { Download, Clock, CheckCircle, Brain, Flame } from "lucide-react";
|
||||
import { useStore } from "@/lib/store";
|
||||
@@ -14,21 +20,24 @@ export default function History() {
|
||||
const getSessionsInRange = (days: number) => {
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
return localSessions.filter(session =>
|
||||
new Date(session.startedAt) >= startDate
|
||||
return localSessions.filter(
|
||||
(session) => new Date(session.startedAt) >= startDate,
|
||||
);
|
||||
};
|
||||
|
||||
const calculateStats = (sessions: any[]) => {
|
||||
const focusSessions = sessions.filter(s => s.type === 'focus');
|
||||
const completed = focusSessions.filter(s => s.completed);
|
||||
|
||||
const focusSessions = sessions.filter((s) => s.type === "focus");
|
||||
const completed = focusSessions.filter((s) => s.completed);
|
||||
|
||||
return {
|
||||
totalSessions: focusSessions.length,
|
||||
completionRate: focusSessions.length > 0 ? completed.length / focusSessions.length : 0,
|
||||
avgFocusTime: completed.length > 0
|
||||
? completed.reduce((sum, s) => sum + s.intendedMinutes, 0) / completed.length
|
||||
: 0,
|
||||
completionRate:
|
||||
focusSessions.length > 0 ? completed.length / focusSessions.length : 0,
|
||||
avgFocusTime:
|
||||
completed.length > 0
|
||||
? completed.reduce((sum, s) => sum + s.intendedMinutes, 0) /
|
||||
completed.length
|
||||
: 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -40,27 +49,32 @@ export default function History() {
|
||||
const calculateStreak = () => {
|
||||
const today = new Date();
|
||||
let streak = 0;
|
||||
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const date = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
const dayEnd = new Date(date);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const daySessions = localSessions.filter(session => {
|
||||
|
||||
const daySessions = localSessions.filter((session) => {
|
||||
const sessionDate = new Date(session.startedAt);
|
||||
return sessionDate >= date && sessionDate <= dayEnd &&
|
||||
session.type === 'focus' && session.completed;
|
||||
return (
|
||||
sessionDate >= date &&
|
||||
sessionDate <= dayEnd &&
|
||||
session.type === "focus" &&
|
||||
session.completed
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
if (daySessions.length > 0) {
|
||||
streak++;
|
||||
} else if (i > 0) { // Don't break streak on current day if no sessions yet
|
||||
} else if (i > 0) {
|
||||
// Don't break streak on current day if no sessions yet
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return streak;
|
||||
};
|
||||
|
||||
@@ -68,27 +82,36 @@ export default function History() {
|
||||
|
||||
const handleExport = () => {
|
||||
const sessionsToExport = getSessionsInRange(rangeInDays);
|
||||
|
||||
|
||||
// CSV Export
|
||||
const headers = ['Date', 'Type', 'Intended Minutes', 'Actual Seconds', 'Completed', 'Interruptions'];
|
||||
const headers = [
|
||||
"Date",
|
||||
"Type",
|
||||
"Intended Minutes",
|
||||
"Actual Seconds",
|
||||
"Completed",
|
||||
"Interruptions",
|
||||
];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...sessionsToExport.map(session => [
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
session.type,
|
||||
session.intendedMinutes,
|
||||
session.actualSeconds,
|
||||
session.completed,
|
||||
session.interruptions
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
headers.join(","),
|
||||
...sessionsToExport.map((session) =>
|
||||
[
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
session.type,
|
||||
session.intendedMinutes,
|
||||
session.actualSeconds,
|
||||
session.completed,
|
||||
session.interruptions,
|
||||
].join(","),
|
||||
),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `pomodorian-sessions-${timeRange}d.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `pomodorian-sessions-${timeRange}d.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -97,35 +120,37 @@ export default function History() {
|
||||
const formatDuration = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return `Today, ${date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
||||
return `Today, ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return `Yesterday, ${date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
||||
return `Yesterday, ${date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
title="Session History"
|
||||
<MainLayout
|
||||
title="Session History"
|
||||
subtitle="Track your productivity trends and insights"
|
||||
>
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8 fade-in">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Session History</h2>
|
||||
<p className="text-muted-foreground">Track your productivity trends and insights</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-2xl font-bold break-words">Session History</h2>
|
||||
<p className="text-muted-foreground break-words">
|
||||
Track your productivity trends and insights
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-3 flex-shrink-0 ml-4">
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
@@ -152,15 +177,20 @@ export default function History() {
|
||||
<Clock className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Sessions</p>
|
||||
<p className="text-2xl font-bold" data-testid="text-total-sessions">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Sessions
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
data-testid="text-total-sessions"
|
||||
>
|
||||
{stats.totalSessions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
@@ -168,15 +198,20 @@ export default function History() {
|
||||
<CheckCircle className="text-chart-2" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completion Rate</p>
|
||||
<p className="text-2xl font-bold" data-testid="text-completion-rate">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Completion Rate
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
data-testid="text-completion-rate"
|
||||
>
|
||||
{Math.round(stats.completionRate * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
@@ -184,15 +219,20 @@ export default function History() {
|
||||
<Brain className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Avg Focus Time</p>
|
||||
<p className="text-2xl font-bold" data-testid="text-avg-focus">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Avg Focus Time
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
data-testid="text-avg-focus"
|
||||
>
|
||||
{Math.round(stats.avgFocusTime)}m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
@@ -200,8 +240,13 @@ export default function History() {
|
||||
<Flame className="text-chart-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Current Streak</p>
|
||||
<p className="text-2xl font-bold" data-testid="text-current-streak">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current Streak
|
||||
</p>
|
||||
<p
|
||||
className="text-2xl font-bold"
|
||||
data-testid="text-current-streak"
|
||||
>
|
||||
{currentStreak} days
|
||||
</p>
|
||||
</div>
|
||||
@@ -247,25 +292,32 @@ export default function History() {
|
||||
{formatDateTime(session.startedAt.toString())}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
session.type === 'focus'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-accent/10 text-accent'
|
||||
}`}>
|
||||
{session.type === 'focus' ? 'Focus' : 'Break'}
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
session.type === "focus"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-accent/10 text-accent"
|
||||
}`}
|
||||
>
|
||||
{session.type === "focus" ? "Focus" : "Break"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{session.intendedMinutes}:00 / {formatDuration(session.actualSeconds)}
|
||||
{session.intendedMinutes}:00 /{" "}
|
||||
{formatDuration(session.actualSeconds)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
session.completed
|
||||
? 'bg-chart-2/10 text-chart-2'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}>
|
||||
<CheckCircle className={`mr-1 w-3 h-3 ${session.completed ? '' : 'hidden'}`} />
|
||||
{session.completed ? 'Completed' : 'Interrupted'}
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
session.completed
|
||||
? "bg-chart-2/10 text-chart-2"
|
||||
: "bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
<CheckCircle
|
||||
className={`mr-1 w-3 h-3 ${session.completed ? "" : "hidden"}`}
|
||||
/>
|
||||
{session.completed ? "Completed" : "Interrupted"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
@@ -275,7 +327,10 @@ export default function History() {
|
||||
))}
|
||||
{sessionsInRange.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-8 text-center text-muted-foreground">
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
No sessions found for the selected time range.
|
||||
</td>
|
||||
</tr>
|
||||
|
@@ -39,12 +39,12 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleDurationChange = (focus: number, breakDuration: number) => {
|
||||
updateSettings({
|
||||
focusDuration: focus,
|
||||
shortBreakDuration: breakDuration
|
||||
updateSettings({
|
||||
focusDuration: focus,
|
||||
shortBreakDuration: breakDuration,
|
||||
});
|
||||
|
||||
if (timer.phase === 'focus') {
|
||||
|
||||
if (timer.phase === "focus") {
|
||||
const durationSeconds = focus * 60;
|
||||
setTimer({
|
||||
currentTime: durationSeconds,
|
||||
@@ -55,32 +55,37 @@ export default function Home() {
|
||||
|
||||
const handleExportData = () => {
|
||||
const dataStr = JSON.stringify(localSessions, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `pomodorian-sessions-${new Date().toISOString().split('T')[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
const dataUri =
|
||||
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `pomodorian-sessions-${new Date().toISOString().split("T")[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement("a");
|
||||
linkElement.setAttribute("href", dataUri);
|
||||
linkElement.setAttribute("download", exportFileDefaultName);
|
||||
linkElement.click();
|
||||
};
|
||||
|
||||
// Calculate today's progress
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todaySessions = localSessions.filter(session => {
|
||||
|
||||
const todaySessions = localSessions.filter((session) => {
|
||||
const sessionDate = new Date(session.startedAt);
|
||||
sessionDate.setHours(0, 0, 0, 0);
|
||||
return sessionDate.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
const todayFocusSessions = todaySessions.filter(s => s.type === 'focus');
|
||||
const completedToday = todayFocusSessions.filter(s => s.completed);
|
||||
const todayFocusTime = completedToday.reduce((sum, s) => sum + s.intendedMinutes, 0);
|
||||
const completionRate = todayFocusSessions.length > 0
|
||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||
: 0;
|
||||
const todayFocusSessions = todaySessions.filter((s) => s.type === "focus");
|
||||
const completedToday = todayFocusSessions.filter((s) => s.completed);
|
||||
const todayFocusTime = completedToday.reduce(
|
||||
(sum, s) => sum + s.intendedMinutes,
|
||||
0,
|
||||
);
|
||||
const completionRate =
|
||||
todayFocusSessions.length > 0
|
||||
? Math.round((completedToday.length / todayFocusSessions.length) * 100)
|
||||
: 0;
|
||||
|
||||
const formatFocusTime = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
@@ -92,8 +97,8 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
title="Focus Timer"
|
||||
<MainLayout
|
||||
title="Focus Timer"
|
||||
subtitle="Stay focused with the Pomodoro Technique"
|
||||
>
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-8 fade-in">
|
||||
@@ -104,7 +109,57 @@ export default function Home() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Timer Circle */}
|
||||
<div className="lg:col-span-2">
|
||||
{ /* Pomodoro Timer Component */ }
|
||||
<PomodoroTimer />
|
||||
|
||||
{/* Today's Progress */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground break-words">
|
||||
Sessions Completed
|
||||
</span>
|
||||
<span
|
||||
className="font-medium flex-shrink-0 ml-2"
|
||||
data-testid="text-today-completed"
|
||||
>
|
||||
{completedToday.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground break-words">
|
||||
Focus Time
|
||||
</span>
|
||||
<span
|
||||
className="font-medium flex-shrink-0 ml-2"
|
||||
data-testid="text-today-focus-time"
|
||||
>
|
||||
{formatFocusTime(todayFocusTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground break-words">
|
||||
Completion Rate
|
||||
</span>
|
||||
<span
|
||||
className="font-medium text-chart-2 flex-shrink-0 ml-2"
|
||||
data-testid="text-today-completion-rate"
|
||||
>
|
||||
{completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-chart-2 h-2 rounded-full transition-all"
|
||||
style={{ width: `${completionRate}%` }}
|
||||
data-testid="progress-today-completion"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Duration Selector & Settings */}
|
||||
@@ -124,64 +179,36 @@ export default function Home() {
|
||||
<CardContent className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start space-x-3 h-auto p-3 hover:border-accent hover:bg-accent/5"
|
||||
className="w-full justify-start space-x-3 h-auto p-3 hover:border-accent hover:bg-accent/5 min-h-[80px]"
|
||||
data-testid="button-sound-settings"
|
||||
>
|
||||
<Volume2 className="text-accent" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Sound Settings</div>
|
||||
<div className="text-sm text-muted-foreground">Adjust notification sounds</div>
|
||||
<Volume2 className="text-accent flex-shrink-0" />
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="font-medium break-words">
|
||||
Sound Settings
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
Adjust notification sounds
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start space-x-3 h-auto p-3 hover:border-chart-2 hover:bg-chart-2/5"
|
||||
className="w-full justify-start space-x-3 h-auto p-3 hover:border-chart-2 hover:bg-chart-2/5 min-h-[80px]"
|
||||
onClick={handleExportData}
|
||||
data-testid="button-export-data"
|
||||
>
|
||||
<Download className="text-chart-2" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Export Data</div>
|
||||
<div className="text-sm text-muted-foreground">Download session history</div>
|
||||
<Download className="text-chart-2 flex-shrink-0" />
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="font-medium break-words">Export Data</div>
|
||||
<div className="text-sm text-muted-foreground break-words leading-relaxed">
|
||||
Download session history
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Today's Progress */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Sessions Completed</span>
|
||||
<span className="font-medium" data-testid="text-today-completed">
|
||||
{completedToday.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Focus Time</span>
|
||||
<span className="font-medium" data-testid="text-today-focus-time">
|
||||
{formatFocusTime(todayFocusTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Completion Rate</span>
|
||||
<span className="font-medium text-chart-2" data-testid="text-today-completion-rate">
|
||||
{completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-chart-2 h-2 rounded-full transition-all"
|
||||
style={{ width: `${completionRate}%` }}
|
||||
data-testid="progress-today-completion"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -3,24 +3,30 @@ import { MainLayout } from "@/components/templates/MainLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
import {
|
||||
Clock,
|
||||
Volume2,
|
||||
FileVolume,
|
||||
ChevronsUp,
|
||||
Palette,
|
||||
ShieldAlert,
|
||||
Sun,
|
||||
Moon,
|
||||
Download,
|
||||
FileText,
|
||||
import {
|
||||
Clock,
|
||||
Volume2,
|
||||
FileVolume,
|
||||
ChevronsUp,
|
||||
Palette,
|
||||
ShieldAlert,
|
||||
Sun,
|
||||
Moon,
|
||||
Download,
|
||||
FileText,
|
||||
Trash2,
|
||||
Play,
|
||||
Keyboard
|
||||
Keyboard,
|
||||
} from "lucide-react";
|
||||
import { useStore } from "@/lib/store";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
@@ -35,7 +41,7 @@ export default function Settings() {
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
const clearLocalSessions = useStore((state) => state.clearLocalSessions);
|
||||
const setDeviceId = useStore((state) => state.setDeviceId);
|
||||
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -58,45 +64,59 @@ export default function Settings() {
|
||||
sessions: localSessions,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
const dataStr = JSON.stringify(data, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `pomodorian-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
const dataUri =
|
||||
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `pomodorian-export-${new Date().toISOString().split("T")[0]}.json`;
|
||||
|
||||
const linkElement = document.createElement("a");
|
||||
linkElement.setAttribute("href", dataUri);
|
||||
linkElement.setAttribute("download", exportFileDefaultName);
|
||||
linkElement.click();
|
||||
};
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const headers = ['Date', 'Type', 'Intended Minutes', 'Actual Seconds', 'Completed', 'Interruptions'];
|
||||
const headers = [
|
||||
"Date",
|
||||
"Type",
|
||||
"Intended Minutes",
|
||||
"Actual Seconds",
|
||||
"Completed",
|
||||
"Interruptions",
|
||||
];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...localSessions.map(session => [
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
session.type,
|
||||
session.intendedMinutes,
|
||||
session.actualSeconds,
|
||||
session.completed,
|
||||
session.interruptions
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
headers.join(","),
|
||||
...localSessions.map((session) =>
|
||||
[
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
session.type,
|
||||
session.intendedMinutes,
|
||||
session.actualSeconds,
|
||||
session.completed,
|
||||
session.interruptions,
|
||||
].join(","),
|
||||
),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `pomodorian-sessions.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `pomodorian-sessions.csv`);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleClearData = () => {
|
||||
if (confirm('Are you sure you want to clear all data? This action cannot be undone.')) {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to clear all data? This action cannot be undone.",
|
||||
)
|
||||
) {
|
||||
clearLocalSessions();
|
||||
toast({
|
||||
title: "Data cleared",
|
||||
@@ -115,10 +135,7 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
title="Settings"
|
||||
subtitle="Customize your Pomodoro experience"
|
||||
>
|
||||
<MainLayout title="Settings" subtitle="Customize your Pomodoro experience">
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-8 fade-in">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Timer Settings */}
|
||||
@@ -138,42 +155,60 @@ export default function Settings() {
|
||||
value={settings.focusDuration}
|
||||
min="1"
|
||||
max="120"
|
||||
onChange={(e) => updateSettings({ focusDuration: parseInt(e.target.value) || 25 })}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
focusDuration: parseInt(e.target.value) || 25,
|
||||
})
|
||||
}
|
||||
data-testid="input-focus-duration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="short-break-duration">Short Break Duration (minutes)</Label>
|
||||
<Label htmlFor="short-break-duration">
|
||||
Short Break Duration (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="short-break-duration"
|
||||
type="number"
|
||||
value={settings.shortBreakDuration}
|
||||
min="1"
|
||||
max="30"
|
||||
onChange={(e) => updateSettings({ shortBreakDuration: parseInt(e.target.value) || 5 })}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
shortBreakDuration: parseInt(e.target.value) || 5,
|
||||
})
|
||||
}
|
||||
data-testid="input-short-break-duration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="long-break-duration">Long Break Duration (minutes)</Label>
|
||||
<Label htmlFor="long-break-duration">
|
||||
Long Break Duration (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="long-break-duration"
|
||||
type="number"
|
||||
value={settings.longBreakDuration}
|
||||
min="5"
|
||||
max="60"
|
||||
onChange={(e) => updateSettings({ longBreakDuration: parseInt(e.target.value) || 15 })}
|
||||
onChange={(e) =>
|
||||
updateSettings({
|
||||
longBreakDuration: parseInt(e.target.value) || 15,
|
||||
})
|
||||
}
|
||||
data-testid="input-long-break-duration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="long-break-interval">Long Break Interval</Label>
|
||||
<Select
|
||||
value={settings.longBreakInterval.toString()}
|
||||
onValueChange={(value) => updateSettings({ longBreakInterval: parseInt(value) })}
|
||||
<Select
|
||||
value={settings.longBreakInterval.toString()}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ longBreakInterval: parseInt(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="select-long-break-interval">
|
||||
<SelectValue />
|
||||
@@ -185,16 +220,20 @@ export default function Settings() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="auto-start-breaks">Auto-start breaks</Label>
|
||||
<p className="text-sm text-muted-foreground">Automatically start break timer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically start break timer
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-start-breaks"
|
||||
checked={settings.autoStartBreaks}
|
||||
onCheckedChange={(checked) => updateSettings({ autoStartBreaks: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings({ autoStartBreaks: checked })
|
||||
}
|
||||
data-testid="switch-auto-start-breaks"
|
||||
/>
|
||||
</div>
|
||||
@@ -213,21 +252,27 @@ export default function Settings() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="sound-enabled">Enable notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">Play sound when sessions end</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Play sound when sessions end
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sound-enabled"
|
||||
checked={settings.soundEnabled}
|
||||
onCheckedChange={(checked) => updateSettings({ soundEnabled: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings({ soundEnabled: checked })
|
||||
}
|
||||
data-testid="switch-sound-enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sound-type">Notification Sound</Label>
|
||||
<Select
|
||||
value={settings.soundType}
|
||||
onValueChange={(value) => updateSettings({ soundType: value })}
|
||||
<Select
|
||||
value={settings.soundType}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ soundType: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="select-sound-type">
|
||||
<SelectValue />
|
||||
@@ -238,7 +283,7 @@ export default function Settings() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label htmlFor="volume">Volume</Label>
|
||||
<div className="flex items-center space-x-3 mt-2">
|
||||
@@ -256,7 +301,7 @@ export default function Settings() {
|
||||
<ChevronsUp className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
@@ -282,41 +327,81 @@ export default function Settings() {
|
||||
<Label>Theme</Label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
<Button
|
||||
variant={theme === 'light' ? 'default' : 'outline'}
|
||||
className="h-auto p-3 text-left"
|
||||
onClick={() => setTheme('light')}
|
||||
variant={theme === "light" ? "default" : "outline"}
|
||||
className="h-auto p-3 text-left min-h-[80px] hover:bg-[#f1f5f9] transition-colors group"
|
||||
onClick={() => setTheme("light")}
|
||||
data-testid="button-theme-light"
|
||||
>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Sun className="text-primary" />
|
||||
<span className="font-medium">Light</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Sun
|
||||
className={`flex-shrink-0 transition-colors ${
|
||||
theme === "light" ? "text-white" : "text-current"
|
||||
} group-hover:text-black`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={`font-medium break-words transition-colors ${
|
||||
theme === "light" ? "text-white" : "text-current"
|
||||
} group-hover:text-black`}
|
||||
>
|
||||
Light
|
||||
</span>
|
||||
<div
|
||||
className={`text-xs text-muted-foreground break-words leading-relaxed transition-colors ${
|
||||
theme === "light" ? "text-white" : "text-current"
|
||||
} group-hover:text-black`}
|
||||
>
|
||||
Clean and bright
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Clean and bright</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === 'dark' ? 'default' : 'outline'}
|
||||
className="h-auto p-3 text-left"
|
||||
onClick={() => setTheme('dark')}
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
className="h-auto p-3 text-left min-h-[80px] hover:bg-[#f1f5f9] transition-colors group"
|
||||
onClick={() => setTheme("dark")}
|
||||
data-testid="button-theme-dark"
|
||||
>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Moon />
|
||||
<span className="font-medium">Dark</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Moon
|
||||
className={`flex-shrink-0 transition-colors ${
|
||||
theme === "dark" ? "text-white" : "text-current"
|
||||
} group-hover:text-black`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={`font-medium break-words transition-colors ${
|
||||
theme === "dark" ? "text-white" : "text-current"
|
||||
} group-hover:text-black`}
|
||||
>
|
||||
Dark
|
||||
</span>
|
||||
<div
|
||||
className={`text-xs text-muted-foreground break-words leading-relaxed transition-colors ${
|
||||
theme === "dark" ? "text-white" : "text-current"
|
||||
} group-hover:text-black`}
|
||||
>
|
||||
Easy on the eyes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Easy on the eyes</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="animations">Animations</Label>
|
||||
<p className="text-sm text-muted-foreground">Enable UI animations</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable UI animations
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="animations"
|
||||
checked={settings.animationsEnabled}
|
||||
onCheckedChange={(checked) => updateSettings({ animationsEnabled: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings({ animationsEnabled: checked })
|
||||
}
|
||||
data-testid="switch-animations"
|
||||
/>
|
||||
</div>
|
||||
@@ -334,9 +419,14 @@ export default function Settings() {
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<Label>Anonymous Device ID</Label>
|
||||
<p className="text-sm text-muted-foreground mb-2">Used for smart suggestions and analytics</p>
|
||||
<p className="text-sm text-muted-foreground mb-2 break-words">
|
||||
Used for smart suggestions and analytics
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<code className="px-2 py-1 bg-muted rounded text-xs font-mono flex-1 truncate" data-testid="text-device-id">
|
||||
<code
|
||||
className="px-2 py-1 bg-muted rounded text-xs font-mono flex-1 truncate break-all"
|
||||
data-testid="text-device-id"
|
||||
>
|
||||
{deviceId}
|
||||
</code>
|
||||
<Button
|
||||
@@ -344,41 +434,60 @@ export default function Settings() {
|
||||
size="sm"
|
||||
onClick={handleRegenerateDeviceId}
|
||||
data-testid="button-regenerate-device-id"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full bg-chart-2 text-chart-2-foreground hover:bg-chart-2/90"
|
||||
className="w-full bg-chart-2 text-chart-2-foreground hover:bg-chart-2/90 h-auto p-3 text-left"
|
||||
onClick={handleExportJSON}
|
||||
data-testid="button-export-json"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export All Data (JSON)
|
||||
<div className="flex items-center space-x-2">
|
||||
<Download className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium break-words">
|
||||
Export All Data (JSON)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
className="w-full bg-accent text-accent-foreground hover:bg-accent/90 h-auto p-3 text-left"
|
||||
onClick={handleExportCSV}
|
||||
data-testid="button-export-csv"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Export Sessions (CSV)
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium break-words">
|
||||
Export Sessions (CSV)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
className="w-full h-auto p-3 text-left"
|
||||
onClick={handleClearData}
|
||||
data-testid="button-clear-data"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear All Data
|
||||
<div className="flex items-center space-x-2">
|
||||
<Trash2 className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium break-words">
|
||||
Clear All Data
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -394,22 +503,47 @@ export default function Settings() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span>Start/Pause Timer</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">Space</kbd>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="shortcuts-enabled">Enable Keyboard Shortcuts</Label>
|
||||
<p className="text-sm text-muted-foreground">Allow keyboard shortcuts throughout the app</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="shortcuts-enabled"
|
||||
checked={useStore((state) => state.shortcuts.shortcutsEnabled)}
|
||||
onCheckedChange={useStore((state) => state.toggleShortcuts)}
|
||||
data-testid="switch-shortcuts-enabled"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span>Reset Timer</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">R</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span>Skip Break</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">S</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span>Toggle Theme</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono">Ctrl + D</kbd>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These shortcuts are active throughout the app. Try them now!
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="break-words">Start/Pause Timer</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
|
||||
Space
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="break-words">Reset Timer</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
|
||||
R
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="break-words">Skip Break</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
|
||||
S
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="break-words">Toggle Theme</span>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm font-mono flex-shrink-0 ml-2">
|
||||
Ctrl + D
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
@@ -8,7 +8,9 @@ export default function NotFound() {
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
404 Page Not Found
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
|
Reference in New Issue
Block a user