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:
Carlos Gutierrez
2025-09-01 12:36:27 -04:00
parent 80595e7002
commit 3b577288da
30 changed files with 3324 additions and 2510 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View 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>
);
}

View File

@@ -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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>
))

View 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]);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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,
}),
}
)
},
),
);

View File

@@ -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(() => {

View File

@@ -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));
}

View File

@@ -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 />);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">