From 660005d8579ec6892757c3d2ca3085969512051e Mon Sep 17 00:00:00 2001 From: Doh Date: Sat, 10 May 2025 15:51:39 -0400 Subject: [PATCH] Show overdue tasks and improved context menu (#110) --- CHANGELOG.md | 14 ++ components/DailyOverview.tsx | 208 ++++++++++++++++----------- components/HabitContextMenuItems.tsx | 157 ++++++++++++++++++++ components/HabitItem.tsx | 73 ++-------- components/HabitList.tsx | 143 ++++++++++++++++-- components/WishlistManager.tsx | 6 +- package.json | 2 +- 7 files changed, 442 insertions(+), 161 deletions(-) create mode 100644 components/HabitContextMenuItems.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b47b2e8..6b9353e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Version 0.2.11 + +### Added + +* support searching and sorting in habit list + +### Improved + +* Show overdue tasks in daily overview +* Context menu option for tasks changed from "Move to Today" to "Move to Tomorrow" +* More context menu items in daily overview +* code refactor for context menu and daily overview item section + + ## Version 0.2.10 ### Improved diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index 59b7c5d..cfd49cf 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -1,26 +1,35 @@ -import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus, Pin, Calendar } from 'lucide-react' +import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Plus, Pin, AlertTriangle } from 'lucide-react' // Removed unused icons import CompletionCountBadge from './CompletionCountBadge' import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu" import { cn } from '@/lib/utils' import Link from 'next/link' import { useState } from 'react' import { useAtom } from 'jotai' -import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom, dailyHabitsAtom } from '@/lib/atoms' -import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils' +import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms' +import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, isHabitDue, isTaskOverdue } from '@/lib/utils' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" import { Progress } from '@/components/ui/progress' import { Settings, WishlistItemType } from '@/lib/types' import { Habit } from '@/lib/types' import Linkify from './linkify' import { useHabits } from '@/hooks/useHabits' import AddEditHabitModal from './AddEditHabitModal' +import ConfirmDialog from './ConfirmDialog' import { Button } from './ui/button' +import { HabitContextMenuItems } from './HabitContextMenuItems' interface UpcomingItemsProps { habits: Habit[] @@ -34,13 +43,7 @@ interface ItemSectionProps { emptyMessage: string; isTask: boolean; viewLink: string; - expanded: boolean; - setExpanded: (value: boolean) => void; addNewItem: () => void; - badgeType: "tasks" | "habits"; - todayCompletions: Habit[]; - settings: Settings; - setBrowserSettings: (value: React.SetStateAction) => void; } const ItemSection = ({ @@ -49,16 +52,46 @@ const ItemSection = ({ emptyMessage, isTask, viewLink, - expanded, - setExpanded, addNewItem, - badgeType, - todayCompletions, - settings, - setBrowserSettings, }: ItemSectionProps) => { - const { completeHabit, undoComplete, saveHabit, habitFreqMap } = useHabits(); + const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits(); const [_, setPomo] = useAtom(pomodoroAtom); + const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom); + const [settings] = useAtom(settingsAtom); + const [completedHabitsMap] = useAtom(completedHabitsMapAtom); + + const today = getTodayInTimezone(settings.system.timezone); + const currentTodayCompletions = completedHabitsMap.get(today) || []; + const currentBadgeType = isTask ? 'tasks' : 'habits'; + + const currentExpanded = isTask ? browserSettings.expandedTasks : browserSettings.expandedHabits; + const setCurrentExpanded = (value: boolean) => { + setBrowserSettings(prev => ({ + ...prev, + [isTask ? 'expandedTasks' : 'expandedHabits']: value + })); + }; + + const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(false); + const [habitToDelete, setHabitToDelete] = useState(null); + const [habitToEdit, setHabitToEdit] = useState(null); + + const handleDeleteClick = (habit: Habit) => { + setHabitToDelete(habit); + setIsConfirmDeleteDialogOpen(true); + }; + + const confirmDelete = async () => { + if (habitToDelete) { + await deleteHabit(habitToDelete.id); + setHabitToDelete(null); + setIsConfirmDeleteDialogOpen(false); + } + }; + + const handleEditClick = (habit: Habit) => { + setHabitToEdit(habit); + }; if (items.length === 0) { return ( @@ -89,7 +122,7 @@ const ItemSection = ({

{title}

- +
-
    +
      {items .sort((a, b) => { // First by pinned status @@ -110,8 +143,8 @@ const ItemSection = ({ } // Then by completion status - const aCompleted = todayCompletions.includes(a); - const bCompleted = todayCompletions.includes(b); + const aCompleted = currentTodayCompletions.includes(a); + const bCompleted = currentTodayCompletions.includes(b); if (aCompleted !== bCompleted) { return aCompleted ? 1 : -1; } @@ -134,7 +167,7 @@ const ItemSection = ({ const bTarget = b.targetCompletions || 1; return bTarget - aTarget; }) - .slice(0, expanded ? undefined : 5) + .slice(0, currentExpanded ? undefined : 5) .map((habit) => { const completionsToday = habit.completions.filter(completion => isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone })) @@ -190,50 +223,46 @@ const ItemSection = ({ )} { + const newViewType = isTask ? 'tasks' : 'habits'; + if (browserSettings.viewType !== newViewType) { + setBrowserSettings(prev => ({ ...prev, viewType: newViewType })); + } + }} > - {habit.name} + {isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && ( + + + + {/* The AlertTriangle itself doesn't need hover styles if the parent Link handles it */} + + + +

      Overdue

      +
      +
      +
      + )} + + {habit.name} + - { - setPomo((prev) => ({ - ...prev, - show: true, - selectedHabitId: habit.id - })) - }}> - - Start Pomodoro - - {habit.isTask && ( - { - saveHabit({ ...habit, frequency: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }) }) - }}> - - Move to Today - - )} - { - saveHabit({ ...habit, pinned: !habit.pinned }) - }}> - {habit.pinned ? ( - <> - - Unpin - - ) : ( - <> - - Pin - - )} - + handleEditClick(habit)} + onDeleteRequest={() => handleDeleteClick(habit)} + context="daily-overview" + /> @@ -271,10 +300,10 @@ const ItemSection = ({
    + {habitToDelete && ( + setIsConfirmDeleteDialogOpen(false)} + onConfirm={confirmDelete} + title={`Delete ${isTask ? 'Task' : 'Habit'}`} + message={`Are you sure you want to delete "${habitToDelete.name}"? This action cannot be undone.`} + confirmText="Delete" + /> + )} + {habitToEdit && ( + setHabitToEdit(null)} + onSave={async (updatedHabit) => { + await saveHabit({ ...habitToEdit, ...updatedHabit }); + setHabitToEdit(null); + }} + habit={habitToEdit} + isTask={habitToEdit.isTask || false} + /> + )} ); }; @@ -313,14 +362,25 @@ export default function DailyOverview({ const { completeHabit, undoComplete } = useHabits() const [settings] = useAtom(settingsAtom) const [completedHabitsMap] = useAtom(completedHabitsMapAtom) - const [dailyItems] = useAtom(dailyHabitsAtom) const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom) - const dailyTasks = dailyItems.filter(habit => habit.isTask) - const dailyHabits = dailyItems.filter(habit => !habit.isTask) const today = getTodayInTimezone(settings.system.timezone) const todayCompletions = completedHabitsMap.get(today) || [] const { saveHabit } = useHabits() + const timezone = settings.system.timezone + const todayDateObj = getNow({ timezone }) + + const dailyTasks = habits.filter(habit => + habit.isTask && + !habit.archived && + (isHabitDue({ habit, timezone, date: todayDateObj }) || isTaskOverdue(habit, timezone)) + ) + const dailyHabits = habits.filter(habit => + !habit.isTask && + !habit.archived && + isHabitDue({ habit, timezone, date: todayDateObj }) + ) + // Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost // Filter out archived wishlist items const sortedWishlistItems = wishlistItems @@ -364,13 +424,7 @@ export default function DailyOverview({ emptyMessage="No tasks due today. Add some tasks to get started!" isTask={true} viewLink="/habits?view=tasks" - expanded={browserSettings.expandedTasks} - setExpanded={(value) => setBrowserSettings(prev => ({ ...prev, expandedTasks: value }))} addNewItem={() => setModalConfig({ isOpen: true, isTask: true })} - badgeType="tasks" - todayCompletions={todayCompletions} - settings={settings} - setBrowserSettings={setBrowserSettings} /> )} @@ -381,13 +435,7 @@ export default function DailyOverview({ emptyMessage="No habits due today. Add some habits to get started!" isTask={false} viewLink="/habits" - expanded={browserSettings.expandedHabits} - setExpanded={(value) => setBrowserSettings(prev => ({ ...prev, expandedHabits: value }))} addNewItem={() => setModalConfig({ isOpen: true, isTask: false })} - badgeType="habits" - todayCompletions={todayCompletions} - settings={settings} - setBrowserSettings={setBrowserSettings} />
    diff --git a/components/HabitContextMenuItems.tsx b/components/HabitContextMenuItems.tsx new file mode 100644 index 0000000..6aef003 --- /dev/null +++ b/components/HabitContextMenuItems.tsx @@ -0,0 +1,157 @@ +import { Habit } from '@/lib/types'; +import { useHabits } from '@/hooks/useHabits'; +import { useAtom } from 'jotai'; +import { pomodoroAtom, settingsAtom } from '@/lib/atoms'; +import { d2t, getNow, isHabitDueToday } from '@/lib/utils'; +import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; +import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'; +import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react'; +import { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most + +interface HabitContextMenuItemsProps { + habit: Habit; + onEditRequest: () => void; + onDeleteRequest: () => void; + context?: 'daily-overview' | 'habit-item'; + onClose?: () => void; // Optional: To close the dropdown if an action is taken +} + +export function HabitContextMenuItems({ + habit, + onEditRequest, + onDeleteRequest, + context = 'habit-item', + onClose, +}: HabitContextMenuItemsProps) { + const { saveHabit, archiveHabit, unarchiveHabit } = useHabits(); + const [settings] = useAtom(settingsAtom); + const [, setPomo] = useAtom(pomodoroAtom); + const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions + + const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions + const canInteract = hasPermission('habit', 'interact'); + + const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem; + const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator; + + const taskIsDueToday = habit.isTask ? isHabitDueToday({ habit, timezone: settings.system.timezone }) : false; + + const handleAction = (action: () => void) => { + action(); + onClose?.(); + }; + + return ( + <> + {!habit.archived && ( + handleAction(() => { + setPomo((prev) => ({ + ...prev, + show: true, + selectedHabitId: habit.id, + })); + })} + > + + Start Pomodoro + + )} + + {/* "Move to Today" option: Show if task is not due today */} + {habit.isTask && !habit.archived && !taskIsDueToday && ( + handleAction(() => { + const today = getNow({ timezone: settings.system.timezone }); + saveHabit({ ...habit, frequency: d2t({ dateTime: today }) }); + })} + > + + Move to Today + + )} + + {/* "Move to Tomorrow" option: Show if task is due today OR not due today */} + {habit.isTask && !habit.archived && ( + handleAction(() => { + const tomorrow = getNow({ timezone: settings.system.timezone }).plus({ days: 1 }); + saveHabit({ ...habit, frequency: d2t({ dateTime: tomorrow }) }); + })} + > + + Move to Tomorrow + + )} + + {!habit.archived && ( + handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))} + > + + {habit.pinned ? 'Unpin' : 'Pin'} + + )} + + {context === 'habit-item' && !habit.archived && ( // Edit button visible in dropdown only for habit-item context on small screens + handleAction(onEditRequest)} + className="sm:hidden" // Kept the sm:hidden for HabitItem specific responsive behavior + disabled={!canWrite} + > + + Edit + + )} + + {context === 'daily-overview' && !habit.archived && ( // Edit button always visible in dropdown for daily-overview context + handleAction(onEditRequest)} + disabled={!canWrite} + > + + Edit + + )} + + + {!habit.archived && ( + handleAction(() => archiveHabit(habit.id))} + > + + Archive + + )} + + {habit.archived && ( + handleAction(() => unarchiveHabit(habit.id))} + > + + Unarchive + + )} + + {context === 'habit-item' && !habit.archived && } + + {(context === 'daily-overview' || habit.archived) && } + + + handleAction(onDeleteRequest)} + className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400" + disabled={!canWrite} // Assuming delete is a write operation + > + + Delete + + + ); +} diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index 14e260a..4b6165f 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -4,7 +4,7 @@ import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/li import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar, Pin } from 'lucide-react' +import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react' // Removed unused icons import { DropdownMenu, DropdownMenuContent, @@ -18,6 +18,7 @@ import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants' import { DateTime } from 'luxon' import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' import { useHelpers } from '@/lib/client-helpers' +import { HabitContextMenuItems } from './HabitContextMenuItems' interface HabitItemProps { habit: Habit @@ -194,70 +195,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { - {!habit.archived && ( - { - if (!canInteract) return - setPomo((prev) => ({ - ...prev, - show: true, - selectedHabitId: habit.id - })) - }}> - - Start Pomodoro - - )} - {!habit.archived && ( - <> - {habit.isTask && ( - { - saveHabit({...habit, frequency: d2t({ dateTime: getNow({ timezone: settings.system.timezone })})}) - }}> - - Move to Today - - )} - saveHabit({...habit, pinned: !habit.pinned})}> - {habit.pinned ? ( - <> - - Unpin - - ) : ( - <> - - Pin - - )} - - archiveHabit(habit.id)}> - - Archive - - - )} - {habit.archived && ( - unarchiveHabit(habit.id)}> - - Unarchive - - )} - - - Edit - - - - - Delete - +
    diff --git a/components/HabitList.tsx b/components/HabitList.tsx index 3e19f66..1b4af51 100644 --- a/components/HabitList.tsx +++ b/components/HabitList.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState } from 'react' -import { Plus, ListTodo } from 'lucide-react' +import { useState, useMemo, useEffect } from 'react' // Added useMemo, useEffect +import { Plus, ArrowUpNarrowWide, ArrowDownWideNarrow, Search } from 'lucide-react' // Added sort icons, Search icon import { useAtom } from 'jotai' import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms' import EmptyState from './EmptyState' @@ -13,20 +13,100 @@ import { Habit } from '@/lib/types' import { useHabits } from '@/hooks/useHabits' import { HabitIcon, TaskIcon } from '@/lib/constants' import { ViewToggle } from './ViewToggle' +import { Input } from '@/components/ui/input' // Added +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' // Added +import { Label } from '@/components/ui/label' // Added +import { DateTime } from 'luxon' // Added +import { getHabitFreq } from '@/lib/utils' // Added export default function HabitList() { const { saveHabit, deleteHabit } = useHabits() - const [habitsData, setHabitsData] = useAtom(habitsAtom) + const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used const [browserSettings] = useAtom(browserSettingsAtom) const isTasksView = browserSettings.viewType === 'tasks' - const habits = habitsData.habits.filter(habit => - isTasksView ? habit.isTask : !habit.isTask - ) - const activeHabits = habits - .filter(h => !h.archived) - .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)) - const archivedHabits = habits.filter(h => h.archived) - const [settings] = useAtom(settingsAtom) + // const [settings] = useAtom(settingsAtom); // settingsAtom is not directly used in HabitList itself. + + type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency'; + type SortOrder = 'asc' | 'desc'; + + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState('name'); + const [sortOrder, setSortOrder] = useState('asc'); + + useEffect(() => { + if (isTasksView && sortBy === 'frequency') { + setSortBy('name'); + } else if (!isTasksView && sortBy === 'dueDate') { + setSortBy('name'); + } + }, [isTasksView, sortBy]); + + const compareHabits = useMemo(() => { + return (a: Habit, b: Habit, currentSortBy: SortableField, currentSortOrder: SortOrder, tasksView: boolean): number => { + let comparison = 0; + switch (currentSortBy) { + case 'name': + comparison = a.name.localeCompare(b.name); + break; + case 'coinReward': + comparison = a.coinReward - b.coinReward; + break; + case 'dueDate': + if (tasksView && a.isTask && b.isTask) { + const dateA = DateTime.fromISO(a.frequency); + const dateB = DateTime.fromISO(b.frequency); + if (dateA.isValid && dateB.isValid) comparison = dateA.toMillis() - dateB.toMillis(); + else if (dateA.isValid) comparison = -1; // Valid dates first + else if (dateB.isValid) comparison = 1; + // If both invalid, comparison remains 0 + } + break; + case 'frequency': + if (!tasksView && !a.isTask && !b.isTask) { + const freqOrder = ['daily', 'weekly', 'monthly', 'yearly']; + const freqAVal = getHabitFreq(a); + const freqBVal = getHabitFreq(b); + comparison = freqOrder.indexOf(freqAVal) - freqOrder.indexOf(freqBVal); + } + break; + } + return currentSortOrder === 'asc' ? comparison : -comparison; + }; + }, []); + + const allHabitsInView = useMemo(() => { + return habitsData.habits.filter(habit => + isTasksView ? habit.isTask : !habit.isTask + ); + }, [habitsData.habits, isTasksView]); + + const searchedHabits = useMemo(() => { + if (!searchTerm.trim()) { + return allHabitsInView; + } + const lowercasedSearchTerm = searchTerm.toLowerCase(); + return allHabitsInView.filter(habit => + habit.name.toLowerCase().includes(lowercasedSearchTerm) || + (habit.description && habit.description.toLowerCase().includes(lowercasedSearchTerm)) + ); + }, [allHabitsInView, searchTerm]); + + const activeHabits = useMemo(() => { + return searchedHabits + .filter(h => !h.archived) + .sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + // For items in the same pinned group (both pinned or both not pinned), apply general sort + return compareHabits(a, b, sortBy, sortOrder, isTasksView); + }); + }, [searchedHabits, sortBy, sortOrder, isTasksView, compareHabits]); + + const archivedHabits = useMemo(() => { + return searchedHabits + .filter(h => h.archived) + .sort((a, b) => compareHabits(a, b, sortBy, sortOrder, isTasksView)); + }, [searchedHabits, sortBy, sortOrder, isTasksView, compareHabits]); const [modalConfig, setModalConfig] = useState<{ isOpen: boolean, isTask: boolean @@ -59,8 +139,47 @@ export default function HabitList() {
    + + {/* Search and Sort Controls */} +
    +
    +
    + +
    + setSearchTerm(e.target.value)} + className="pl-10 w-full" + /> +
    +
    + + + +
    +
    +
    - {activeHabits.length === 0 ? ( + {activeHabits.length === 0 && searchTerm.trim() ? ( +
    + No {isTasksView ? 'tasks' : 'habits'} found matching your search. +
    + ) : activeHabits.length === 0 ? (
    Add Reward
    -
    +
    {activeItems.length === 0 ? ( -
    +
    0 && ( <> -
    +
    Archived
    diff --git a/package.json b/package.json index 6611e93..32ab2b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.2.10", + "version": "0.2.11", "private": true, "scripts": { "dev": "next dev --turbopack",