mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-20 22:24:28 +01:00
enable completing past habit
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## Version 0.1.22
|
||||
|
||||
### Added
|
||||
|
||||
- start pomodoro from habit view
|
||||
- complete past habit in calendar view (#32)
|
||||
|
||||
## Version 0.1.21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,8 +9,8 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils'
|
||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
@@ -33,16 +33,13 @@ export default function DailyOverview({
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||
const today = getTodayInTimezone(settings.system.timezone)
|
||||
const todayCompletions = getCompletedHabitsForDate({
|
||||
habits,
|
||||
date: getNow({ timezone: settings.system.timezone }),
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
const todayCompletions = completedHabitsMap.get(today) || []
|
||||
|
||||
useEffect(() => {
|
||||
// Filter habits that are due today based on their recurrence rule
|
||||
const filteredHabits = habits.filter(habit => isHabitDueToday(habit, settings.system.timezone))
|
||||
const filteredHabits = habits.filter(habit => isHabitDueToday({ habit, timezone: settings.system.timezone }))
|
||||
setDailyHabits(filteredHabits)
|
||||
}, [habits])
|
||||
|
||||
@@ -78,11 +75,8 @@ export default function DailyOverview({
|
||||
<h3 className="font-semibold">Daily Habits</h3>
|
||||
<Badge variant="secondary">
|
||||
{dailyHabits.filter(habit => {
|
||||
const completions = getCompletionsForDate({
|
||||
habit,
|
||||
date: today,
|
||||
timezone: settings.system.timezone
|
||||
});
|
||||
const completions = (completedHabitsMap.get(today) || [])
|
||||
.filter(h => h.id === habit.id).length;
|
||||
return completions >= (habit.targetCompletions || 1);
|
||||
}).length}/{dailyHabits.length} Completed
|
||||
</Badge>
|
||||
|
||||
0
components/DebugPerformance.tsx
Normal file
0
components/DebugPerformance.tsx
Normal file
@@ -1,29 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, Circle, CircleCheck } from 'lucide-react'
|
||||
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
|
||||
import { DateTime } from 'luxon'
|
||||
import Linkify from './linkify'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
export default function HabitCalendar() {
|
||||
const { completePastHabit } = useHabits()
|
||||
|
||||
const handleCompletePastHabit = useCallback(async (habit: Habit, date: DateTime) => {
|
||||
try {
|
||||
await completePastHabit(habit, date)
|
||||
} catch (error) {
|
||||
console.error('Error completing past habit:', error)
|
||||
}
|
||||
}, [completePastHabit])
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [selectedDate, setSelectedDate] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
const habits = habitsData.habits
|
||||
|
||||
const getHabitsForDate = (date: Date) => {
|
||||
return getCompletedHabitsForDate({
|
||||
habits,
|
||||
date: DateTime.fromJSDate(date),
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
}
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||
|
||||
// Get completed dates for calendar modifiers
|
||||
const completedDates = useMemo(() => {
|
||||
return new Set(Array.from(completedHabitsMap.keys()).map(date =>
|
||||
getISODate({ dateTime: DateTime.fromISO(date), timezone: settings.system.timezone })
|
||||
))
|
||||
}, [completedHabitsMap, settings.system.timezone])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -40,7 +53,12 @@ export default function HabitCalendar() {
|
||||
onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))}
|
||||
className="rounded-md border"
|
||||
modifiers={{
|
||||
completed: (date) => getHabitsForDate(date).length > 0,
|
||||
completed: (date) => completedDates.has(
|
||||
getISODate({
|
||||
dateTime: DateTime.fromJSDate(date),
|
||||
timezone: settings.system.timezone
|
||||
})!
|
||||
)
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
completed: 'bg-green-100 text-green-800 font-bold',
|
||||
@@ -61,21 +79,57 @@ export default function HabitCalendar() {
|
||||
<CardContent>
|
||||
{selectedDate && (
|
||||
<ul className="space-y-2">
|
||||
{habits.map((habit) => {
|
||||
const isCompleted = getHabitsForDate(selectedDate.toJSDate()).some((h: Habit) => h.id === habit.id)
|
||||
return (
|
||||
<li key={habit.id} className="flex items-center justify-between">
|
||||
<span>
|
||||
<Linkify>{habit.name}</Linkify>
|
||||
</span>
|
||||
{isCompleted ? (
|
||||
<Badge variant="default">Completed</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Not Completed</Badge>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{habits
|
||||
.filter(habit => isHabitDue({
|
||||
habit,
|
||||
timezone: settings.system.timezone,
|
||||
date: selectedDate
|
||||
}))
|
||||
.map((habit) => {
|
||||
const habitsForDate = completedHabitsMap.get(getISODate({ dateTime: selectedDate, timezone: settings.system.timezone })) || []
|
||||
const completionsToday = habitsForDate.filter((h: Habit) => h.id === habit.id).length
|
||||
const isCompleted = completionsToday >= (habit.targetCompletions || 1)
|
||||
return (
|
||||
<li key={habit.id} className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
<Linkify>{habit.name}</Linkify>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{habit.targetCompletions && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{completionsToday}/{habit.targetCompletions}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCompletePastHabit(habit, selectedDate)}
|
||||
disabled={isCompleted}
|
||||
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<div className="relative h-4 w-4">
|
||||
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
||||
<div
|
||||
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
||||
style={{
|
||||
background: `conic-gradient(
|
||||
currentColor ${(completionsToday / (habit.targetCompletions ?? 1)) * 360}deg,
|
||||
transparent ${(completionsToday / (habit.targetCompletions ?? 1)) * 360}deg 360deg
|
||||
)`,
|
||||
mask: 'radial-gradient(transparent 50%, black 51%)',
|
||||
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Habit } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
import { settingsAtom, pomodoroAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical } from 'lucide-react'
|
||||
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { RRule } from 'rrule'
|
||||
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
||||
|
||||
interface HabitItemProps {
|
||||
@@ -27,7 +25,7 @@ interface HabitItemProps {
|
||||
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const today = getTodayInTimezone(settings.system.timezone)
|
||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
||||
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 || 0
|
||||
@@ -143,6 +141,16 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setPomo((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
selectedHabitId: habit.id
|
||||
}))
|
||||
}}>
|
||||
<Timer className="mr-2 h-4 w-4" />
|
||||
<span>Start Pomodoro</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside: "text-muted-foreground opacity-50",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useAtom } from 'jotai'
|
||||
import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate } from '@/lib/utils'
|
||||
import { DateTime } from 'luxon'
|
||||
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate, getISODate, d2s } from '@/lib/utils'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
@@ -161,10 +162,80 @@ export function useHabits() {
|
||||
return updatedHabits
|
||||
}
|
||||
|
||||
const completePastHabit = async (habit: Habit, date: DateTime) => {
|
||||
const timezone = settings.system.timezone
|
||||
const dateKey = getISODate({ dateTime: date, timezone })
|
||||
|
||||
// Check if already completed on this date
|
||||
const completionsOnDate = habit.completions.filter(completion =>
|
||||
isSameDate(t2d({ timestamp: completion, timezone }), date)
|
||||
).length
|
||||
const target = habit.targetCompletions || 1
|
||||
|
||||
if (completionsOnDate >= target) {
|
||||
toast({
|
||||
title: "Already completed",
|
||||
description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Use current time but with the past date
|
||||
const now = getNow({ timezone })
|
||||
const completionDateTime = date.set({
|
||||
hour: now.hour,
|
||||
minute: now.minute,
|
||||
second: now.second,
|
||||
millisecond: now.millisecond
|
||||
})
|
||||
const completionTimestamp = d2t({ dateTime: completionDateTime })
|
||||
const updatedHabit = {
|
||||
...habit,
|
||||
completions: [...habit.completions, completionTimestamp]
|
||||
}
|
||||
|
||||
const updatedHabits = habitsData.habits.map(h =>
|
||||
h.id === habit.id ? updatedHabit : h
|
||||
)
|
||||
|
||||
await saveHabitsData({ habits: updatedHabits })
|
||||
setHabitsData({ habits: updatedHabits })
|
||||
|
||||
// Check if we've now reached the target
|
||||
const isTargetReached = completionsOnDate + 1 === target
|
||||
if (isTargetReached) {
|
||||
const updatedCoins = await addCoins(
|
||||
habit.coinReward,
|
||||
`Completed habit: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
|
||||
'HABIT_COMPLETION',
|
||||
habit.id
|
||||
)
|
||||
setCoins(updatedCoins)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: isTargetReached ? "Habit completed!" : "Progress!",
|
||||
description: isTargetReached
|
||||
? `You earned ${habit.coinReward} coins for ${dateKey}.`
|
||||
: `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`,
|
||||
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
||||
<Undo2 className="h-4 w-4" />Undo
|
||||
</ToastAction>
|
||||
})
|
||||
|
||||
return {
|
||||
updatedHabits,
|
||||
newBalance: coins.balance,
|
||||
newTransactions: coins.transactions
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
completeHabit,
|
||||
undoComplete,
|
||||
saveHabit,
|
||||
deleteHabit
|
||||
deleteHabit,
|
||||
completePastHabit
|
||||
}
|
||||
}
|
||||
|
||||
22
lib/atoms.ts
22
lib/atoms.ts
@@ -15,7 +15,8 @@ import {
|
||||
calculateTotalSpent,
|
||||
calculateCoinsSpentToday,
|
||||
calculateTransactionsToday,
|
||||
getCompletionsForToday
|
||||
getCompletionsForToday,
|
||||
getISODate
|
||||
} from "@/lib/utils";
|
||||
|
||||
export const settingsAtom = atom(getDefaultSettings());
|
||||
@@ -72,6 +73,23 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
})
|
||||
|
||||
// Derived atom for today's completions of selected habit
|
||||
export const completedHabitsMapAtom = atom((get) => {
|
||||
const habits = get(habitsAtom).habits
|
||||
const timezone = get(settingsAtom).system.timezone
|
||||
|
||||
const map = new Map<string, Habit[]>()
|
||||
habits.forEach(habit => {
|
||||
habit.completions.forEach(completion => {
|
||||
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone })
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, [])
|
||||
}
|
||||
map.get(dateKey)!.push(habit)
|
||||
})
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
||||
const pomo = get(pomodoroAtom)
|
||||
const habits = get(habitsAtom)
|
||||
@@ -79,7 +97,7 @@ export const pomodoroTodayCompletionsAtom = atom((get) => {
|
||||
|
||||
if (!pomo.selectedHabitId) return 0
|
||||
|
||||
const selectedHabit = habits.habits.find(h => h.id === pomo.selectedHabitId)
|
||||
const selectedHabit = habits.habits.find(h => h.id === pomo.selectedHabitId!)
|
||||
if (!selectedHabit) return 0
|
||||
|
||||
return getCompletionsForToday({
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
calculateTotalEarned,
|
||||
calculateTotalSpent,
|
||||
calculateCoinsSpentToday,
|
||||
isHabitDueToday
|
||||
isHabitDueToday,
|
||||
isHabitDue
|
||||
} from './utils'
|
||||
import { CoinTransaction } from './types'
|
||||
import { DateTime } from "luxon";
|
||||
@@ -277,21 +278,21 @@ describe('isHabitDueToday', () => {
|
||||
DateTime.now = () => mockDate
|
||||
|
||||
const habit = testHabit('FREQ=DAILY')
|
||||
expect(isHabitDueToday(habit, 'UTC')).toBe(true)
|
||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(true)
|
||||
})
|
||||
|
||||
test('should return true for weekly habit on correct day', () => {
|
||||
const habit = testHabit('FREQ=WEEKLY;BYDAY=MO') // Monday
|
||||
const mockDate = DateTime.fromISO('2024-01-01T00:00:00Z') as DateTime<true> // Monday
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, 'UTC')).toBe(true)
|
||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false for weekly habit on wrong day', () => {
|
||||
const habit = testHabit('FREQ=WEEKLY;BYDAY=MO') // Monday
|
||||
const mockDate = DateTime.fromISO('2024-01-02T00:00:00Z') as DateTime<true> // Tuesday
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, 'UTC')).toBe(false)
|
||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
|
||||
})
|
||||
|
||||
test('should handle timezones correctly', () => {
|
||||
@@ -339,7 +340,7 @@ describe('isHabitDueToday', () => {
|
||||
testCases.forEach(({ time, timezone, expected }) => {
|
||||
const mockDate = DateTime.fromISO(time) as DateTime<true>
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, timezone)).toBe(expected)
|
||||
expect(isHabitDueToday({ habit, timezone })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -368,7 +369,7 @@ describe('isHabitDueToday', () => {
|
||||
testCases.forEach(({ time, timezone, expected }) => {
|
||||
const mockDate = DateTime.fromISO(time) as DateTime<true>
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, timezone)).toBe(expected)
|
||||
expect(isHabitDueToday({ habit, timezone })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -396,7 +397,7 @@ describe('isHabitDueToday', () => {
|
||||
testCases.forEach(({ time, timezone, expected }) => {
|
||||
const mockDate = DateTime.fromISO(time) as DateTime<true>
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, timezone)).toBe(expected)
|
||||
expect(isHabitDueToday({ habit, timezone })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -424,7 +425,7 @@ describe('isHabitDueToday', () => {
|
||||
testCases.forEach(({ time, timezone, expected }) => {
|
||||
const mockDate = DateTime.fromISO(time) as DateTime<true>
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, timezone)).toBe(expected)
|
||||
expect(isHabitDueToday({ habit, timezone })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -432,21 +433,21 @@ describe('isHabitDueToday', () => {
|
||||
const habit = testHabit('FREQ=MONTHLY;BYMONTHDAY=1')
|
||||
const mockDate = DateTime.fromISO('2024-01-01T00:00:00Z') as DateTime<true> // 1st of month
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, 'UTC')).toBe(true)
|
||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle yearly recurrence', () => {
|
||||
const habit = testHabit('FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1')
|
||||
const mockDate = DateTime.fromISO('2024-01-01T00:00:00Z') as DateTime<true> // Jan 1st
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, 'UTC')).toBe(true)
|
||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle complex recurrence rules', () => {
|
||||
const habit = testHabit('FREQ=WEEKLY;BYDAY=MO,WE,FR')
|
||||
const mockDate = DateTime.fromISO('2024-01-01T00:00:00Z') as DateTime<true> // Monday
|
||||
DateTime.now = () => mockDate
|
||||
expect(isHabitDueToday(habit, 'UTC')).toBe(true)
|
||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false for invalid recurrence rule', () => {
|
||||
@@ -455,8 +456,122 @@ describe('isHabitDueToday', () => {
|
||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
|
||||
|
||||
// Expect the function to throw an error
|
||||
expect(() => isHabitDueToday(habit, 'UTC')).toThrow()
|
||||
expect(() => isHabitDueToday({ habit, timezone: 'UTC' })).toThrow()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isHabitDue', () => {
|
||||
const testHabit = (frequency: string): Habit => ({
|
||||
id: 'test-habit',
|
||||
name: 'Test Habit',
|
||||
description: '',
|
||||
frequency,
|
||||
coinReward: 10,
|
||||
completions: []
|
||||
})
|
||||
|
||||
test('should return true for daily habit on any date', () => {
|
||||
const habit = testHabit('FREQ=DAILY')
|
||||
const date = DateTime.fromISO('2024-01-01T12:34:56Z')
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(true)
|
||||
})
|
||||
|
||||
test('should return true for weekly habit on correct day', () => {
|
||||
const habit = testHabit('FREQ=WEEKLY;BYDAY=MO') // Monday
|
||||
const date = DateTime.fromISO('2024-01-01T00:00:00Z') // Monday
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false for weekly habit on wrong day', () => {
|
||||
const habit = testHabit('FREQ=WEEKLY;BYDAY=MO') // Monday
|
||||
const date = DateTime.fromISO('2024-01-02T00:00:00Z') // Tuesday
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
||||
})
|
||||
|
||||
test('should handle past dates correctly', () => {
|
||||
const habit = testHabit('FREQ=WEEKLY;BYDAY=MO') // Monday
|
||||
const pastDate = DateTime.fromISO('2023-12-25T00:00:00Z') // Christmas (Monday)
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date: pastDate })).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle future dates correctly', () => {
|
||||
const habit = testHabit('FREQ=WEEKLY;BYDAY=MO') // Monday
|
||||
const futureDate = DateTime.fromISO('2024-12-30T00:00:00Z') // Monday
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date: futureDate })).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle timezone transitions correctly', () => {
|
||||
const habit = testHabit('FREQ=DAILY')
|
||||
const testCases = [
|
||||
{
|
||||
date: '2024-01-01T04:00:00Z', // UTC time that's still previous day in New York
|
||||
timezone: 'America/New_York',
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
date: '2024-01-01T23:00:00Z', // Just before midnight in UTC
|
||||
timezone: 'Asia/Tokyo', // Already next day in Tokyo
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
date: '2024-01-01T01:00:00Z', // Just after midnight in UTC
|
||||
timezone: 'Pacific/Honolulu', // Still previous day in Hawaii
|
||||
expected: true
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ date, timezone, expected }) => {
|
||||
const dateObj = DateTime.fromISO(date)
|
||||
expect(isHabitDue({ habit, timezone, date: dateObj })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
test('should handle daylight saving time transitions', () => {
|
||||
const habit = testHabit('FREQ=DAILY')
|
||||
const testCases = [
|
||||
{
|
||||
date: '2024-03-10T02:30:00Z', // During DST transition in US
|
||||
timezone: 'America/New_York',
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
date: '2024-10-27T01:30:00Z', // During DST transition in Europe
|
||||
timezone: 'Europe/London',
|
||||
expected: true
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ date, timezone, expected }) => {
|
||||
const dateObj = DateTime.fromISO(date)
|
||||
expect(isHabitDue({ habit, timezone, date: dateObj })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
test('should handle monthly recurrence', () => {
|
||||
const habit = testHabit('FREQ=MONTHLY;BYMONTHDAY=1')
|
||||
const date = DateTime.fromISO('2024-01-01T00:00:00Z') // 1st of month
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle yearly recurrence', () => {
|
||||
const habit = testHabit('FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1')
|
||||
const date = DateTime.fromISO('2024-01-01T00:00:00Z') // Jan 1st
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle complex recurrence rules', () => {
|
||||
const habit = testHabit('FREQ=WEEKLY;BYDAY=MO,WE,FR')
|
||||
const date = DateTime.fromISO('2024-01-01T00:00:00Z') // Monday
|
||||
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(true)
|
||||
})
|
||||
|
||||
test('should return false for invalid recurrence rule', () => {
|
||||
const habit = testHabit('INVALID_RRULE')
|
||||
const date = DateTime.fromISO('2024-01-01T00:00:00Z')
|
||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
|
||||
expect(() => isHabitDue({ habit, timezone: 'UTC', date })).toThrow()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
39
lib/utils.ts
39
lib/utils.ts
@@ -12,7 +12,11 @@ export function cn(...inputs: ClassValue[]) {
|
||||
// get today's date string for timezone
|
||||
export function getTodayInTimezone(timezone: string): string {
|
||||
const now = getNow({ timezone });
|
||||
return d2s({ dateTime: now, format: 'yyyy-MM-dd', timezone });
|
||||
return getISODate({ dateTime: now, timezone });
|
||||
}
|
||||
|
||||
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
|
||||
return dateTime.setZone(timezone).toISODate()!;
|
||||
}
|
||||
|
||||
// get datetime object of now
|
||||
@@ -200,26 +204,45 @@ export function serializeRRule(rrule: RRule) {
|
||||
return rrule.toString()
|
||||
}
|
||||
|
||||
export function isHabitDueToday(habit: Habit, timezone: string): boolean {
|
||||
const startOfDay = DateTime.now().setZone(timezone).startOf('day')
|
||||
const endOfDay = DateTime.now().setZone(timezone).endOf('day')
|
||||
export function isHabitDue({
|
||||
habit,
|
||||
timezone,
|
||||
date
|
||||
}: {
|
||||
habit: Habit
|
||||
timezone: string
|
||||
date: DateTime
|
||||
}): boolean {
|
||||
const startOfDay = date.setZone(timezone).startOf('day')
|
||||
const endOfDay = date.setZone(timezone).endOf('day')
|
||||
|
||||
const ruleText = habit.frequency
|
||||
const rrule = parseRRule(ruleText)
|
||||
|
||||
rrule.origOptions.tzid = timezone // set the target timezone, rrule will do calculation in this timezone
|
||||
rrule.origOptions.tzid = timezone
|
||||
rrule.options.tzid = rrule.origOptions.tzid
|
||||
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second) // set the start time to 00:00:00 of timezone's today
|
||||
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
|
||||
rrule.options.dtstart = rrule.origOptions.dtstart
|
||||
rrule.origOptions.count = 1
|
||||
rrule.options.count = rrule.origOptions.count
|
||||
|
||||
const matches = rrule.all() // this is given as local time, we need to convert back to timezone time
|
||||
const matches = rrule.all()
|
||||
if (!matches.length) return false
|
||||
const t = DateTime.fromJSDate(matches[0]).toUTC().setZone('local', { keepLocalTime: true }).setZone(timezone) // this is the formula to convert local time matches[0] to tz time
|
||||
const t = DateTime.fromJSDate(matches[0]).toUTC().setZone('local', { keepLocalTime: true }).setZone(timezone)
|
||||
return startOfDay <= t && t <= endOfDay
|
||||
}
|
||||
|
||||
export function isHabitDueToday({
|
||||
habit,
|
||||
timezone
|
||||
}: {
|
||||
habit: Habit
|
||||
timezone: string
|
||||
}): boolean {
|
||||
const today = getNow({ timezone })
|
||||
return isHabitDue({ habit, timezone, date: today })
|
||||
}
|
||||
|
||||
export function getHabitFreq(habit: Habit): Freq {
|
||||
const rrule = parseRRule(habit.frequency)
|
||||
const freq = rrule.origOptions.freq
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.22",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user