From 685cb80321b8a595994698656426c1008d8c0ebb Mon Sep 17 00:00:00 2001 From: Doh Date: Thu, 10 Apr 2025 16:47:59 -0400 Subject: [PATCH] add support for habit pinning (#105) --- CHANGELOG.md | 8 + components/DailyOverview.tsx | 715 +++++++++++++++-------------------- components/HabitItem.tsx | 22 +- components/HabitList.tsx | 4 +- lib/types.ts | 3 +- package.json | 2 +- 6 files changed, 342 insertions(+), 412 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 250920c..693cb34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Version 0.2.7 + +### Added + +* visual pin indicators for pinned habits/tasks +* pin/unpin options in context menus +* support click and right-click context menu in dailyoverview + ## Version 0.2.6 ### Added diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index d070f62..b3c527e 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -1,4 +1,4 @@ -import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react' +import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus, Pin, Calendar } from 'lucide-react' import CompletionCountBadge from './CompletionCountBadge' import { ContextMenu, @@ -28,6 +28,283 @@ interface UpcomingItemsProps { coinBalance: number } +interface ItemSectionProps { + title: string; + items: Habit[]; + emptyMessage: string; + isTask: boolean; + viewLink: string; + expanded: boolean; + setExpanded: (value: boolean) => void; + addNewItem: () => void; + badgeType: "tasks" | "habits"; + todayCompletions: Habit[]; + settings: any; + setBrowserSettings: (value: React.SetStateAction) => void; +} + +const ItemSection = ({ + title, + items, + emptyMessage, + isTask, + viewLink, + expanded, + setExpanded, + addNewItem, + badgeType, + todayCompletions, + settings, + setBrowserSettings, +}: ItemSectionProps) => { + const { completeHabit, undoComplete, saveHabit } = useHabits(); + const [_, setPomo] = useAtom(pomodoroAtom); + + if (items.length === 0) { + return ( +
+
+

{title}

+ +
+
+ {emptyMessage} +
+
+ ); + } + + return ( +
+
+
+

{title}

+
+
+ + +
+
+
    + {items + .sort((a, b) => { + // First by pinned status + if (a.pinned !== b.pinned) { + return a.pinned ? -1 : 1; + } + + // Then by completion status + const aCompleted = todayCompletions.includes(a); + const bCompleted = todayCompletions.includes(b); + if (aCompleted !== bCompleted) { + return aCompleted ? 1 : -1; + } + + // Then by frequency (daily first) + const aFreq = getHabitFreq(a); + const bFreq = getHabitFreq(b); + const freqOrder = ['daily', 'weekly', 'monthly', 'yearly']; + if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) { + return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq); + } + + // Then by coin reward (higher first) + if (a.coinReward !== b.coinReward) { + return b.coinReward - a.coinReward; + } + + // Finally by target completions (higher first) + const aTarget = a.targetCompletions || 1; + const bTarget = b.targetCompletions || 1; + return bTarget - aTarget; + }) + .slice(0, expanded ? 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 })) + ).length + const target = habit.targetCompletions || 1 + const isCompleted = completionsToday >= target || (isTask && habit.archived) + return ( +
  • + + + +
    +
    + +
    + + {habit.pinned && ( + + )} + + {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 + + )} + + +
    +
    + + {habit.targetCompletions && ( + + {completionsToday}/{target} + + )} + {getHabitFreq(habit) !== 'daily' && ( + + {getHabitFreq(habit)} + + )} + + + + {habit.coinReward} + + + +
  • + ) + })} +
