enable completing past habit

This commit is contained in:
dohsimpson
2025-01-18 19:02:17 -05:00
parent 7ca1744168
commit 2bcbabccc1
11 changed files with 359 additions and 134 deletions

View File

@@ -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

View File

@@ -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>

View File

View 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,18 +79,54 @@ 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)
{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">
<li key={habit.id} className="flex items-center justify-between gap-2">
<span>
<Linkify>{habit.name}</Linkify>
</span>
{isCompleted ? (
<Badge variant="default">Completed</Badge>
) : (
<Badge variant="secondary">Not Completed</Badge>
<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>
)
})}

View File

@@ -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

View File

@@ -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 }

View File

@@ -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
}
}

View File

@@ -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({

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.1.21",
"version": "0.1.22",
"private": true,
"scripts": {
"dev": "next dev --turbopack",