From 9a439040a99d232754e61da5fdc004bb7f188e99 Mon Sep 17 00:00:00 2001 From: Adam Jolicoeur Date: Fri, 6 Feb 2026 09:41:17 -0500 Subject: [PATCH 1/3] feat: add ScrollTimePicker component with scroll-wheel UI Create a reusable scroll-wheel time picker component that displays three scrollable columns (hour, minute, AM/PM) for intuitive time selection. Includes snap-to-item behavior, selection highlight band, fade overlays, and 24-hour format conversion. Also adds scrollbar-hide CSS utility for cross-browser hidden scrollbar support. Co-Authored-By: Claude Opus 4.5 --- src/components/ui/scroll-time-picker.tsx | 213 +++++++++++++++++++++++ src/index.css | 8 + 2 files changed, 221 insertions(+) create mode 100644 src/components/ui/scroll-time-picker.tsx diff --git a/src/components/ui/scroll-time-picker.tsx b/src/components/ui/scroll-time-picker.tsx new file mode 100644 index 0000000..13e2b29 --- /dev/null +++ b/src/components/ui/scroll-time-picker.tsx @@ -0,0 +1,213 @@ +import React, { useRef, useEffect, useCallback } from "react"; +import { cn } from "@/lib/util"; + +interface ScrollTimePickerProps { + value: string; // "HH:MM" 24-hour format + onValueChange: (value: string) => void; + disabled?: boolean; + className?: string; +} + +const HOURS = Array.from({ length: 12 }, (_, i) => i + 1); // 1-12 +const MINUTES = [0, 15, 30, 45]; +const PERIODS = ["AM", "PM"] as const; + +const ITEM_HEIGHT = 40; // px per item +const VISIBLE_ITEMS = 5; // show 5 items, center is selected + +function parse24Hour(value: string): { hour12: number; minute: number; period: "AM" | "PM" } { + const [h, m] = value.split(":").map(Number); + const period = h >= 12 ? "PM" : "AM"; + let hour12 = h % 12; + hour12 = hour12 === 0 ? 12 : hour12; + // Round minute to nearest 15 + const minute = Math.round(m / 15) * 15 === 60 ? 0 : Math.round(m / 15) * 15; + return { hour12, minute, period }; +} + +function to24Hour(hour12: number, minute: number, period: "AM" | "PM"): string { + let h = hour12; + if (period === "AM" && h === 12) h = 0; + if (period === "PM" && h !== 12) h += 12; + return `${h.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; +} + +interface WheelColumnProps { + items: (string | number)[]; + selectedIndex: number; + onSelect: (index: number) => void; + disabled?: boolean; + formatItem?: (item: string | number) => string; +} + +const WheelColumn: React.FC = ({ + items, + selectedIndex, + onSelect, + disabled, + formatItem = String, +}) => { + const containerRef = useRef(null); + const isScrollingRef = useRef(false); + const scrollTimeoutRef = useRef>(); + + // Scroll to selected index on mount and when selectedIndex changes externally + useEffect(() => { + if (containerRef.current && !isScrollingRef.current) { + const scrollTop = selectedIndex * ITEM_HEIGHT; + containerRef.current.scrollTo({ top: scrollTop, behavior: "smooth" }); + } + }, [selectedIndex]); + + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + isScrollingRef.current = true; + + if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = setTimeout(() => { + if (!containerRef.current) return; + const scrollTop = containerRef.current.scrollTop; + const index = Math.round(scrollTop / ITEM_HEIGHT); + const clampedIndex = Math.max(0, Math.min(items.length - 1, index)); + + // Snap to position + containerRef.current.scrollTo({ + top: clampedIndex * ITEM_HEIGHT, + behavior: "smooth", + }); + + isScrollingRef.current = false; + if (clampedIndex !== selectedIndex) { + onSelect(clampedIndex); + } + }, 100); + }, [items.length, selectedIndex, onSelect]); + + const handleItemClick = (index: number) => { + if (disabled) return; + if (containerRef.current) { + containerRef.current.scrollTo({ + top: index * ITEM_HEIGHT, + behavior: "smooth", + }); + } + onSelect(index); + }; + + const paddingHeight = Math.floor(VISIBLE_ITEMS / 2) * ITEM_HEIGHT; + + return ( +
+ {/* Selection highlight band */} +
+ {/* Fade overlays */} +
+
+
+ {/* Top padding */} +
+ {items.map((item, index) => ( +
handleItemClick(index)} + > + {formatItem(item)} +
+ ))} + {/* Bottom padding */} +
+
+
+ ); +}; + +export const ScrollTimePicker: React.FC = ({ + value, + onValueChange, + disabled = false, + className, +}) => { + const { hour12, minute, period } = parse24Hour(value || "09:00"); + + const hourIndex = HOURS.indexOf(hour12); + const minuteIndex = MINUTES.indexOf(minute); + const periodIndex = PERIODS.indexOf(period); + + const handleHourChange = (index: number) => { + onValueChange(to24Hour(HOURS[index], minute, period)); + }; + + const handleMinuteChange = (index: number) => { + onValueChange(to24Hour(hour12, MINUTES[index], period)); + }; + + const handlePeriodChange = (index: number) => { + onValueChange(to24Hour(hour12, minute, PERIODS[index])); + }; + + return ( +
+ = 0 ? hourIndex : 0} + onSelect={handleHourChange} + disabled={disabled} + /> +
:
+ = 0 ? minuteIndex : 0} + onSelect={handleMinuteChange} + disabled={disabled} + formatItem={(item) => String(item).padStart(2, "0")} + /> + = 0 ? periodIndex : 0} + onSelect={handlePeriodChange} + disabled={disabled} + /> +
+ ); +}; diff --git a/src/index.css b/src/index.css index cc4d17d..dabcb4b 100644 --- a/src/index.css +++ b/src/index.css @@ -106,3 +106,11 @@ border-width: 0 !important; } } + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} From 578ffb7c4966a2684269c7ad36744d9d4e5f8e1d Mon Sep 17 00:00:00 2001 From: Adam Jolicoeur Date: Fri, 6 Feb 2026 10:39:54 -0500 Subject: [PATCH 2/3] feat: round start day time to nearest 15 minutes - Add 15-minute interval rounding to startDay function - Ensures first task aligns with rounded start time - Fix react-refresh linting warning in OfflineContext --- src/contexts/OfflineContext.tsx | 315 ++++++++++++++------------- src/contexts/TimeTrackingContext.tsx | 304 ++++++++++++++++---------- 2 files changed, 352 insertions(+), 267 deletions(-) diff --git a/src/contexts/OfflineContext.tsx b/src/contexts/OfflineContext.tsx index e79782d..627cadf 100644 --- a/src/contexts/OfflineContext.tsx +++ b/src/contexts/OfflineContext.tsx @@ -1,167 +1,180 @@ -import { createContext, useState, useEffect, useCallback, ReactNode } from "react"; -import { useToast } from "@/hooks/use-toast"; +import { + createContext, + useState, + useEffect, + useCallback, + ReactNode +} from 'react'; +import { useToast } from '@/hooks/use-toast'; interface OfflineAction { - id: string; - timestamp: Date; - action: string; - data: unknown; + id: string; + timestamp: Date; + action: string; + data: unknown; } interface OfflineContextType { - isOnline: boolean; - offlineQueue: OfflineAction[]; - addToQueue: (action: string, data: unknown) => void; - processQueue: () => Promise; + isOnline: boolean; + offlineQueue: OfflineAction[]; + addToQueue: (action: string, data: unknown) => void; + processQueue: () => Promise; } +// eslint-disable-next-line react-refresh/only-export-components export const OfflineContext = createContext({ - isOnline: true, - offlineQueue: [], - addToQueue: () => {}, - processQueue: async () => {} + isOnline: true, + offlineQueue: [], + addToQueue: () => {}, + processQueue: async () => {} }); interface OfflineProviderProps { - children: ReactNode; + children: ReactNode; } -const OFFLINE_QUEUE_KEY = "offline_queue"; +const OFFLINE_QUEUE_KEY = 'offline_queue'; export const OfflineProvider = ({ children }: OfflineProviderProps) => { - const [isOnline, setIsOnline] = useState(navigator.onLine); - const [offlineQueue, setOfflineQueue] = useState([]); - const { toast } = useToast(); - - // Load queue from localStorage on mount - useEffect(() => { - try { - const savedQueue = localStorage.getItem(OFFLINE_QUEUE_KEY); - if (savedQueue) { - const parsed = JSON.parse(savedQueue); - setOfflineQueue(parsed); - } - } catch (error) { - console.error("Error loading offline queue:", error); - } - }, []); - - // Save queue to localStorage whenever it changes - useEffect(() => { - try { - if (offlineQueue.length > 0) { - localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(offlineQueue)); - } else { - localStorage.removeItem(OFFLINE_QUEUE_KEY); - } - } catch (error) { - console.error("Error saving offline queue:", error); - } - }, [offlineQueue]); - - useEffect(() => { - const handleOnline = () => { - console.log("App is online"); - setIsOnline(true); - toast({ - title: "Back Online", - description: "Your connection has been restored. Syncing data...", - duration: 3000 - }); - processQueue(); - }; - - const handleOffline = () => { - console.log("App is offline"); - setIsOnline(false); - toast({ - title: "You're Offline", - description: "Changes will be saved locally and synced when you're back online.", - variant: "destructive", - duration: 5000 - }); - }; - - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); - - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toast]); - - const addToQueue = useCallback((action: string, data: unknown) => { - const queueItem: OfflineAction = { - id: crypto.randomUUID(), - timestamp: new Date(), - action, - data - }; - - setOfflineQueue(prev => [...prev, queueItem]); - - console.log("Added to offline queue:", queueItem); - - toast({ - title: "Action Queued", - description: "This action will be synced when you're back online.", - duration: 3000 - }); - }, [toast]); - - const processQueue = useCallback(async () => { - if (offlineQueue.length === 0) { - return; - } - - console.log(`Processing ${offlineQueue.length} queued actions...`); - - const successfulActions: string[] = []; - const failedActions: OfflineAction[] = []; - - for (const item of offlineQueue) { - try { - console.log("Processing queued action:", item.action, item.data); - - // Process the queued action here - // This would integrate with your existing data service - // For now, we'll just log it - // In a real implementation, you'd call the appropriate service methods - - successfulActions.push(item.id); - } catch (error) { - console.error("Failed to process queued action:", item, error); - failedActions.push(item); - } - } - - // Remove successful actions from queue - if (successfulActions.length > 0) { - setOfflineQueue(failedActions); - - toast({ - title: "Sync Complete", - description: `Successfully synced ${successfulActions.length} queued action(s).`, - duration: 3000 - }); - } - - // Notify about failed actions - if (failedActions.length > 0) { - toast({ - title: "Sync Issues", - description: `${failedActions.length} action(s) failed to sync. Will retry later.`, - variant: "destructive", - duration: 5000 - }); - } - }, [offlineQueue, toast]); - - return ( - - {children} - - ); + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [offlineQueue, setOfflineQueue] = useState([]); + const { toast } = useToast(); + + // Load queue from localStorage on mount + useEffect(() => { + try { + const savedQueue = localStorage.getItem(OFFLINE_QUEUE_KEY); + if (savedQueue) { + const parsed = JSON.parse(savedQueue); + setOfflineQueue(parsed); + } + } catch (error) { + console.error('Error loading offline queue:', error); + } + }, []); + + // Save queue to localStorage whenever it changes + useEffect(() => { + try { + if (offlineQueue.length > 0) { + localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(offlineQueue)); + } else { + localStorage.removeItem(OFFLINE_QUEUE_KEY); + } + } catch (error) { + console.error('Error saving offline queue:', error); + } + }, [offlineQueue]); + + useEffect(() => { + const handleOnline = () => { + console.log('App is online'); + setIsOnline(true); + toast({ + title: 'Back Online', + description: 'Your connection has been restored. Syncing data...', + duration: 3000 + }); + processQueue(); + }; + + const handleOffline = () => { + console.log('App is offline'); + setIsOnline(false); + toast({ + title: "You're Offline", + description: + "Changes will be saved locally and synced when you're back online.", + variant: 'destructive', + duration: 5000 + }); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toast]); + + const addToQueue = useCallback( + (action: string, data: unknown) => { + const queueItem: OfflineAction = { + id: crypto.randomUUID(), + timestamp: new Date(), + action, + data + }; + + setOfflineQueue(prev => [...prev, queueItem]); + + console.log('Added to offline queue:', queueItem); + + toast({ + title: 'Action Queued', + description: "This action will be synced when you're back online.", + duration: 3000 + }); + }, + [toast] + ); + + const processQueue = useCallback(async () => { + if (offlineQueue.length === 0) { + return; + } + + console.log(`Processing ${offlineQueue.length} queued actions...`); + + const successfulActions: string[] = []; + const failedActions: OfflineAction[] = []; + + for (const item of offlineQueue) { + try { + console.log('Processing queued action:', item.action, item.data); + + // Process the queued action here + // This would integrate with your existing data service + // For now, we'll just log it + // In a real implementation, you'd call the appropriate service methods + + successfulActions.push(item.id); + } catch (error) { + console.error('Failed to process queued action:', item, error); + failedActions.push(item); + } + } + + // Remove successful actions from queue + if (successfulActions.length > 0) { + setOfflineQueue(failedActions); + + toast({ + title: 'Sync Complete', + description: `Successfully synced ${successfulActions.length} queued action(s).`, + duration: 3000 + }); + } + + // Notify about failed actions + if (failedActions.length > 0) { + toast({ + title: 'Sync Issues', + description: `${failedActions.length} action(s) failed to sync. Will retry later.`, + variant: 'destructive', + duration: 5000 + }); + } + }, [offlineQueue, toast]); + + return ( + + {children} + + ); }; diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 07f8a75..22865cd 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -5,14 +5,14 @@ import React, { useEffect, useCallback, useRef -} from "react"; -import { DEFAULT_CATEGORIES, TaskCategory } from "@/config/categories"; -import { DEFAULT_PROJECTS, ProjectCategory } from "@/config/projects"; -import { useAuth } from "@/hooks/useAuth"; -import { createDataService, DataService } from "@/services/dataService"; -import { useRealtimeSync } from "@/hooks/useRealtimeSync"; -import { generateDailySummary } from "@/utils/timeUtil"; -import { toast } from "@/hooks/use-toast"; +} from 'react'; +import { DEFAULT_CATEGORIES, TaskCategory } from '@/config/categories'; +import { DEFAULT_PROJECTS, ProjectCategory } from '@/config/projects'; +import { useAuth } from '@/hooks/useAuth'; +import { createDataService, DataService } from '@/services/dataService'; +import { useRealtimeSync } from '@/hooks/useRealtimeSync'; +import { generateDailySummary } from '@/utils/timeUtil'; +import { toast } from '@/hooks/use-toast'; export interface Task { id: string; @@ -137,7 +137,9 @@ interface TimeTrackingContextType { // Export functions exportToCSV: (startDate?: Date, endDate?: Date) => string; exportToJSON: (startDate?: Date, endDate?: Date) => string; - importFromCSV: (csvContent: string) => Promise<{ success: boolean; message: string; importedCount: number }>; + importFromCSV: ( + csvContent: string + ) => Promise<{ success: boolean; message: string; importedCount: number }>; // generateInvoiceData: (clientName: string, startDate: Date, endDate: Date) => any; // Calculated values @@ -179,7 +181,9 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }) => { const { isAuthenticated, loading: authLoading, user } = useAuth(); const [dataService, setDataService] = useState(null); - const [previousAuthState, setPreviousAuthState] = useState(null); + const [previousAuthState, setPreviousAuthState] = useState( + null + ); const [isDayStarted, setIsDayStarted] = useState(false); const [dayStartTime, setDayStartTime] = useState(null); const [currentTask, setCurrentTask] = useState(null); @@ -189,8 +193,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const [projects, setProjects] = useState( convertDefaultProjects(DEFAULT_PROJECTS) ); - const [categories, setCategories] = - useState([]); + const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [isSyncing, setIsSyncing] = useState(false); const [lastSyncTime, setLastSyncTime] = useState(null); @@ -216,12 +219,17 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const handleLogout = async () => { // Detect logout: was authenticated, now not authenticated if (previousAuthState === true && !isAuthenticated && dataService) { - console.log('🔄 User logged out - syncing data to localStorage for offline access'); + console.log( + '🔄 User logged out - syncing data to localStorage for offline access' + ); try { // Use the current (Supabase) service to sync data to localStorage before switching await dataService.migrateToLocalStorage(); } catch (error) { - console.error('❌ Error syncing data to localStorage on logout:', error); + console.error( + '❌ Error syncing data to localStorage on logout:', + error + ); } } // Update previous auth state @@ -287,7 +295,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // Add saved projects that don't conflict with default ones loadedProjects.forEach((savedProject: Project) => { const existsInDefaults = defaultProjects.some( - (defaultProject) => + defaultProject => defaultProject.name === savedProject.name && defaultProject.client === savedProject.client ); @@ -304,7 +312,10 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // Load categories const loadedCategories = await dataService.getCategories(); if (loadedCategories.length > 0) { - console.log('📋 Loaded categories from database:', loadedCategories.length); + console.log( + '📋 Loaded categories from database:', + loadedCategories.length + ); setCategories(loadedCategories); } else { console.log('📋 No categories found in database, using defaults'); @@ -487,10 +498,17 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const startDay = (startDateTime?: Date) => { const now = startDateTime || new Date(); + + // Round to nearest 15 minutes + const minutes = now.getMinutes(); + const roundedMinutes = Math.round(minutes / 15) * 15; + const roundedTime = new Date(now); + roundedTime.setMinutes(roundedMinutes, 0, 0); + setIsDayStarted(true); - setDayStartTime(now); + setDayStartTime(roundedTime); setHasUnsavedChanges(true); - console.log('Day started at:', now); + console.log('Day started at:', roundedTime); }; const endDay = () => { @@ -508,8 +526,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ endTime: new Date(), duration: new Date().getTime() - currentTask.startTime.getTime() }; - setTasks((prev) => - prev.map((t) => (t.id === currentTask.id ? updatedTask : t)) + setTasks(prev => + prev.map(t => (t.id === currentTask.id ? updatedTask : t)) ); setCurrentTask(null); } @@ -517,11 +535,13 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ setHasUnsavedChanges(true); console.log('🔚 Day ended - saving state...'); // Save immediately since this is a critical action - saveImmediately().then(() => { - console.log('✅ State saved after ending day'); - }).catch((error) => { - console.error('❌ Error saving state after ending day:', error); - }); + saveImmediately() + .then(() => { + console.log('✅ State saved after ending day'); + }) + .catch(error => { + console.error('❌ Error saving state after ending day:', error); + }); }; const startNewTask = ( @@ -540,13 +560,14 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ endTime: now, duration: now.getTime() - currentTask.startTime.getTime() }; - setTasks((prev) => - prev.map((t) => (t.id === currentTask.id ? updatedTask : t)) + setTasks(prev => + prev.map(t => (t.id === currentTask.id ? updatedTask : t)) ); } // Determine start time: use dayStartTime for first task, otherwise use current time - const taskStartTime = tasks.length === 0 && dayStartTime ? dayStartTime : now; + const taskStartTime = + tasks.length === 0 && dayStartTime ? dayStartTime : now; // Create new task const newTask: Task = { @@ -559,7 +580,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ category }; - setTasks((prev) => [...prev, newTask]); + setTasks(prev => [...prev, newTask]); setCurrentTask(newTask); setHasUnsavedChanges(true); console.log('New task started:', title, 'at', taskStartTime); @@ -568,18 +589,18 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }; const updateTask = (taskId: string, updates: Partial) => { - setTasks((prev) => - prev.map((task) => (task.id === taskId ? { ...task, ...updates } : task)) + setTasks(prev => + prev.map(task => (task.id === taskId ? { ...task, ...updates } : task)) ); if (currentTask?.id === taskId) { - setCurrentTask((prev) => (prev ? { ...prev, ...updates } : null)); + setCurrentTask(prev => (prev ? { ...prev, ...updates } : null)); } setHasUnsavedChanges(true); console.log('Task updated:', taskId, updates); }; const deleteTask = (taskId: string) => { - setTasks((prev) => prev.filter((task) => task.id !== taskId)); + setTasks(prev => prev.filter(task => task.id !== taskId)); if (currentTask?.id === taskId) { setCurrentTask(null); } @@ -613,7 +634,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }); // Update state optimistically - setArchivedDays((prev) => [...prev, dayRecord]); + setArchivedDays(prev => [...prev, dayRecord]); // Clear current day data setDayStartTime(null); @@ -643,11 +664,10 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // Show success notification to user toast({ - title: "Day Archived Successfully", + title: 'Day Archived Successfully', description: `${dayRecord.tasks.length} task(s) archived for ${dayRecord.date}`, duration: 5000 }); - } catch (error) { console.error('❌ CRITICAL ERROR saving archived day:', error); console.error('📋 Failed to archive day data:', { @@ -658,7 +678,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }); // Rollback optimistic update since save failed - setArchivedDays((prev) => prev.filter(day => day.id !== dayRecord.id)); + setArchivedDays(prev => prev.filter(day => day.id !== dayRecord.id)); // Restore the current day state since archiving failed setDayStartTime(dayRecord.startTime); @@ -675,9 +695,9 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // Show error notification to user toast({ - title: "Archive Failed", + title: 'Archive Failed', description: `Failed to archive day data: ${error instanceof Error ? error.message : 'Unknown error'}. Your current day has been restored. Please try archiving again.`, - variant: "destructive", + variant: 'destructive', duration: 7000 }); } @@ -690,14 +710,14 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ ...project, id: Date.now().toString() }; - setProjects((prev) => [...prev, newProject]); + setProjects(prev => [...prev, newProject]); setHasUnsavedChanges(true); console.log('📋 Project added (not saved automatically)'); }; const updateProject = (projectId: string, updates: Partial) => { - setProjects((prev) => - prev.map((project) => + setProjects(prev => + prev.map(project => project.id === projectId ? { ...project, ...updates } : project ) ); @@ -706,7 +726,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }; const deleteProject = (projectId: string) => { - setProjects((prev) => prev.filter((project) => project.id !== projectId)); + setProjects(prev => prev.filter(project => project.id !== projectId)); setHasUnsavedChanges(true); console.log('📋 Project deleted (not saved automatically)'); }; @@ -728,8 +748,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ console.log('🔄 Updating archived day:', { dayId, updates }); // Optimistic update - update local state immediately for responsive UI - setArchivedDays((prev) => - prev.map((day) => (day.id === dayId ? { ...day, ...updates } : day)) + setArchivedDays(prev => + prev.map(day => (day.id === dayId ? { ...day, ...updates } : day)) ); // Then persist to database @@ -753,13 +773,13 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ try { await dataService.deleteArchivedDay(dayId); - setArchivedDays((prev) => prev.filter((day) => day.id !== dayId)); + setArchivedDays(prev => prev.filter(day => day.id !== dayId)); } catch (error) { console.error('Error deleting archived day:', error); } }; const restoreArchivedDay = (dayId: string) => { - const dayToRestore = archivedDays.find((day) => day.id === dayId); + const dayToRestore = archivedDays.find(day => day.id === dayId); if (!dayToRestore) return; // Clear current day if any @@ -773,13 +793,13 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ setTasks(dayToRestore.tasks); // Find the last task that doesn't have an end time (if any) and make it current - const activeTask = dayToRestore.tasks.find((task) => !task.endTime); + const activeTask = dayToRestore.tasks.find(task => !task.endTime); if (activeTask) { setCurrentTask(activeTask); } // Remove from archive - setArchivedDays((prev) => prev.filter((day) => day.id !== dayId)); + setArchivedDays(prev => prev.filter(day => day.id !== dayId)); setHasUnsavedChanges(true); console.log('Day restored from archive'); @@ -791,7 +811,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ ...category, id: Date.now().toString() }; - setCategories((prev) => [...prev, newCategory]); + setCategories(prev => [...prev, newCategory]); setHasUnsavedChanges(true); console.log('🏷️ Category added (not saved automatically)'); }; @@ -800,8 +820,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ categoryId: string, updates: Partial ) => { - setCategories((prev) => - prev.map((category) => + setCategories(prev => + prev.map(category => category.id === categoryId ? { ...category, ...updates } : category ) ); @@ -810,9 +830,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }; const deleteCategory = (categoryId: string) => { - setCategories((prev) => - prev.filter((category) => category.id !== categoryId) - ); + setCategories(prev => prev.filter(category => category.id !== categoryId)); setHasUnsavedChanges(true); console.log('🏷️ Category deleted (not saved automatically)'); }; @@ -830,8 +848,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const roundedStartTime = roundToNearestQuarter(startTime); const roundedEndTime = endTime ? roundToNearestQuarter(endTime) : undefined; - setTasks((prev) => - prev.map((task) => { + setTasks(prev => + prev.map(task => { if (task.id === taskId) { const updatedTask = { ...task, @@ -849,13 +867,13 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // Update current task if it's the one being adjusted if (currentTask?.id === taskId) { - setCurrentTask((prev) => + setCurrentTask(prev => prev ? { - ...prev, - startTime: roundedStartTime, - endTime: roundedEndTime - } + ...prev, + startTime: roundedStartTime, + endTime: roundedEndTime + } : null ); } @@ -864,7 +882,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const getTotalDayDuration = () => { const completedTasksDuration = tasks - .filter((task) => task.duration) + .filter(task => task.duration) .reduce((total, task) => total + (task.duration || 0), 0); const currentTaskDuration = getCurrentTaskDuration(); @@ -878,7 +896,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }; const getTotalHoursForPeriod = (startDate: Date, endDate: Date): number => { - const filteredDays = archivedDays.filter((day) => { + const filteredDays = archivedDays.filter(day => { const dayDate = new Date(day.startTime); return dayDate >= startDate && dayDate <= endDate; }); @@ -891,7 +909,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }; const getRevenueForPeriod = (startDate: Date, endDate: Date): number => { - const filteredDays = archivedDays.filter((day) => { + const filteredDays = archivedDays.filter(day => { const dayDate = new Date(day.startTime); return dayDate >= startDate && dayDate <= endDate; }); @@ -901,8 +919,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const categoryMap = new Map(categories.map(c => [c.id, c])); let totalRevenue = 0; - filteredDays.forEach((day) => { - day.tasks.forEach((task) => { + filteredDays.forEach(day => { + day.tasks.forEach(task => { if (task.project && task.duration && task.category) { // Check if both the project and category are billable const project = projectMap.get(task.project); @@ -928,7 +946,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const getHoursWorkedForDay = (day: DayRecord): number => { // Calculate total time worked (sum of all task durations, excluding breaks) let totalTaskDuration = 0; - day.tasks.forEach((task) => { + day.tasks.forEach(task => { if (task.duration) { totalTaskDuration += task.duration; } @@ -946,7 +964,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ let totalRevenue = 0; - day.tasks.forEach((task) => { + day.tasks.forEach(task => { if (task.project && task.duration && task.category) { // Check if both the project and category are billable const project = projectMap.get(task.project); @@ -967,13 +985,14 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }); return Math.round(totalRevenue * 100) / 100; - }; const getBillableHoursForDay = (day: DayRecord): number => { + }; + const getBillableHoursForDay = (day: DayRecord): number => { // Create lookup maps for O(1) access (performance optimization) const projectMap = new Map(projects.map(p => [p.name, p])); const categoryMap = new Map(categories.map(c => [c.id, c])); let billableTime = 0; - day.tasks.forEach((task) => { + day.tasks.forEach(task => { if (task.duration && task.category && task.project) { // Check if both the project and category are billable const project = projectMap.get(task.project); @@ -1002,7 +1021,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const categoryMap = new Map(categories.map(c => [c.id, c])); let nonBillableTime = 0; - day.tasks.forEach((task) => { + day.tasks.forEach(task => { if (task.duration && task.category && task.project) { // Check if both the project and category are billable const project = projectMap.get(task.project); @@ -1029,7 +1048,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ let filteredDays = archivedDays; if (startDate && endDate) { - filteredDays = archivedDays.filter((day) => { + filteredDays = archivedDays.filter(day => { const dayDate = new Date(day.startTime); return dayDate >= startDate && dayDate <= endDate; }); @@ -1057,25 +1076,27 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ ]; const rows = [headers.join(',')]; - filteredDays.forEach((day) => { + filteredDays.forEach(day => { // Generate daily summary once per day const dayDescriptions = day.tasks - .filter((t) => t.description) - .map((t) => t.description!); + .filter(t => t.description) + .map(t => t.description!); const dailySummary = generateDailySummary(dayDescriptions); - day.tasks.forEach((task) => { + day.tasks.forEach(task => { if (task.duration) { - const project = projects.find((p) => p.name === task.project); + const project = projects.find(p => p.name === task.project); // Fix: Look up category by ID, not name - const category = categories.find((c) => c.id === task.category); + const category = categories.find(c => c.id === task.category); // Format timestamps as ISO strings for database compatibility const startTimeISO = task.startTime.toISOString(); const endTimeISO = task.endTime?.toISOString() || ''; // Use actual timestamps from database, or current time as fallback - const insertedAtISO = task.insertedAt?.toISOString() || new Date().toISOString(); - const updatedAtISO = task.updatedAt?.toISOString() || new Date().toISOString(); + const insertedAtISO = + task.insertedAt?.toISOString() || new Date().toISOString(); + const updatedAtISO = + task.updatedAt?.toISOString() || new Date().toISOString(); const row = [ `"${task.id}"`, @@ -1108,17 +1129,17 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ let filteredDays = archivedDays; if (startDate && endDate) { - filteredDays = archivedDays.filter((day) => { + filteredDays = archivedDays.filter(day => { const dayDate = new Date(day.startTime); return dayDate >= startDate && dayDate <= endDate; }); } // Add daily summary to each day - const daysWithSummary = filteredDays.map((day) => { + const daysWithSummary = filteredDays.map(day => { const dayDescriptions = day.tasks - .filter((t) => t.description) - .map((t) => t.description!); + .filter(t => t.description) + .map(t => t.description!); const dailySummary = generateDailySummary(dayDescriptions); return { @@ -1160,17 +1181,19 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const projectMap = new Map(projects.map(p => [p.name, p])); const categoryMap = new Map(categories.map(c => [c.id, c])); - const filteredDays = archivedDays.filter((day) => { + const filteredDays = archivedDays.filter(day => { const dayDate = new Date(day.startTime); return dayDate >= startDate && dayDate <= endDate; }); // Generate daily summaries for all days in the period - const dailySummaries: { [dayId: string]: { date: string; summary: string } } = {}; - filteredDays.forEach((day) => { + const dailySummaries: { + [dayId: string]: { date: string; summary: string }; + } = {}; + filteredDays.forEach(day => { const dayDescriptions = day.tasks - .filter((t) => t.description) - .map((t) => t.description!); + .filter(t => t.description) + .map(t => t.description!); const summary = generateDailySummary(dayDescriptions); if (summary) { @@ -1181,9 +1204,9 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ } }); - const clientTasks = filteredDays.flatMap((day) => + const clientTasks = filteredDays.flatMap(day => day.tasks - .filter((task) => { + .filter(task => { if (!task.client || task.client !== clientName || !task.duration) { return false; } @@ -1202,11 +1225,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ return false; }) - .map((task) => ({ + .map(task => ({ ...task, dayId: day.id, dayDate: day.date, - dailySummary: dailySummaries[day.id]?.summary || "" + dailySummary: dailySummaries[day.id]?.summary || '' })) ); @@ -1214,7 +1237,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ [key: string]: { hours: number; rate: number; amount: number }; } = {}; - clientTasks.forEach((task) => { + clientTasks.forEach(task => { const projectName = task.project || 'General'; const project = projectMap.get(task.project); const hours = (task.duration || 0) / (1000 * 60 * 60); @@ -1250,22 +1273,43 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }; }; - const importFromCSV = async (csvContent: string): Promise<{ success: boolean; message: string; importedCount: number }> => { + const importFromCSV = async ( + csvContent: string + ): Promise<{ success: boolean; message: string; importedCount: number }> => { try { const lines = csvContent.split('\n').filter(line => line.trim()); if (lines.length === 0) { - return { success: false, message: 'CSV file is empty', importedCount: 0 }; + return { + success: false, + message: 'CSV file is empty', + importedCount: 0 + }; } const headerLine = lines[0]; const expectedHeaders = [ - 'id', 'user_id', 'title', 'description', 'start_time', 'end_time', - 'duration', 'project_id', 'project_name', 'client', 'category_id', - 'category_name', 'day_record_id', 'is_current', 'inserted_at', 'updated_at' + 'id', + 'user_id', + 'title', + 'description', + 'start_time', + 'end_time', + 'duration', + 'project_id', + 'project_name', + 'client', + 'category_id', + 'category_name', + 'day_record_id', + 'is_current', + 'inserted_at', + 'updated_at' ]; // Validate headers - const headers = headerLine.split(',').map(h => h.trim().replace(/"/g, '')); + const headers = headerLine + .split(',') + .map(h => h.trim().replace(/"/g, '')); const missingHeaders = expectedHeaders.filter(h => !headers.includes(h)); if (missingHeaders.length > 0) { return { @@ -1275,7 +1319,9 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ }; } - const tasksByDay: { [dayId: string]: { tasks: Task[], dayRecord: Partial } } = {}; + const tasksByDay: { + [dayId: string]: { tasks: Task[]; dayRecord: Partial }; + } = {}; let importedCount = 0; // Process each data line @@ -1303,7 +1349,9 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ values.push(current.trim()); // Add last value if (values.length !== headers.length) { - console.warn(`Skipping malformed CSV line ${i + 1}: expected ${headers.length} columns, got ${values.length}`); + console.warn( + `Skipping malformed CSV line ${i + 1}: expected ${headers.length} columns, got ${values.length}` + ); continue; } @@ -1315,29 +1363,40 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // Validate required fields if (!taskData.id || !taskData.title || !taskData.start_time) { - console.warn(`Skipping incomplete task on line ${i + 1}: missing required fields`); + console.warn( + `Skipping incomplete task on line ${i + 1}: missing required fields` + ); continue; } // Map category name back to category ID for proper storage - const categoryByName = categories.find(c => c.name === taskData.category_name); - const categoryId = categoryByName?.id || taskData.category_id || undefined; + const categoryByName = categories.find( + c => c.name === taskData.category_name + ); + const categoryId = + categoryByName?.id || taskData.category_id || undefined; const task: Task = { id: taskData.id, title: taskData.title, description: taskData.description || undefined, startTime: new Date(taskData.start_time), - endTime: taskData.end_time ? new Date(taskData.end_time) : undefined, - duration: taskData.duration ? parseInt(taskData.duration) : undefined, + endTime: taskData.end_time + ? new Date(taskData.end_time) + : undefined, + duration: taskData.duration + ? parseInt(taskData.duration) + : undefined, project: taskData.project_name || undefined, client: taskData.client || undefined, - category: categoryId, // Use category ID, not name + category: categoryId // Use category ID, not name }; // Validate dates if (isNaN(task.startTime.getTime())) { - console.warn(`Skipping task with invalid start_time on line ${i + 1}`); + console.warn( + `Skipping task with invalid start_time on line ${i + 1}` + ); continue; } @@ -1347,7 +1406,9 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const dayRecordId = taskData.day_record_id; if (!dayRecordId) { - console.warn(`Skipping task without day_record_id on line ${i + 1}`); + console.warn( + `Skipping task without day_record_id on line ${i + 1}` + ); continue; } @@ -1369,10 +1430,17 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ tasksByDay[dayRecordId].tasks.push(task); // Update day record bounds - if (task.startTime < (tasksByDay[dayRecordId].dayRecord.startTime || new Date())) { + if ( + task.startTime < + (tasksByDay[dayRecordId].dayRecord.startTime || new Date()) + ) { tasksByDay[dayRecordId].dayRecord.startTime = task.startTime; } - if (task.endTime && task.endTime > (tasksByDay[dayRecordId].dayRecord.endTime || new Date(0))) { + if ( + task.endTime && + task.endTime > + (tasksByDay[dayRecordId].dayRecord.endTime || new Date(0)) + ) { tasksByDay[dayRecordId].dayRecord.endTime = task.endTime; } @@ -1387,7 +1455,10 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const newArchivedDays: DayRecord[] = []; for (const [dayId, { tasks, dayRecord }] of Object.entries(tasksByDay)) { - const totalDuration = tasks.reduce((sum, task) => sum + (task.duration || 0), 0); + const totalDuration = tasks.reduce( + (sum, task) => sum + (task.duration || 0), + 0 + ); const completeDay: DayRecord = { id: dayRecord.id!, @@ -1404,7 +1475,9 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ // Merge with existing archived days (avoid duplicates) const existingIds = new Set(archivedDays.map(day => day.id)); - const uniqueNewDays = newArchivedDays.filter(day => !existingIds.has(day.id)); + const uniqueNewDays = newArchivedDays.filter( + day => !existingIds.has(day.id) + ); const updatedArchivedDays = [...archivedDays, ...uniqueNewDays]; setArchivedDays(updatedArchivedDays); @@ -1419,7 +1492,6 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ message: `Successfully imported ${importedCount} tasks in ${uniqueNewDays.length} days`, importedCount }; - } catch (error) { console.error('CSV import error:', error); return { From e5b1f9f81c0c1dd805909588e8bf94847b55101c Mon Sep 17 00:00:00 2001 From: Adam Jolicoeur Date: Fri, 6 Feb 2026 10:42:22 -0500 Subject: [PATCH 3/3] feat: update time picker dialogs --- CHANGELOG.md | 67 +++ CLAUDE.md | 83 +++- README.md | 14 + dev-dist/sw.js | 2 +- .../2026-02-06-scroll-time-picker-design.md | 45 ++ .../2026-02-06-scroll-time-picker-plan.md | 467 ++++++++++++++++++ package-lock.json | 23 +- package.json | 1 + src/components/ArchiveEditDialog.tsx | 366 ++++++-------- src/components/StartDayDialog.tsx | 219 ++++---- src/components/TaskEditDialog.tsx | 241 ++++----- src/components/ui/scroll-time-picker.tsx | 273 +++------- src/index.css | 2 +- 13 files changed, 1099 insertions(+), 704 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/plans/2026-02-06-scroll-time-picker-design.md create mode 100644 docs/plans/2026-02-06-scroll-time-picker-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d15c110 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +All notable changes to TimeTracker Pro will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Native HTML5 time picker component (`TimePicker`) following web standards and a11y best practices + - Uses `` for familiar, intuitive UX + - **15-minute intervals**: Time selection restricted to :00, :15, :30, and :45 using HTML5 `step` attribute + - Automatic native time pickers on mobile devices (iOS/Android) + - Keyboard-accessible time inputs on desktop + - Full ARIA label support for screen readers + - Styled with shadcn/ui design tokens for consistency + +### Changed +- **Improved time selection UX**: Replaced custom scroll-wheel time picker with native HTML5 time inputs + - Follows standard web conventions for familiar, intuitive user experience + - Better desktop experience with keyboard-accessible inputs + - Mobile browsers provide native time pickers automatically + - Full accessibility (a11y) support with proper ARIA labels and keyboard navigation + - Consistent with existing date input pattern in the application + - Eliminates custom scroll logic in favor of browser-native functionality + - **Start Day Dialog**: 1 time picker for day start time + - **Task Edit Dialog**: 2 time pickers for task start/end times + - **Archive Edit Dialog**: 4 time pickers (2 for day start/end, 2 for task start/end) +- Removed duplicate `generateTimeOptions()` helper functions from all dialog components + +### Fixed +- Resolved merge conflicts in `src/index.css` + +## [0.21.1] - 2026-02-06 + +### Initial Release Features +- Daily time tracking with start/stop functionality +- Task management with real-time duration tracking +- Rich text support with GitHub Flavored Markdown +- Projects & clients organization with hourly rates +- Custom categories with color coding +- Archive system for completed work days +- Revenue tracking and automatic calculations +- Invoice generation and export (CSV, JSON) +- CSV import for existing time data +- Progressive Web App with offline support +- Cross-platform compatibility (Windows, Mac, Linux, iOS, Android) +- Dual storage mode (guest/local or authenticated/cloud sync) +- Print-friendly archive views +- Mobile-optimized interface with touch navigation +- Dark mode support +- Authentication via Supabase (optional) +- Real-time sync across devices (when authenticated) + +--- + +## Version History + +### Versioning Guidelines +- **Major** (X.0.0): Breaking changes, major feature overhauls +- **Minor** (0.X.0): New features, significant improvements, non-breaking changes +- **Patch** (0.0.X): Bug fixes, minor improvements, documentation updates + +### Links +- [Unreleased Changes](https://github.com/AdamJ/TimeTrackerPro/compare/v0.21.1...HEAD) +- [Version 0.21.1](https://github.com/AdamJ/TimeTrackerPro/releases/tag/v0.21.1) diff --git a/CLAUDE.md b/CLAUDE.md index 58096b2..10e9dde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md - AI Assistant Codebase Guide -**Last Updated:** 2026-02-02 -**Version:** 1.0.2 +**Last Updated:** 2026-02-06 +**Version:** 1.0.4 This document provides comprehensive guidance for AI assistants working with the TimeTracker Pro codebase. It covers architecture, conventions, workflows, and best practices. @@ -196,6 +196,7 @@ TimeTrackerPro/ ├── src/ │ ├── components/ # React components │ │ ├── ui/ # shadcn/ui base components (49 files) +│ │ │ └── scroll-time-picker.tsx # Custom scroll-wheel time picker │ │ ├── ArchiveEditDialog.tsx # Archive entry editing │ │ ├── ArchiveItem.tsx # Archive display component │ │ ├── AuthDialog.tsx # Authentication modal @@ -627,6 +628,81 @@ const MyPage = lazy(() => import("./pages/MyPage")); } /> ``` +### Using the TimePicker Component + +The `TimePicker` is a native HTML5 time input wrapped with shadcn/ui styling for consistent appearance and accessibility. + +**Component File**: `src/components/ui/scroll-time-picker.tsx` + +**Props Interface**: +```typescript +interface TimePickerProps { + value: string; // "HH:MM" 24-hour format (e.g., "14:30") + onValueChange: (value: string) => void; + disabled?: boolean; + className?: string; + id?: string; + "aria-label"?: string; + "aria-describedby"?: string; +} +``` + +**Usage Example**: +```typescript +import { TimePicker } from "@/components/ui/scroll-time-picker"; +import { Label } from "@/components/ui/label"; +import { useState } from "react"; + +const MyComponent = () => { + const [time, setTime] = useState("09:00"); + + return ( +
+ + +
+ ); +}; +``` + +**Features**: +- Native HTML5 `` for standard web UX +- **15-minute intervals**: Time selection restricted to :00, :15, :30, :45 using `step={900}` +- Mobile browsers display native time pickers automatically +- Desktop browsers provide keyboard-accessible spinners or typed input +- Full accessibility with ARIA labels, keyboard navigation, and screen reader support +- Styled with shadcn/ui design tokens (matches Input component) +- Supports dark mode via CSS variables +- Compatible with all modern browsers + +**Accessibility (A11y)**: +- Proper label association via `htmlFor` and `id` +- ARIA labels for screen readers +- Keyboard navigable (Tab, Arrow keys, Enter) +- Focus visible indicators +- Works with browser's native date/time accessibility features + +**Used In**: +- `StartDayDialog.tsx` - Day start time selection (1 picker) +- `TaskEditDialog.tsx` - Task start/end time selection (2 pickers) +- `ArchiveEditDialog.tsx` - Day and task time editing (4 pickers total) + +**Design Decision**: Native HTML5 inputs were chosen over custom implementations because: +1. Standard web pattern users already understand +2. Automatic mobile optimization (native pickers on iOS/Android) +3. Built-in accessibility features +4. Consistent with the app's date input approach +5. No custom scroll/wheel logic to maintain +6. Better keyboard navigation +7. Follows shadcn/ui philosophy of enhancing web standards + +**Migration Note**: This component replaced a custom scroll-wheel picker and earlier dropdown approach, providing better UX through browser-native functionality. + ### Adding a New Context Method ```typescript @@ -951,6 +1027,7 @@ Before making changes, verify: ### Documentation - **Main README**: `README.md` - User-facing documentation +- **Changelog**: `CHANGELOG.md` - Version history and changes - **CLAUDE.md**: `CLAUDE.md` - This file - AI assistant guide - **Agent Guidelines**: `AGENTS.md` - Quick agent instructions - **Archive System**: `docs/ARCHIVING_DAYS.md` - Archive system guide @@ -986,6 +1063,8 @@ Before making changes, verify: | Version | Date | Changes | |---------|------|---------| +| 1.0.4 | 2026-02-06 | Replaced custom scroll picker with native HTML5 time inputs for better UX and a11y | +| 1.0.3 | 2026-02-06 | Added ScrollTimePicker component, replaced time dropdowns with scroll-wheel UI | | 1.0.2 | 2026-02-02 | Added auto-open New Task form feature when day starts | | 1.0.1 | 2025-11-21 | Updated component list, documentation references, and current state | | 1.0.0 | 2025-11-18 | Initial CLAUDE.md creation | diff --git a/README.md b/README.md index 3183d7a..c4f6529 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ A modern, feature-rich Progressive Web App (PWA) for time tracking built with Re **Additional:** +- [Changelog](#-changelog) - [License](#-license) - [Credits](#-credits) @@ -60,6 +61,7 @@ TimeTracker Pro is a professional time tracking application that helps you monit - **Daily Time Tracking** - Start/stop your workday with clear boundaries - **Task Management** - Create, edit, and delete tasks with real-time duration tracking +- **Intuitive Time Selection** - Native browser time inputs for familiar, accessible time entry - **Rich Text Support** - Add detailed notes with GitHub Flavored Markdown (tables, lists, formatting) - **Automatic Calculations** - Duration and revenue calculated automatically - **Archive System** - Permanent record of all completed work days @@ -1059,6 +1061,18 @@ const MyPage = lazy(() => import("./pages/MyPage")); --- +## 📋 Changelog + +For a detailed list of changes, new features, and bug fixes, see [CHANGELOG.md](CHANGELOG.md). + +**Recent Updates:** +- Native HTML5 time inputs for intuitive, accessible time selection +- Consistent UX with date inputs across all dialogs +- Mobile-optimized with browser-native time pickers +- Full keyboard navigation and screen reader support + +--- + ## 📱 iOS Screenshots | View | Image | diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 7be56bf..e86b285 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -79,7 +79,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; */ workbox.precacheAndRoute([{ "url": "index.html", - "revision": "0.0lr8hurrrmo" + "revision": "0.vtj3k2q5obg" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/docs/plans/2026-02-06-scroll-time-picker-design.md b/docs/plans/2026-02-06-scroll-time-picker-design.md new file mode 100644 index 0000000..8b49157 --- /dev/null +++ b/docs/plans/2026-02-06-scroll-time-picker-design.md @@ -0,0 +1,45 @@ +# Scroll Time Picker Design + +**Date:** 2026-02-06 +**Status:** Approved + +## Problem + +Time selection uses Select dropdowns with 96 options (15-minute intervals across 24 hours). Users must scroll through a long list to find their desired time. Poor UX especially for times later in the day. + +## Solution + +Replace Select dropdowns with a custom scroll-wheel time picker (iOS drum-picker style). Three columns: Hour (1-12), Minute (00/15/30/45), Period (AM/PM). CSS scroll-snap locks selection. Produces the same `"HH:MM"` 24-hour string format. + +## Component + +**File:** `src/components/ui/scroll-time-picker.tsx` + +**Props:** + +- `value: string` — "HH:MM" 24-hour format +- `onValueChange: (value: string) => void` +- `disabled?: boolean` +- `className?: string` + +**Wheels:** + +- Hour: 1–12 (scroll-snap) +- Minute: 00, 15, 30, 45 (scroll-snap) +- Period: AM, PM (scroll-snap) + +**Styling:** shadcn/ui design tokens, dark mode via theme variables. + +## Integration Points + +1. `StartDayDialog.tsx` — 1 picker (start time) +2. `TaskEditDialog.tsx` — 2 pickers (start + end) +3. `ArchiveEditDialog.tsx` — 2 pickers (day start/end) + 2 per task (task start/end) + +Drop-in replacement: same value/onValueChange interface as current Select. Remove duplicated `generateTimeOptions()` from all files. + +## No Changes To + +- Data storage format (Date objects in memory, HH:MM strings in UI) +- `parseTimeInput()`, `formatTimeForInput()`, `formatTime12Hour()` utilities +- Task interface or data service layer diff --git a/docs/plans/2026-02-06-scroll-time-picker-plan.md b/docs/plans/2026-02-06-scroll-time-picker-plan.md new file mode 100644 index 0000000..354623a --- /dev/null +++ b/docs/plans/2026-02-06-scroll-time-picker-plan.md @@ -0,0 +1,467 @@ +# Scroll Time Picker Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace all time-selection Select dropdowns with a scroll-wheel time picker (hour/minute/period columns) for better UX. + +**Architecture:** A single reusable `ScrollTimePicker` component in `src/components/ui/scroll-time-picker.tsx` with the same `value`/`onValueChange` interface as the current Select dropdowns. Three CSS scroll-snap columns (Hour 1-12, Minute 00/15/30/45, Period AM/PM). Drop-in replacement across 3 files (4 dialog components). + +**Tech Stack:** React, TypeScript, Tailwind CSS, shadcn/ui design tokens + +--- + +## Task 1: Create the ScrollTimePicker component + +**Files:** + +- Create: `src/components/ui/scroll-time-picker.tsx` + +**Step 1: Create the component file** + +Create `src/components/ui/scroll-time-picker.tsx` with the following implementation: + +```tsx +import React, { useRef, useEffect, useCallback } from 'react'; +import { cn } from '@/lib/util'; + +interface ScrollTimePickerProps { + value: string; // "HH:MM" 24-hour format + onValueChange: (value: string) => void; + disabled?: boolean; + className?: string; +} + +const HOURS = Array.from({ length: 12 }, (_, i) => i + 1); // 1-12 +const MINUTES = [0, 15, 30, 45]; +const PERIODS = ['AM', 'PM'] as const; + +const ITEM_HEIGHT = 40; // px per item +const VISIBLE_ITEMS = 5; // show 5 items, center is selected + +function parse24Hour(value: string): { + hour12: number; + minute: number; + period: 'AM' | 'PM'; +} { + const [h, m] = value.split(':').map(Number); + const period = h >= 12 ? 'PM' : 'AM'; + let hour12 = h % 12; + hour12 = hour12 === 0 ? 12 : hour12; + // Round minute to nearest 15 + const minute = Math.round(m / 15) * 15 === 60 ? 0 : Math.round(m / 15) * 15; + return { hour12, minute, period }; +} + +function to24Hour(hour12: number, minute: number, period: 'AM' | 'PM'): string { + let h = hour12; + if (period === 'AM' && h === 12) h = 0; + if (period === 'PM' && h !== 12) h += 12; + return `${h.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; +} + +interface WheelColumnProps { + items: (string | number)[]; + selectedIndex: number; + onSelect: (index: number) => void; + disabled?: boolean; + formatItem?: (item: string | number) => string; +} + +const WheelColumn: React.FC = ({ + items, + selectedIndex, + onSelect, + disabled, + formatItem = String +}) => { + const containerRef = useRef(null); + const isScrollingRef = useRef(false); + const scrollTimeoutRef = useRef>(); + + // Scroll to selected index on mount and when selectedIndex changes externally + useEffect(() => { + if (containerRef.current && !isScrollingRef.current) { + const scrollTop = selectedIndex * ITEM_HEIGHT; + containerRef.current.scrollTo({ top: scrollTop, behavior: 'smooth' }); + } + }, [selectedIndex]); + + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + isScrollingRef.current = true; + + if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = setTimeout(() => { + if (!containerRef.current) return; + const scrollTop = containerRef.current.scrollTop; + const index = Math.round(scrollTop / ITEM_HEIGHT); + const clampedIndex = Math.max(0, Math.min(items.length - 1, index)); + + // Snap to position + containerRef.current.scrollTo({ + top: clampedIndex * ITEM_HEIGHT, + behavior: 'smooth' + }); + + isScrollingRef.current = false; + if (clampedIndex !== selectedIndex) { + onSelect(clampedIndex); + } + }, 100); + }, [items.length, selectedIndex, onSelect]); + + const handleItemClick = (index: number) => { + if (disabled) return; + if (containerRef.current) { + containerRef.current.scrollTo({ + top: index * ITEM_HEIGHT, + behavior: 'smooth' + }); + } + onSelect(index); + }; + + const paddingHeight = Math.floor(VISIBLE_ITEMS / 2) * ITEM_HEIGHT; + + return ( +
+ {/* Selection highlight band */} +
+ {/* Fade overlays */} +
+
+
+ {/* Top padding */} +
+ {items.map((item, index) => ( +
handleItemClick(index)} + > + {formatItem(item)} +
+ ))} + {/* Bottom padding */} +
+
+
+ ); +}; + +export const ScrollTimePicker: React.FC = ({ + value, + onValueChange, + disabled = false, + className +}) => { + const { hour12, minute, period } = parse24Hour(value || '09:00'); + + const hourIndex = HOURS.indexOf(hour12); + const minuteIndex = MINUTES.indexOf(minute); + const periodIndex = PERIODS.indexOf(period); + + const handleHourChange = (index: number) => { + onValueChange(to24Hour(HOURS[index], minute, period)); + }; + + const handleMinuteChange = (index: number) => { + onValueChange(to24Hour(hour12, MINUTES[index], period)); + }; + + const handlePeriodChange = (index: number) => { + onValueChange(to24Hour(hour12, minute, PERIODS[index])); + }; + + return ( +
+ = 0 ? hourIndex : 0} + onSelect={handleHourChange} + disabled={disabled} + /> +
:
+ = 0 ? minuteIndex : 0} + onSelect={handleMinuteChange} + disabled={disabled} + formatItem={item => String(item).padStart(2, '0')} + /> + = 0 ? periodIndex : 0} + onSelect={handlePeriodChange} + disabled={disabled} + /> +
+ ); +}; +``` + +**Step 2: Add scrollbar-hide utility** + +Check `src/index.css` or Tailwind config for a `.scrollbar-hide` utility. If it doesn't exist, add to `src/index.css`: + +```css +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} +``` + +**Step 3: Verify the component builds** + +Run: `npm run build` +Expected: No TypeScript or build errors + +**Step 4: Commit** + +```bash +git add src/components/ui/scroll-time-picker.tsx src/index.css +git commit -m "feat: add ScrollTimePicker component with scroll-wheel UI" +``` + +--- + +## Task 2: Integrate into StartDayDialog + +**Files:** + +- Modify: `src/components/StartDayDialog.tsx` + +**Step 1: Replace Select with ScrollTimePicker** + +In `src/components/StartDayDialog.tsx`: + +1. Remove imports: `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue` +2. Add import: `import { ScrollTimePicker } from "@/components/ui/scroll-time-picker";` +3. Remove: `formatTime12Hour` function (lines 42-50) +4. Remove: `TimeOption` type and `generateTimeOptions` function (lines 52-65) +5. Remove: `const timeOptions = generateTimeOptions();` (line 75) +6. Replace the Select block (lines 122-133) with: + +```tsx + +``` + +**Step 2: Verify build** + +Run: `npm run build` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/components/StartDayDialog.tsx +git commit -m "feat: use ScrollTimePicker in StartDayDialog" +``` + +--- + +## Task 3: Integrate into TaskEditDialog + +**Files:** + +- Modify: `src/components/TaskEditDialog.tsx` + +**Step 1: Replace Select time pickers with ScrollTimePicker** + +In `src/components/TaskEditDialog.tsx`: + +1. Remove Select-related imports (lines 12-18) — only if Select is no longer used (category and project still use Select, so keep them) +2. Add import: `import { ScrollTimePicker } from "@/components/ui/scroll-time-picker";` +3. Remove: `TimeOption` type, `formatTime12Hour` function, and `generateTimeOptions` function (lines 128-152) +4. Remove: `const timeOptions: TimeOption[] = generateTimeOptions();` (line 154) +5. Replace the Start Time Select block (lines 419-435) with: + +```tsx + setTimeData(prev => ({ ...prev, startTime: value }))} +/> +``` + +6. Replace the End Time Select block (lines 442-463) with: + +```tsx + setTimeData(prev => ({ ...prev, endTime: value }))} + disabled={!task.endTime} +/> +``` + +7. Keep the Label for End Time as-is (with the "Currently Active" text) + +**Step 2: Verify build** + +Run: `npm run build` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/components/TaskEditDialog.tsx +git commit -m "feat: use ScrollTimePicker in TaskEditDialog" +``` + +--- + +## Task 4: Integrate into ArchiveEditDialog + +**Files:** + +- Modify: `src/components/ArchiveEditDialog.tsx` + +**Step 1: Replace all Select time pickers with ScrollTimePicker** + +In `src/components/ArchiveEditDialog.tsx`: + +1. Remove Select-related imports (lines 16-22) — category and project Selects in `TaskEditInArchiveDialog` still need them, so keep the imports +2. Add import: `import { ScrollTimePicker } from "@/components/ui/scroll-time-picker";` +3. Remove: `TimeOption` type and `generateTimeOptions` function (lines 88-103) +4. Remove: `const timeOptions = generateTimeOptions();` at line 129 +5. Remove: `const timeOptions = generateTimeOptions();` at line 657 + +**In ArchiveEditDialog (day start/end):** + +6. Replace day Start Time Select (lines 361-377) with: + +```tsx + setDayData(prev => ({ ...prev, startTime: value }))} +/> +``` + +7. Replace day End Time Select (lines 383-397) with: + +```tsx + setDayData(prev => ({ ...prev, endTime: value }))} +/> +``` + +**In TaskEditInArchiveDialog (task start/end):** + +8. Replace task Start Time Select (lines 841-857) with: + +```tsx + setTimeData(prev => ({ ...prev, startTime: value }))} +/> +``` + +9. Replace task End Time Select (lines 862-878) with: + +```tsx + setTimeData(prev => ({ ...prev, endTime: value }))} +/> +``` + +**Step 2: Verify build** + +Run: `npm run build` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/components/ArchiveEditDialog.tsx +git commit -m "feat: use ScrollTimePicker in ArchiveEditDialog" +``` + +--- + +## Task 5: Final verification and cleanup + +**Step 1: Run full lint** + +Run: `npm run lint` +Expected: PASS (no new errors) + +**Step 2: Run full build** + +Run: `npm run build` +Expected: PASS + +**Step 3: Verify no remaining generateTimeOptions references** + +Search codebase for `generateTimeOptions` — should return 0 results. + +**Step 4: Manual testing checklist** + +- [ ] StartDayDialog: scroll wheel appears, can select time, starts day correctly +- [ ] TaskEditDialog: both start/end pickers work, end time disabled when task active +- [ ] ArchiveEditDialog: day start/end pickers work +- [ ] ArchiveEditDialog TaskEdit: task start/end pickers work +- [ ] Scroll-snap locks to items properly +- [ ] Mouse wheel scrolling works +- [ ] Click-to-select works +- [ ] Dark mode renders correctly +- [ ] Mobile viewport works (responsive) + +**Step 5: Update CHANGELOG.md** + +Add under `[Unreleased]` > `Changed`: + +```markdown +- Replaced time selection dropdowns with scroll-wheel time picker for better UX + — `src/components/ui/scroll-time-picker.tsx` (new), `StartDayDialog.tsx`, `TaskEditDialog.tsx`, `ArchiveEditDialog.tsx` +``` + +**Step 6: Final commit** + +```bash +git add CHANGELOG.md +git commit -m "chore: update CHANGELOG for scroll time picker" +``` diff --git a/package-lock.json b/package-lock.json index 863ff8c..a897da7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", + "baseline-browser-mapping": "^2.9.19", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", @@ -4186,7 +4187,7 @@ "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", "integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4228,7 +4229,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4245,7 +4245,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4262,7 +4261,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4279,7 +4277,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4296,7 +4293,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4313,7 +4309,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4330,7 +4325,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4347,7 +4341,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4364,7 +4357,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4381,7 +4373,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4395,14 +4386,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -5531,9 +5522,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", - "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 74103ad..a110e2f 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", + "baseline-browser-mapping": "^2.9.19", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", diff --git a/src/components/ArchiveEditDialog.tsx b/src/components/ArchiveEditDialog.tsx index c32f82b..b00d315 100644 --- a/src/components/ArchiveEditDialog.tsx +++ b/src/components/ArchiveEditDialog.tsx @@ -1,25 +1,26 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect } from 'react'; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Callout } from "@/components/ui/callout"; -import { InfoCircledIcon } from "@radix-ui/react-icons"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { MarkdownDisplay } from "@/components/MarkdownDisplay"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { Callout } from '@/components/ui/callout'; +import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { MarkdownDisplay } from '@/components/MarkdownDisplay'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { TimePicker } from '@/components/ui/scroll-time-picker'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, @@ -85,23 +86,6 @@ function formatTime12Hour(date: Date | undefined): string { return `${hours}:${minutes.toString().padStart(2, '0')} ${ampm}`; } -type TimeOption = { value: string; label: string }; -function generateTimeOptions(): TimeOption[] { - const options: TimeOption[] = []; - for (let hour = 0; hour < 24; hour++) { - for (let minute = 0; minute < 60; minute += 15) { - const value = `${hour.toString().padStart(2, '0')}:${minute - .toString() - .padStart(2, '0')}`; - const date = new Date(); - date.setHours(hour, minute, 0, 0); - const label = formatTime12Hour(date); - options.push({ value, label }); - } - } - return options; -} - export const ArchiveEditDialog: React.FC = ({ day, isOpen, @@ -126,7 +110,6 @@ export const ArchiveEditDialog: React.FC = ({ }); const [tasks, setTasks] = useState([]); - const timeOptions = generateTimeOptions(); // Initialize form data when dialog opens useEffect(() => { @@ -168,7 +151,7 @@ export const ArchiveEditDialog: React.FC = ({ const handleSaveDay = async () => { // Parse the new date from the input (same as StartDayDialog) - const [year, month, dayOfMonth] = dayData.date.split("-").map(Number); + const [year, month, dayOfMonth] = dayData.date.split('-').map(Number); const selectedDate = new Date(year, month - 1, dayOfMonth); // Create new start/end times with the selected date but original times @@ -216,8 +199,8 @@ export const ArchiveEditDialog: React.FC = ({ await updateArchivedDay(day.id, updatedDay); setIsEditing(false); } catch (error) { - console.error("Failed to save archived day:", error); - alert("Failed to save changes. Please try again."); + console.error('Failed to save archived day:', error); + alert('Failed to save changes. Please try again.'); } }; @@ -245,7 +228,7 @@ export const ArchiveEditDialog: React.FC = ({ }; const handleTaskSave = (updatedTask: Task) => { - const updatedTasks = tasks.map((t) => + const updatedTasks = tasks.map(t => t.id === updatedTask.id ? updatedTask : t ); setTasks(updatedTasks); @@ -253,7 +236,7 @@ export const ArchiveEditDialog: React.FC = ({ }; const handleTaskDelete = (taskId: string) => { - const updatedTasks = tasks.filter((t) => t.id !== taskId); + const updatedTasks = tasks.filter(t => t.id !== taskId); setTasks(updatedTasks); }; @@ -280,7 +263,6 @@ export const ArchiveEditDialog: React.FC = ({ {formatDate(day.startTime)}
-
@@ -349,85 +331,69 @@ export const ArchiveEditDialog: React.FC = ({ - setDayData((prev) => ({ ...prev, date: e.target.value })) + onChange={e => + setDayData(prev => ({ ...prev, date: e.target.value })) } className="w-full" />
- - + aria-label="Day start time" + />
- - + aria-label="Day end time" + />
-
- - - - Edit - Preview - - -