+
+ + { + if (isTask) { + setBrowserSettings(prev => ({ ...prev, viewType: 'tasks' })); + } else { + setBrowserSettings(prev => ({ ...prev, viewType: 'habits' })); + } + }} + > + View + + +
+
+ ); +}; + export default function DailyOverview({ habits, wishlistItems, @@ -37,12 +314,12 @@ export default function DailyOverview({ 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 [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom) // Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost // Filter out archived wishlist items @@ -80,414 +357,38 @@ export default function DailyOverview({
{/* Tasks Section */} - {hasTasks && dailyTasks.length === 0 ? ( -
-
-

Daily Tasks

- -
-
- No tasks due today. Add some tasks to get started! -
-
- ) : hasTasks && ( -
-
-
-

Daily Tasks

-
-
- - -
-
-
    - {dailyTasks - .sort((a, b) => { - // First by completion status - const aCompleted = todayCompletions.includes(a); - const bCompleted = todayCompletions.includes(b); - if (aCompleted !== bCompleted) { - return aCompleted ? 1 : -1; - } - - // Then by frequency (daily first) - const aFreq = getHabitFreq(a); - const bFreq = getHabitFreq(b); - const freqOrder = ['daily', 'weekly', 'monthly', 'yearly']; - if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) { - return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq); - } - - // Then by coin reward (higher first) - if (a.coinReward !== b.coinReward) { - return b.coinReward - a.coinReward; - } - - // Finally by target completions (higher first) - const aTarget = a.targetCompletions || 1; - const bTarget = b.targetCompletions || 1; - return bTarget - aTarget; - }) - .slice(0, browserSettings.expandedTasks ? 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 })) - ).length - const target = habit.targetCompletions || 1 - const isCompleted = completionsToday >= target || (habit.isTask && habit.archived) - return ( -
  • - - - -
    - -
    -
    - - - {habit.name} - - - - { - setPomo((prev) => ({ - ...prev, - show: true, - selectedHabitId: habit.id - })) - }}> - - Start Pomodoro - - -
    -
    - - {habit.targetCompletions && ( - - {completionsToday}/{target} - - )} - {getHabitFreq(habit) !== 'daily' && ( - - {getHabitFreq(habit)} - - )} - - - - {habit.coinReward} - - - -
  • - ) - })} -
-
- - setBrowserSettings(prev => ({ ...prev, viewType: 'tasks' }))} - > - View - - -
-
+ {hasTasks && ( + setBrowserSettings(prev => ({ ...prev, expandedTasks: value }))} + addNewItem={() => setModalConfig({ isOpen: true, isTask: true })} + badgeType="tasks" + todayCompletions={todayCompletions} + settings={settings} + setBrowserSettings={setBrowserSettings} + /> )} {/* Habits Section */} - {dailyHabits.length === 0 ? ( -
-
-

Daily Habits

- -
-
- No habits due today. Add some habits to get started! -
-
- ) : ( -
-
-
-

Daily Habits

-
-
- - -
-
-
    - {dailyHabits - .sort((a, b) => { - // First by completion status - const aCompleted = todayCompletions.includes(a); - const bCompleted = todayCompletions.includes(b); - if (aCompleted !== bCompleted) { - return aCompleted ? 1 : -1; - } - - // Then by frequency (daily first) - const aFreq = getHabitFreq(a); - const bFreq = getHabitFreq(b); - const freqOrder = ['daily', 'weekly', 'monthly', 'yearly']; - if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) { - return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq); - } - - // Then by coin reward (higher first) - if (a.coinReward !== b.coinReward) { - return b.coinReward - a.coinReward; - } - - // Finally by target completions (higher first) - const aTarget = a.targetCompletions || 1; - const bTarget = b.targetCompletions || 1; - return bTarget - aTarget; - }) - .slice(0, browserSettings.expandedHabits ? 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 })) - ).length - const target = habit.targetCompletions || 1 - const isCompleted = completionsToday >= target - return ( -
  • - - - -
    - -
    -
    - - - {habit.name} - - - - { - setPomo((prev) => ({ - ...prev, - show: true, - selectedHabitId: habit.id - })) - }}> - - Start Pomodoro - - -
    -
    - - {habit.targetCompletions && ( - - {completionsToday}/{target} - - )} - {getHabitFreq(habit) !== 'daily' && ( - - {getHabitFreq(habit)} - - )} - - - - {habit.coinReward} - - - -
  • - ) - })} -
-
- - setBrowserSettings(prev => ({ ...prev, viewType: 'habits' }))} - > - View - - -
-
- )} + setBrowserSettings(prev => ({ ...prev, expandedHabits: value }))} + addNewItem={() => setModalConfig({ isOpen: true, isTask: false })} + badgeType="habits" + todayCompletions={todayCompletions} + settings={settings} + setBrowserSettings={setBrowserSettings} + />
diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index 81856af..14e260a 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 } from 'lucide-react' +import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar, Pin } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -88,7 +88,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
- {habit.name} +
+ {habit.pinned && ( + + )} + {habit.name} +
{isTaskOverdue(habit, settings.system.timezone) && ( Overdue @@ -212,6 +217,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { Move to Today )} + saveHabit({...habit, pinned: !habit.pinned})}> + {habit.pinned ? ( + <> + + Unpin + + ) : ( + <> + + Pin + + )} + archiveHabit(habit.id)}> Archive diff --git a/components/HabitList.tsx b/components/HabitList.tsx index e6e187d..3e19f66 100644 --- a/components/HabitList.tsx +++ b/components/HabitList.tsx @@ -22,7 +22,9 @@ export default function HabitList() { const habits = habitsData.habits.filter(habit => isTasksView ? habit.isTask : !habit.isTask ) - const activeHabits = habits.filter(h => !h.archived) + 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 [modalConfig, setModalConfig] = useState<{ diff --git a/lib/types.ts b/lib/types.ts index d4f2bbb..6022d6a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -44,6 +44,7 @@ export type Habit = { completions: string[] // Array of UTC ISO date strings isTask?: boolean // mark the habit as a task archived?: boolean // mark the habit as archived + pinned?: boolean // mark the habit as pinned userIds?: UserId[] } @@ -197,4 +198,4 @@ export type ParsedResultType = DateTime | RRule | string | null // null if export interface ParsedFrequencyResult { message: string | null result: ParsedResultType -} \ No newline at end of file +} diff --git a/package.json b/package.json index 544ec31..47d2ba6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.2.6", + "version": "0.2.7", "private": true, "scripts": { "dev": "next dev --turbopack",