UI updates and bug fixes

This commit is contained in:
Harivansh Rathi 2024-11-21 18:02:18 -05:00
parent 20e91dae4c
commit 94c26c6b50
5 changed files with 274 additions and 244 deletions

View file

@ -30,7 +30,8 @@ function HabitTrackerContent() {
addHabit: addHabitApi, addHabit: addHabitApi,
toggleHabit, toggleHabit,
updateHabit, updateHabit,
deleteHabit deleteHabit,
error
} = useHabits(); } = useHabits();
const { const {
@ -88,26 +89,23 @@ function HabitTrackerContent() {
const renderHabitsView = () => ( const renderHabitsView = () => (
<div className="flex-1"> <div className="flex-1">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<div className="mb-8 flex items-center gap-4"> <div className="mb-8">
<input <form onSubmit={handleAddHabit} className="flex items-center gap-4">
type="text" <input
value={newHabit} type="text"
onChange={(e) => setNewHabit(e.target.value)} value={newHabit}
onKeyPress={(e) => { onChange={(e) => setNewHabit(e.target.value)}
if (e.key === 'Enter' && newHabit.trim()) { placeholder="Add a new habit"
handleAddHabit(e); className={`flex-1 px-4 py-2 rounded-lg ${theme.input}`}
} />
}} <button
placeholder="Add a new habit" type="submit"
className={`flex-1 px-4 py-2 rounded-lg ${theme.input}`} disabled={!newHabit.trim()}
/> className={`px-4 py-2 rounded-lg ${theme.button.primary} disabled:opacity-50`}
<button >
onClick={handleAddHabit} Add Habit
disabled={!newHabit.trim()} </button>
className={`px-4 py-2 rounded-lg ${theme.button.primary} disabled:opacity-50`} </form>
>
Add Habit
</button>
</div> </div>
<div className="mb-6"> <div className="mb-6">
@ -124,9 +122,7 @@ function HabitTrackerContent() {
<ChevronLeft className="h-5 w-5" /> <ChevronLeft className="h-5 w-5" />
</button> </button>
<button <button
onClick={() => { onClick={goToCurrentWeek}
setCurrentWeek(getCurrentWeekDates());
}}
className={`px-4 py-2 rounded-lg ${theme.button.secondary}`} className={`px-4 py-2 rounded-lg ${theme.button.secondary}`}
> >
Today Today
@ -141,17 +137,35 @@ function HabitTrackerContent() {
</div> </div>
</div> </div>
<HabitList {loading ? (
habits={getSortedHabits()} <div className="flex justify-center items-center py-8">
currentWeek={currentWeek} <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
daysOfWeek={DAYS_OF_WEEK} </div>
onToggleHabit={toggleHabit} ) : error ? (
onUpdateHabit={updateHabit} <div className="text-red-500 text-center py-4">
onDeleteHabit={deleteHabit} {error}
/> <button
<p className={`text-sm ${theme.mutedText} mt-4`}> onClick={fetchHabits}
Keep up the good work! Consistency is key. className="ml-2 text-blue-500 hover:underline"
</p> >
Retry
</button>
</div>
) : (
<>
<HabitList
habits={getSortedHabits()}
currentWeek={currentWeek}
daysOfWeek={DAYS_OF_WEEK}
onToggleHabit={toggleHabit}
onUpdateHabit={updateHabit}
onDeleteHabit={deleteHabit}
/>
<p className={`text-sm ${theme.mutedText} mt-4`}>
Keep up the good work! Consistency is key.
</p>
</>
)}
</div> </div>
</div> </div>
); );

View file

@ -3,15 +3,15 @@ import { Trash2 } from 'lucide-react';
import { Habit } from '../types'; import { Habit } from '../types';
import { useThemeContext } from '../contexts/ThemeContext'; import { useThemeContext } from '../contexts/ThemeContext';
import { calculateStreak } from '../utils/streakCalculator'; import { calculateStreak } from '../utils/streakCalculator';
import { getDayIndex } from '../utils/dateUtils';
interface HabitListProps { interface HabitListProps {
habits: Habit[]; habits: Habit[];
currentWeek: string[]; currentWeek: string[];
daysOfWeek: string[]; daysOfWeek: string[];
onToggleHabit: (id: number, date: string) => void; onToggleHabit: (id: number, date: string) => Promise<void>;
onUpdateHabit: (id: number, name: string) => void; onUpdateHabit: (id: number, name: string) => Promise<void>;
onDeleteHabit: (id: number) => void; onDeleteHabit: (id: number) => Promise<void>;
onUpdateStreak?: (id: number, newStreak: number) => Promise<void>;
} }
export function HabitList({ export function HabitList({
@ -22,21 +22,18 @@ export function HabitList({
onUpdateHabit, onUpdateHabit,
onDeleteHabit, onDeleteHabit,
}: HabitListProps) { }: HabitListProps) {
const { habitSort, showStreaks } = useThemeContext(); const { theme, habitSort, showStreaks } = useThemeContext();
const sortedHabits = useMemo(() => { const sortedHabits = useMemo(() => {
if (habitSort === 'alphabetical') { if (habitSort === 'alphabetical') {
return [...habits].sort((a, b) => a.name.localeCompare(b.name)); return [...habits].sort((a, b) => a.name.localeCompare(b.name));
} }
// Default to dateCreated sort
return habits; return habits;
}, [habits, habitSort]); }, [habits, habitSort]);
// Helper function to get day name
const getDayName = (dateStr: string) => { const getDayName = (dateStr: string) => {
const date = new Date(dateStr); const date = new Date(dateStr);
const dayIndex = date.getDay() === 0 ? 6 : date.getDay() - 1; return daysOfWeek[getDayIndex(date)];
return daysOfWeek[dayIndex];
}; };
return ( return (
@ -45,84 +42,88 @@ export function HabitList({
<thead> <thead>
<tr> <tr>
<th className="text-left px-4 py-2 dark:text-white">Habit</th> <th className="text-left px-4 py-2 dark:text-white">Habit</th>
{currentWeek.map((dateStr) => { {currentWeek.map((dateStr) => (
const date = new Date(dateStr); <th key={dateStr} className="px-4 py-2 text-center dark:text-white">
return ( <div className="hidden md:block">{getDayName(dateStr)}</div>
<th key={dateStr} className="px-4 py-2 text-center dark:text-white"> <div className="md:hidden">{getDayName(dateStr).slice(0, 1)}</div>
<div className="hidden md:block">{getDayName(dateStr)}</div> <div className="text-xs text-gray-500 dark:text-gray-400">
<div className="md:hidden">{getDayName(dateStr).slice(0, 1)}</div> {new Date(dateStr).getDate()}
<div className="text-xs text-gray-500 dark:text-gray-400"> </div>
{date.getDate()} </th>
</div> ))}
</th> {showStreaks && (
); <th className="px-4 py-2 text-center dark:text-white">Streak</th>
})} )}
<th className="px-4 py-2 text-center dark:text-white">Actions</th> <th className="px-4 py-2 text-center dark:text-white">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedHabits.map((habit) => ( {sortedHabits.map((habit) => {
<tr key={habit.id} className="border-t dark:border-gray-700"> const { currentStreak } = calculateStreak(habit.completedDates);
<td className="px-4 py-2 dark:text-white">
<input return (
type="text" <tr key={habit.id} className="border-t dark:border-gray-700">
value={habit.name} <td className="px-4 py-2 dark:text-white">
onChange={(e) => onUpdateHabit(habit.id, e.target.value)} <input
className="bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-gray-300 rounded px-2 w-full" type="text"
/> value={habit.name}
</td> onChange={(e) => onUpdateHabit(habit.id, e.target.value)}
{currentWeek.map((date) => ( className="bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-gray-300 rounded px-2 w-full"
<td key={date} className="px-4 py-2 text-center"> />
<label className="relative inline-block cursor-pointer">
<input
type="checkbox"
checked={habit.completedDates.includes(date)}
onChange={() => onToggleHabit(habit.id, date)}
className="sr-only"
/>
<div
className={`
w-6 h-6 rounded-md border-2 transition-all duration-200
${habit.completedDates.includes(date)
? 'bg-green-500 border-green-500'
: 'border-gray-300 dark:border-gray-600 hover:border-green-400 dark:hover:border-green-400'}
flex items-center justify-center
`}
>
{habit.completedDates.includes(date) && (
<svg
className="w-4 h-4 text-white"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7"></path>
</svg>
)}
</div>
</label>
</td> </td>
))} {currentWeek.map((date) => (
<td className="px-4 py-2 text-center"> <td key={date} className="px-4 py-2 text-center">
<button <label className="relative inline-block cursor-pointer">
onClick={() => onDeleteHabit(habit.id)} <input
className="p-2 text-red-500 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-colors duration-200" type="checkbox"
> checked={habit.completedDates.includes(date)}
<Trash2 className="h-4 w-4" /> onChange={() => onToggleHabit(habit.id, date)}
</button> className="sr-only"
</td> />
{showStreaks && ( <div
<> className={`
<td className="px-4 py-2 text-center"> w-6 h-6 rounded-md border-2 transition-all duration-200
{/* ... streak content ... */} ${habit.completedDates.includes(date)
? 'bg-green-500 border-green-500'
: 'border-gray-300 dark:border-gray-600 hover:border-green-400 dark:hover:border-green-400'}
flex items-center justify-center
`}
>
{habit.completedDates.includes(date) && (
<svg
className="w-4 h-4 text-white"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7"></path>
</svg>
)}
</div>
</label>
</td> </td>
</> ))}
)} {showStreaks && (
</tr> <td className="px-4 py-2 text-center dark:text-white">
))} <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
{currentStreak} days
</span>
</td>
)}
<td className="px-4 py-2 text-center">
<button
onClick={() => onDeleteHabit(habit.id)}
className="p-2 text-red-500 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-colors duration-200"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -3,27 +3,34 @@ import { supabase } from '../lib/supabase';
import { Habit } from '../types'; import { Habit } from '../types';
import { calculateStreak } from '../utils/streakCalculator'; import { calculateStreak } from '../utils/streakCalculator';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { formatDate } from '../utils/dateUtils';
export const useHabits = () => { export const useHabits = () => {
const [habits, setHabits] = useState<Habit[]>([]); const [habits, setHabits] = useState<Habit[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user } = useAuth(); const { user } = useAuth();
// Automatically fetch habits when user changes
useEffect(() => { useEffect(() => {
if (user) { if (user) {
fetchHabits(); fetchHabits();
} else { } else {
setHabits([]); // Clear habits when user logs out setHabits([]);
setLoading(false);
} }
}, [user?.id]); // Depend on user.id to prevent unnecessary rerenders }, [user?.id]);
const fetchHabits = async () => { const fetchHabits = async () => {
if (!user) { if (!user) {
setHabits([]); setHabits([]);
setLoading(false);
return; return;
} }
try { try {
setLoading(true);
setError(null);
const { data, error } = await supabase const { data, error } = await supabase
.from('habits') .from('habits')
.select(` .select(`
@ -40,7 +47,7 @@ export const useHabits = () => {
if (error) throw error; if (error) throw error;
const formattedHabits = (data || []).map(habit => ({ const formattedHabits = data.map(habit => ({
id: habit.id, id: habit.id,
name: habit.name, name: habit.name,
created_at: habit.created_at, created_at: habit.created_at,
@ -51,157 +58,144 @@ export const useHabits = () => {
})); }));
setHabits(formattedHabits); setHabits(formattedHabits);
} catch (error) { } catch (err) {
console.error('Error fetching habits:', error); console.error('Error fetching habits:', err);
setHabits([]); setError(err instanceof Error ? err.message : 'Failed to fetch habits');
} finally {
setLoading(false);
} }
}; };
const addHabit = async (name: string) => { const addHabit = async (name: string): Promise<boolean> => {
if (!user) return false; if (!user || !name.trim()) return false;
try { try {
const { data, error } = await supabase const { data, error } = await supabase
.from('habits') .from('habits')
.insert([{ .insert([{
name, name: name.trim(),
best_streak: 0, user_id: user.id,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
user_id: user.id best_streak: 0
}]) }])
.select('id') .select()
.single(); .single();
if (error) throw error; if (error) throw error;
await fetchHabits(); await fetchHabits();
return true; return true;
} catch (error) { } catch (err) {
console.error('Error adding habit:', error); console.error('Error adding habit:', err);
setError(err instanceof Error ? err.message : 'Failed to add habit');
return false; return false;
} }
}; };
const toggleHabit = async (id: number, date: string) => { const toggleHabit = async (id: number, date: string): Promise<void> => {
if (!user) return; if (!user) return;
try { try {
// First verify this habit belongs to the user const isCompleted = habits
const { data: habitData, error: habitError } = await supabase .find(h => h.id === id)
.from('habits') ?.completedDates.includes(date);
.select('id')
.eq('id', id)
.eq('user_id', user.id)
.single();
if (habitError || !habitData) { if (isCompleted) {
throw new Error('Unauthorized access to habit'); // Remove completion
} const { error } = await supabase
// Keep the original date string without any timezone conversion
const formattedDate = date;
console.log('Toggling habit:', { id, date, formattedDate });
// Check for existing completion
const { data: existing, error: existingError } = await supabase
.from('habit_completions')
.select('*')
.eq('habit_id', id)
.eq('completion_date', formattedDate)
.eq('user_id', user.id)
.single();
if (existingError && existingError.code !== 'PGRST116') {
throw existingError;
}
if (existing) {
const { error: deleteError } = await supabase
.from('habit_completions') .from('habit_completions')
.delete() .delete()
.eq('habit_id', id) .eq('habit_id', id)
.eq('completion_date', formattedDate) .eq('completion_date', date);
.eq('user_id', user.id);
if (deleteError) throw deleteError; if (error) throw error;
} else { } else {
const { error: insertError } = await supabase // Add completion
const { error } = await supabase
.from('habit_completions') .from('habit_completions')
.insert([{ .insert({
habit_id: id, habit_id: id,
completion_date: formattedDate, completion_date: date,
user_id: user.id user_id: user.id
}]); });
if (insertError) throw insertError; if (error) throw error;
} }
// Update local state optimistically
setHabits(prevHabits =>
prevHabits.map(h => {
if (h.id !== id) return h;
return {
...h,
completedDates: isCompleted
? h.completedDates.filter(d => d !== date)
: [...h.completedDates, date]
};
})
);
// Fetch updated data to ensure consistency
await fetchHabits();
} catch (err) {
console.error('Error toggling habit:', err);
setError(err instanceof Error ? err.message : 'Failed to toggle habit');
await fetchHabits(); await fetchHabits();
} catch (error) {
console.error('Error toggling habit:', error);
throw error;
} }
}; };
const updateHabit = async (id: number, name: string) => { const updateHabit = async (id: number, name: string): Promise<void> => {
if (!user) return; if (!user || !name.trim()) return;
try { try {
// First verify this habit belongs to the user const { error } = await supabase
const { data: habitData, error: habitError } = await supabase
.from('habits') .from('habits')
.select('id') .update({ name: name.trim() })
.eq('id', id)
.eq('user_id', user.id)
.single();
if (habitError || !habitData) {
throw new Error('Unauthorized access to habit');
}
await supabase
.from('habits')
.update({ name })
.eq('id', id) .eq('id', id)
.eq('user_id', user.id); .eq('user_id', user.id);
if (error) throw error;
// Update local state optimistically
setHabits(prevHabits =>
prevHabits.map(h =>
h.id === id ? { ...h, name: name.trim() } : h
)
);
} catch (err) {
console.error('Error updating habit:', err);
setError(err instanceof Error ? err.message : 'Failed to update habit');
// Revert optimistic update on error
await fetchHabits(); await fetchHabits();
} catch (error) {
console.error('Error updating habit:', error);
} }
}; };
const deleteHabit = async (id: number) => { const deleteHabit = async (id: number): Promise<void> => {
if (!user) return; if (!user) return;
try { try {
// First verify this habit belongs to the user const { error } = await supabase
const { data: habitData, error: habitError } = await supabase
.from('habits')
.select('id')
.eq('id', id)
.eq('user_id', user.id)
.single();
if (habitError || !habitData) {
throw new Error('Unauthorized access to habit');
}
await supabase
.from('habits') .from('habits')
.delete() .delete()
.eq('id', id) .eq('id', id)
.eq('user_id', user.id); .eq('user_id', user.id);
if (error) throw error;
// Update local state optimistically
setHabits(prevHabits => prevHabits.filter(h => h.id !== id));
} catch (err) {
console.error('Error deleting habit:', err);
setError(err instanceof Error ? err.message : 'Failed to delete habit');
// Revert optimistic update on error
await fetchHabits(); await fetchHabits();
} catch (error) {
console.error('Error deleting habit:', error);
} }
}; };
return { return {
habits, habits,
loading,
error,
fetchHabits, fetchHabits,
addHabit, addHabit,
toggleHabit, toggleHabit,

View file

@ -1,43 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { getWeekDates, formatDate } from '../utils/dateUtils';
export const useWeek = () => { export const useWeek = () => {
const [currentWeek, setCurrentWeek] = useState<string[]>([]); const [currentWeek, setCurrentWeek] = useState<string[]>(getWeekDates());
const getCurrentWeekDates = () => { const getCurrentWeekDates = () => getWeekDates();
const now = new Date();
const currentDay = now.getDay();
const diff = currentDay === 0 ? -6 : 1 - currentDay;
const monday = new Date(now);
monday.setDate(now.getDate() + diff);
monday.setHours(0, 0, 0, 0);
return Array.from({ length: 7 }, (_, i) => {
const date = new Date(monday);
date.setDate(monday.getDate() + i);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
});
};
const changeWeek = (direction: 'prev' | 'next') => { const changeWeek = (direction: 'prev' | 'next') => {
if (currentWeek.length === 0) return; setCurrentWeek(prevWeek => {
const firstDay = new Date(prevWeek[0]);
const firstDay = new Date(currentWeek[0]); firstDay.setDate(firstDay.getDate() + (direction === 'prev' ? -7 : 7));
firstDay.setHours(0, 0, 0, 0); return getWeekDates(firstDay);
const newFirstDay = new Date(firstDay); });
newFirstDay.setDate(firstDay.getDate() + (direction === 'prev' ? -7 : 7));
setCurrentWeek(Array.from({ length: 7 }, (_, i) => {
const date = new Date(newFirstDay);
date.setDate(newFirstDay.getDate() + i);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}));
}; };
return { return {

47
src/utils/dateUtils.ts Normal file
View file

@ -0,0 +1,47 @@
// Simple date formatting without timezone complications
export const formatDate = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Get day index (0-6) where Monday is 0 and Sunday is 6
export const getDayIndex = (date: Date): number => {
const day = date.getDay();
return day === 0 ? 6 : day - 1;
};
// Get dates for current week starting from Monday
export const getWeekDates = (baseDate: Date = new Date()): string[] => {
const current = new Date(baseDate);
const dayIndex = getDayIndex(current);
// Adjust to Monday
current.setDate(current.getDate() - dayIndex);
// Generate dates starting from Monday
const dates = [];
for (let i = 0; i < 7; i++) {
const date = new Date(current);
date.setDate(current.getDate() + i);
dates.push(formatDate(date));
}
return dates;
};
// Get the first day of the month adjusted for Monday start
export const getFirstDayOfMonth = (year: number, month: number): number => {
const date = new Date(year, month, 1);
return getDayIndex(date);
};
// Check if two dates are the same day
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getDate() === date2.getDate() &&
date1.getMonth() === date2.getMonth() &&
date1.getFullYear() === date2.getFullYear()
);
};