From 6c5853adea1c6c40867c8f931bb31f6866fe93dc Mon Sep 17 00:00:00 2001 From: Doh Date: Fri, 10 Jan 2025 22:54:41 -0500 Subject: [PATCH] added recurrence (#35) --- .husky/pre-commit | 6 + CHANGELOG.md | 9 + components/AddEditHabitModal.tsx | 192 +++++++----- components/DailyOverview.tsx | 230 +++++++++----- components/Dashboard.tsx | 1 - components/HabitItem.tsx | 8 +- components/HabitList.tsx | 27 +- components/HabitOverview.tsx | 60 ---- lib/constants.ts | 10 + lib/types.ts | 4 +- lib/utils.test.ts | 496 ++++++++++++++++++++++++------- lib/utils.ts | 82 +++-- package-lock.json | 9 + package.json | 3 +- 14 files changed, 780 insertions(+), 357 deletions(-) delete mode 100644 components/HabitOverview.tsx create mode 100644 lib/constants.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index bfe23ec..311d6b9 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,7 @@ +if git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚'; then + echo "Error: Found debug marker 🪚 in these files:" + git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚' | awk -F: '{print " " $1 ":" $2}' + exit 1 +fi + npm run typecheck && npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md index a28d604..765a86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,19 @@ ## Version 0.1.18 +### Added + +- flexible recurrence rule using natural language (#1) + ### Fixed +- add modal state not cleared after adding habit (#34) - daily overview habit count should not show target completions +### Improved + +- habits and wishlist presentation in daily overview + ## Version 0.1.17 ### Added diff --git a/components/AddEditHabitModal.tsx b/components/AddEditHabitModal.tsx index e82d10d..1ddb31b 100644 --- a/components/AddEditHabitModal.tsx +++ b/components/AddEditHabitModal.tsx @@ -1,4 +1,7 @@ +'use client' + import { useState, useEffect } from 'react' +import { RRule, RRuleSet, rrulestr } from 'rrule' import { useAtom } from 'jotai' import { settingsAtom } from '@/lib/atoms' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' @@ -13,51 +16,40 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import data from '@emoji-mart/data' import Picker from '@emoji-mart/react' import { Habit } from '@/lib/types' +import { parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils' +import { INITIAL_RECURRENCE_RULE } from '@/lib/constants' interface AddEditHabitModalProps { - isOpen: boolean onClose: () => void - onSave: (habit: Omit) => void + onSave: (habit: Omit) => Promise habit?: Habit | null } -export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: AddEditHabitModalProps) { +export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) { const [settings] = useAtom(settingsAtom) - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'monthly'>('daily') - const [coinReward, setCoinReward] = useState(1) - const [targetCompletions, setTargetCompletions] = useState(1) + const [name, setName] = useState(habit?.name || '') + const [description, setDescription] = useState(habit?.description || '') + const [coinReward, setCoinReward] = useState(habit?.coinReward || 1) + const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1) + const origRuleText = parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() + const [ruleText, setRuleText] = useState(origRuleText) - useEffect(() => { - if (habit) { - setName(habit.name) - setDescription(habit.description) - setFrequency(habit.frequency) - setCoinReward(habit.coinReward) - setTargetCompletions(habit.targetCompletions || 1) - } else { - setName('') - setDescription('') - setFrequency('daily') - setCoinReward(1) - } - }, [habit]) - - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - onSave({ + await onSave({ name, description, - frequency, coinReward, targetCompletions: targetCompletions > 1 ? targetCompletions : undefined, - completions: habit?.completions || [] + completions: habit?.completions || [], + frequency: habit ? ( + origRuleText === ruleText ? habit.frequency : serializeRRule(parseNaturalLanguageRRule(ruleText)) + ) : serializeRRule(parseNaturalLanguageRRule(ruleText)), }) } return ( - + {habit ? 'Edit Habit' : 'Add New Habit'} @@ -90,7 +82,11 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad { - setName(prev => `${prev}${emoji.native}`) + setName(prev => { + // Add space before emoji if there isn't one already + const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : ''; + return `${prev}${space}${emoji.native}`; + }) // Focus back on input after selection const input = document.getElementById('name') as HTMLInputElement input?.focus() @@ -112,69 +108,109 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad />
-
- - - - - - -

How many times you want to complete this habit each day.
For example: drink 7 glasses of water or take 3 walks

You'll only receive the coin reward after reaching the daily target.

-
-
-
-
-
- { - const value = parseInt(e.target.value) - setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value)) - }} - min={1} - max={10} - className="w-20" - /> +
+
+
+ + { + const value = parseInt(e.target.value) + setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value)) + }} + min={1} + max={10} + className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + +
- times per day + times per occurrence
- - setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))} - className="col-span-3" - min={1} - required - /> +
+ +
+
+
+
+ + setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))} + min={0} + required + className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + +
+ + coins per completion + +
+
diff --git a/components/DailyOverview.tsx b/components/DailyOverview.tsx index 8a8c383..e7a09bb 100644 --- a/components/DailyOverview.tsx +++ b/components/DailyOverview.tsx @@ -1,7 +1,7 @@ import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react' -import { cn } from '@/lib/utils' +import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils' import Link from 'next/link' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useAtom } from 'jotai' import { settingsAtom } from '@/lib/atoms' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils' @@ -26,6 +26,7 @@ export default function DailyOverview({ }: UpcomingItemsProps) { const { completeHabit, undoComplete } = useHabits() const [settings] = useAtom(settingsAtom) + const [dailyHabits, setDailyHabits] = useState([]) const today = getTodayInTimezone(settings.system.timezone) const todayCompletions = getCompletedHabitsForDate({ habits, @@ -33,12 +34,14 @@ export default function DailyOverview({ timezone: settings.system.timezone }) - // Filter daily habits - const dailyHabits = habits.filter(habit => habit.frequency === 'daily') + useEffect(() => { + // Filter habits that are due today based on their recurrence rule + const filteredHabits = habits.filter(habit => isHabitDueToday(habit, settings.system.timezone)) + setDailyHabits(filteredHabits) + }, [habits]) - // Get achievable wishlist items sorted by coin cost - const achievableWishlistItems = wishlistItems - .filter(item => item.coinCost > coinBalance) + // Get all wishlist items sorted by coin cost + const sortedWishlistItems = wishlistItems .sort((a, b) => a.coinCost - b.coinCost) const [expandedHabits, setExpandedHabits] = useState(false) @@ -68,11 +71,32 @@ export default function DailyOverview({
    {dailyHabits .sort((a, b) => { + // First by completion status const aCompleted = todayCompletions.includes(a); const bCompleted = todayCompletions.includes(b); - return aCompleted === bCompleted ? 0 : aCompleted ? 1 : -1; + 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, expandedHabits ? undefined : 3) + .slice(0, 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 })) @@ -128,85 +152,38 @@ export default function DailyOverview({ {completionsToday}/{target} )} + {getHabitFreq(habit) !== 'daily' && ( + + {getHabitFreq(habit)} + + )} - - {habit.coinReward} + + + {habit.coinReward} + ) })}
-
-
- - - View - - -
- -
-
-

Wishlist Goals

- - {wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable - -
- {achievableWishlistItems.length > 0 && ( -
-
- {achievableWishlistItems - .slice(0, expandedWishlist ? undefined : 1) - .map((item) => ( -
-
- - - {item.name} - - - - - {item.coinCost} - -
- -

- {item.coinCost - coinBalance} coins to go -

-
- ))} -
-
- )}
View @@ -227,9 +204,104 @@ export default function DailyOverview({
+ +
+
+

Wishlist Goals

+ + {wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable + +
+
+
+ {sortedWishlistItems.length === 0 ? ( +
+ No wishlist items yet. Add some goals to work towards! +
+ ) : ( + <> + {sortedWishlistItems + .slice(0, expandedWishlist ? undefined : 5) + .map((item) => { + const isRedeemable = item.coinCost <= coinBalance + return ( + +
+ + {item.name} + + + + + {item.coinCost} + + +
+ +

+ {isRedeemable + ? "Ready to redeem!" + : `${item.coinCost - coinBalance} coins to go` + } +

+ + ) + })} + + )} +
+
+ + + View + + +
+
+
) } - diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index d4ab507..ab27fe1 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -21,7 +21,6 @@ export default function Dashboard() {

Dashboard

- {/* */} {habit.description} -

Frequency: {habit.frequency}

+

Frequency: {parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText()}

{habit.coinReward} coins per completion @@ -87,7 +89,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { target > 1 ? `Complete (${completionsToday}/${target})` : 'Complete' )} {habit.targetCompletions && habit.targetCompletions > 1 && ( -
- { - setIsModalOpen(false) - setEditingHabit(null) - }} - onSave={async (habit) => { - await saveHabit({ ...habit, id: editingHabit?.id }) - setIsModalOpen(false) - setEditingHabit(null) - }} - habit={editingHabit} - /> + {isModalOpen && + { + setIsModalOpen(false) + setEditingHabit(null) + }} + onSave={async (habit) => { + await saveHabit({ ...habit, id: editingHabit?.id }) + setIsModalOpen(false) + setEditingHabit(null) + }} + habit={editingHabit} + /> + } setDeleteConfirmation({ isOpen: false, habitId: null })} diff --git a/components/HabitOverview.tsx b/components/HabitOverview.tsx deleted file mode 100644 index a314901..0000000 --- a/components/HabitOverview.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { BarChart } from 'lucide-react' -import { getTodayInTimezone, getCompletedHabitsForDate } from '@/lib/utils' -import { useAtom } from 'jotai' -import { habitsAtom, settingsAtom } from '@/lib/atoms' - -export default function HabitOverview() { - const [habitsData] = useAtom(habitsAtom) - const habits = habitsData.habits - - const [settings] = useAtom(settingsAtom) - const today = getTodayInTimezone(settings.system.timezone) - - const completedToday = getCompletedHabitsForDate({ - habits, - date: today, - timezone: settings.system.timezone - }).length - - const habitsByFrequency = habits.reduce((acc, habit) => ({ - ...acc, - [habit.frequency]: (acc[habit.frequency] || 0) + 1 - }), {} as Record) - - return ( - - - Habit Overview - - -
- {/* Today's Progress */} -
-

Today's Progress

-
- {completedToday}/{habits.length} completed - -
-
- - {/* Frequency Breakdown */} -
-

Habit Frequency

-
- {Object.entries(habitsByFrequency).map(([frequency, count]) => ( -
- {frequency} - - {count} habits - -
- ))} -
-
-
-
-
- ) -} - diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 0000000..4e56ab1 --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,10 @@ +export const INITIAL_RECURRENCE_RULE = 'daily' + +export const RECURRENCE_RULE_MAP: { [key: string]: string } = { + 'daily': 'FREQ=DAILY', + 'weekly': 'FREQ=WEEKLY', + 'monthly': 'FREQ=MONTHLY', + 'yearly': 'FREQ=YEARLY', + '': 'invalid', +} + diff --git a/lib/types.ts b/lib/types.ts index f60083d..a3842c4 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -2,12 +2,14 @@ export type Habit = { id: string name: string description: string - frequency: 'daily' | 'weekly' | 'monthly' + frequency: string coinReward: number targetCompletions?: number // Optional field, default to 1 completions: string[] // Array of UTC ISO date strings } +export type Freq = 'daily' | 'weekly' | 'monthly' | 'yearly' + export type WishlistItemType = { id: string name: string diff --git a/lib/utils.test.ts b/lib/utils.test.ts index f4e6f92..120d985 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe, beforeAll, afterAll } from "bun:test"; +import { expect, test, describe, beforeAll, beforeEach, afterAll, spyOn } from "bun:test"; import { cn, getTodayInTimezone, @@ -14,9 +14,12 @@ import { calculateTotalEarned, calculateTotalSpent, calculateCoinsSpentToday, + isHabitDueToday } from './utils' import { CoinTransaction } from './types' import { DateTime } from "luxon"; +import { RRule } from 'rrule'; +import { Habit } from '@/lib/types'; describe('cn utility', () => { test('should merge class names correctly', () => { @@ -29,146 +32,431 @@ describe('cn utility', () => { describe('datetime utilities', () => { let fixedNow: DateTime; + let currentDateIndex = 0; + const testDates = [ + '2024-01-01T00:00:00Z', // Monday + '2024-02-14T12:00:00Z', // Valentine's Day + '2024-07-04T18:00:00Z', // Independence Day + '2024-12-25T00:00:00Z', // Christmas + '2024-06-21T12:00:00Z', // Summer Solstice + ]; - beforeAll(() => { - // Fix the current time to 2024-01-01T00:00:00Z - fixedNow = DateTime.fromISO('2024-01-01T00:00:00Z'); - DateTime.now = () => fixedNow; + beforeEach(() => { + // Set fixed date for each test + const date = DateTime.fromISO(testDates[currentDateIndex]) as DateTime; + DateTime.now = () => date; + currentDateIndex = (currentDateIndex + 1) % testDates.length; + }) +}) + +describe('getTodayInTimezone', () => { + test('should return today in YYYY-MM-DD format for timezone', () => { + // Get the current test date in UTC + const utcNow = DateTime.now().setZone('UTC') + + // Test New York timezone + const nyDate = utcNow.setZone('America/New_York').toFormat('yyyy-MM-dd') + expect(getTodayInTimezone('America/New_York')).toBe(nyDate) + + // Test Tokyo timezone + const tokyoDate = utcNow.setZone('Asia/Tokyo').toFormat('yyyy-MM-dd') + expect(getTodayInTimezone('Asia/Tokyo')).toBe(tokyoDate) }) - describe('getTodayInTimezone', () => { - test('should return today in YYYY-MM-DD format for timezone', () => { - expect(getTodayInTimezone('America/New_York')).toBe('2023-12-31') - expect(getTodayInTimezone('Asia/Tokyo')).toBe('2024-01-01') - }) + test('should handle timezone transitions correctly', () => { + // Test a date that crosses midnight in different timezones + const testDate = DateTime.fromISO('2024-01-01T23:30:00Z') as DateTime + DateTime.now = () => testDate + + // In New York (UTC-5), this is still Jan 1st + expect(getTodayInTimezone('America/New_York')).toBe('2024-01-01') + + // In Tokyo (UTC+9), this is already Jan 2nd + expect(getTodayInTimezone('Asia/Tokyo')).toBe('2024-01-02') }) - describe('getNow', () => { - test('should return current datetime in specified timezone', () => { - const nyNow = getNow({ timezone: 'America/New_York' }); - expect(nyNow.zoneName).toBe('America/New_York') - expect(nyNow.year).toBe(2023) - expect(nyNow.month).toBe(12) - expect(nyNow.day).toBe(31) - }) + test('should handle daylight saving time transitions', () => { + // Test a date during DST transition + const dstDate = DateTime.fromISO('2024-03-10T02:30:00Z') as DateTime + DateTime.now = () => dstDate - test('should default to UTC', () => { - const utcNow = getNow({}); - expect(utcNow.zoneName).toBe('UTC') - }) + // In New York (UTC-4 during DST) + expect(getTodayInTimezone('America/New_York')).toBe('2024-03-09') + + // In London (UTC+0/BST+1) + expect(getTodayInTimezone('Europe/London')).toBe('2024-03-10') }) - describe('getNowInMilliseconds', () => { - test('should return current time in milliseconds', () => { - expect(getNowInMilliseconds()).toBe('1704067200000') - }) + test('should handle edge cases around midnight', () => { + // Test just before and after midnight in different timezones + const justBeforeMidnight = DateTime.fromISO('2024-01-01T23:59:59Z') as DateTime + const justAfterMidnight = DateTime.fromISO('2024-01-02T00:00:01Z') as DateTime + + // Test New York timezone (UTC-5) + DateTime.now = () => justBeforeMidnight + expect(getTodayInTimezone('America/New_York')).toBe('2024-01-01') + + DateTime.now = () => justAfterMidnight + expect(getTodayInTimezone('America/New_York')).toBe('2024-01-01') + + // Test Tokyo timezone (UTC+9) + DateTime.now = () => justBeforeMidnight + expect(getTodayInTimezone('Asia/Tokyo')).toBe('2024-01-02') + + DateTime.now = () => justAfterMidnight + expect(getTodayInTimezone('Asia/Tokyo')).toBe('2024-01-02') }) - describe('timestamp conversion utilities', () => { - const testTimestamp = '2024-01-01T00:00:00.000Z'; - const testDateTime = DateTime.fromISO(testTimestamp); + test('should handle all timezones correctly', () => { + const testZones = [ + 'Pacific/Honolulu', // UTC-10 + 'America/Los_Angeles', // UTC-8/-7 + 'America/New_York', // UTC-5/-4 + 'Europe/London', // UTC+0/+1 + 'Europe/Paris', // UTC+1/+2 + 'Asia/Kolkata', // UTC+5:30 + 'Asia/Tokyo', // UTC+9 + 'Pacific/Auckland' // UTC+12/+13 + ] - test('t2d should convert ISO timestamp to DateTime', () => { - const result = t2d({ timestamp: testTimestamp, timezone: 'utc' }); - // Normalize both timestamps to handle different UTC offset formats (Z vs +00:00) - expect(DateTime.fromISO(result.toISO()!).toMillis()) - .toBe(DateTime.fromISO(testTimestamp).toMillis()) - }) + const testDate = DateTime.fromISO('2024-01-01T12:00:00Z') as DateTime + DateTime.now = () => testDate - test('d2t should convert DateTime to ISO timestamp', () => { - const result = d2t({ dateTime: testDateTime }); - expect(result).toBe(testTimestamp) - }) - - test('d2s should format DateTime for display', () => { - const result = d2s({ dateTime: testDateTime, timezone: 'utc' }); - expect(result).toBeString() - - const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' }); - expect(customFormat).toBe('2024-01-01') - }) - - test('d2sDate should format DateTime as date string', () => { - const result = d2sDate({ dateTime: testDateTime }); - expect(result).toBeString() - }) - - test('d2n should convert DateTime to milliseconds string', () => { - const result = d2n({ dateTime: testDateTime }); - expect(result).toBe('1704067200000') + testZones.forEach(zone => { + const expected = testDate.setZone(zone).toFormat('yyyy-MM-dd') + expect(getTodayInTimezone(zone)).toBe(expected) }) }) +}) - describe('isSameDate', () => { - test('should compare dates correctly', () => { - const date1 = DateTime.fromISO('2024-01-01T12:00:00Z'); - const date2 = DateTime.fromISO('2024-01-01T15:00:00Z'); - const date3 = DateTime.fromISO('2024-01-02T12:00:00Z'); +describe('getNow', () => { + test('should return current datetime in specified timezone', () => { + const nyNow = getNow({ timezone: 'America/New_York' }); + expect(nyNow.zoneName).toBe('America/New_York') - expect(isSameDate(date1, date2)).toBe(true) - expect(isSameDate(date1, date3)).toBe(false) - }) + // Get the expected values from the fixed test date + const expected = DateTime.now().setZone('America/New_York') + expect(nyNow.year).toBe(expected.year) + expect(nyNow.month).toBe(expected.month) + expect(nyNow.day).toBe(expected.day) }) - describe('transaction calculations', () => { - const testTransactions: CoinTransaction[] = [ + test('should default to UTC', () => { + const utcNow = getNow({}); + expect(utcNow.zoneName).toBe('UTC') + }) +}) + +describe('getNowInMilliseconds', () => { + test('should return current time in milliseconds', () => { + const now = DateTime.now().setZone('UTC') + expect(getNowInMilliseconds()).toBe(now.toMillis().toString()) + }) +}) + +describe('timestamp conversion utilities', () => { + const testTimestamp = '2024-01-01T00:00:00.000Z'; + const testDateTime = DateTime.fromISO(testTimestamp); + + test('t2d should convert ISO timestamp to DateTime', () => { + const result = t2d({ timestamp: testTimestamp, timezone: 'utc' }); + // Normalize both timestamps to handle different UTC offset formats (Z vs +00:00) + expect(DateTime.fromISO(result.toISO()!).toMillis()) + .toBe(DateTime.fromISO(testTimestamp).toMillis()) + }) + + test('d2t should convert DateTime to ISO timestamp', () => { + const result = d2t({ dateTime: testDateTime }); + expect(result).toBe(testTimestamp) + }) + + test('d2s should format DateTime for display', () => { + const result = d2s({ dateTime: testDateTime, timezone: 'utc' }); + expect(result).toBeString() + + const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' }); + expect(customFormat).toBe('2024-01-01') + }) + + test('d2sDate should format DateTime as date string', () => { + const result = d2sDate({ dateTime: testDateTime }); + expect(result).toBeString() + }) + + test('d2n should convert DateTime to milliseconds string', () => { + const result = d2n({ dateTime: testDateTime }); + expect(result).toBe('1704067200000') + }) +}) + +describe('isSameDate', () => { + test('should compare dates correctly', () => { + const date1 = DateTime.fromISO('2024-01-01T12:00:00Z'); + const date2 = DateTime.fromISO('2024-01-01T15:00:00Z'); + const date3 = DateTime.fromISO('2024-01-02T12:00:00Z'); + + expect(isSameDate(date1, date2)).toBe(true) + expect(isSameDate(date1, date3)).toBe(false) + }) +}) + +describe('transaction calculations', () => { + const testTransactions: CoinTransaction[] = [ + { + id: '1', + amount: 10, + type: 'HABIT_COMPLETION', + description: 'Test habit', + timestamp: '2024-01-01T12:00:00Z' + }, + { + id: '2', + amount: -5, + type: 'HABIT_UNDO', + description: 'Undo test habit', + timestamp: '2024-01-01T13:00:00Z', + relatedItemId: '1' + }, + { + id: '3', + amount: 20, + type: 'HABIT_COMPLETION', + description: 'Another habit', + timestamp: '2024-01-01T14:00:00Z' + }, + { + id: '4', + amount: -15, + type: 'WISH_REDEMPTION', + description: 'Redeemed wish', + timestamp: '2024-01-01T15:00:00Z' + }, + { + id: '5', + amount: 5, + type: 'HABIT_COMPLETION', + description: 'Yesterday habit', + timestamp: '2023-12-31T23:00:00Z' + } + ] + + test('calculateCoinsEarnedToday should calculate today\'s earnings including undos', () => { + const result = calculateCoinsEarnedToday(testTransactions, 'UTC') + expect(result).toBe(25) // 10 + 20 - 5 (including the -5 undo) + }) + + test('calculateTotalEarned should calculate lifetime earnings including undos', () => { + const result = calculateTotalEarned(testTransactions) + expect(result).toBe(30) // 10 + 20 + 5 - 5 (including the -5 undo) + }) + + test('calculateTotalSpent should calculate total spent excluding undos', () => { + const result = calculateTotalSpent(testTransactions) + expect(result).toBe(15) // Only the 15 wish redemption (excluding the 5 undo) + }) + + test('calculateCoinsSpentToday should calculate today\'s spending excluding undos', () => { + const result = calculateCoinsSpentToday(testTransactions, 'UTC') + expect(result).toBe(15) // Only the 15 wish redemption (excluding the 5 undo) + }) +}) + +describe('isHabitDueToday', () => { + const testHabit = (frequency: string): Habit => ({ + id: 'test-habit', + name: 'Test Habit', + description: '', + frequency, + coinReward: 10, + completions: [] + }) + + test('should return true for daily habit', () => { + // Set specific date for this test + const mockDate = DateTime.fromISO('2024-01-01T12:34:56Z') as DateTime + DateTime.now = () => mockDate + + const habit = testHabit('FREQ=DAILY') + expect(isHabitDueToday(habit, '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 // Monday + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, '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 // Tuesday + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, 'UTC')).toBe(false) + }) + + test('should handle timezones correctly', () => { + const habit = testHabit('FREQ=DAILY') + + // Test across multiple timezones with different UTC offsets + const testCases = [ { - id: '1', - amount: 10, - type: 'HABIT_COMPLETION', - description: 'Test habit', - timestamp: '2024-01-01T12:00:00Z' + time: '2024-01-01T04:00:00Z', // UTC time that's still previous day in New York + timezone: 'America/New_York', + expected: true }, { - id: '2', - amount: -5, - type: 'HABIT_UNDO', - description: 'Undo test habit', - timestamp: '2024-01-01T13:00:00Z', - relatedItemId: '1' + time: '2024-01-01T04:00:00Z', + timezone: 'UTC', + expected: true }, { - id: '3', - amount: 20, - type: 'HABIT_COMPLETION', - description: 'Another habit', - timestamp: '2024-01-01T14:00:00Z' + time: '2024-01-01T23:00:00Z', // Just before midnight in UTC + timezone: 'Asia/Tokyo', // Already next day in Tokyo + expected: true }, { - id: '4', - amount: -15, - type: 'WISH_REDEMPTION', - description: 'Redeemed wish', - timestamp: '2024-01-01T15:00:00Z' + time: '2024-01-01T01:00:00Z', // Just after midnight in UTC + timezone: 'Pacific/Honolulu', // Still previous day in Hawaii + expected: true // Changed from false to true since it's a daily habit }, { - id: '5', - amount: 5, - type: 'HABIT_COMPLETION', - description: 'Yesterday habit', - timestamp: '2023-12-31T23:00:00Z' + time: '2024-01-01T12:00:00Z', // Midday UTC + timezone: 'Australia/Sydney', // Evening in Sydney + expected: true + }, + { + time: '2024-01-01T23:59:59Z', // Just before midnight UTC + timezone: 'Europe/London', // Same day in London + expected: true + }, + { + time: '2024-01-01T00:00:01Z', // Just after midnight UTC + timezone: 'Asia/Kolkata', // Same day in India + expected: true } ] - test('calculateCoinsEarnedToday should calculate today\'s earnings including undos', () => { - const result = calculateCoinsEarnedToday(testTransactions, 'UTC') - expect(result).toBe(25) // 10 + 20 - 5 (including the -5 undo) + testCases.forEach(({ time, timezone, expected }) => { + const mockDate = DateTime.fromISO(time) as DateTime + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, timezone)).toBe(expected) }) + }) - test('calculateTotalEarned should calculate lifetime earnings including undos', () => { - const result = calculateTotalEarned(testTransactions) - expect(result).toBe(30) // 10 + 20 + 5 - 5 (including the -5 undo) + test('should handle daylight saving time transitions', () => { + const habit = testHabit('FREQ=DAILY') + + // Test DST transitions in different timezones + const testCases = [ + { + time: '2024-03-10T02:30:00Z', // During DST transition in US + timezone: 'America/New_York', + expected: true + }, + { + time: '2024-10-27T01:30:00Z', // During DST transition in Europe + timezone: 'Europe/London', + expected: true + }, + { + time: '2024-04-07T02:30:00Z', // During DST transition in Australia + timezone: 'Australia/Sydney', + expected: true + } + ] + + testCases.forEach(({ time, timezone, expected }) => { + const mockDate = DateTime.fromISO(time) as DateTime + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, timezone)).toBe(expected) }) + }) - test('calculateTotalSpent should calculate total spent excluding undos', () => { - const result = calculateTotalSpent(testTransactions) - expect(result).toBe(15) // Only the 15 wish redemption (excluding the 5 undo) + test('should handle timezones with half-hour offsets', () => { + const habit = testHabit('FREQ=DAILY') + + const testCases = [ + { + time: '2024-01-01T23:30:00Z', + timezone: 'Asia/Kolkata', // UTC+5:30 + expected: true + }, + { + time: '2024-01-01T00:30:00Z', + timezone: 'Australia/Adelaide', // UTC+9:30/10:30 + expected: true + }, + { + time: '2024-01-01T23:59:59Z', + timezone: 'Asia/Kathmandu', // UTC+5:45 + expected: true + } + ] + + testCases.forEach(({ time, timezone, expected }) => { + const mockDate = DateTime.fromISO(time) as DateTime + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, timezone)).toBe(expected) }) + }) - test('calculateCoinsSpentToday should calculate today\'s spending excluding undos', () => { - const result = calculateCoinsSpentToday(testTransactions, 'UTC') - expect(result).toBe(15) // Only the 15 wish redemption (excluding the 5 undo) + test('should handle timezones that cross the international date line', () => { + const habit = testHabit('FREQ=DAILY') + + const testCases = [ + { + time: '2024-01-01T23:00:00Z', + timezone: 'Pacific/Auckland', // UTC+12/+13 + expected: true + }, + { + time: '2024-01-01T01:00:00Z', + timezone: 'Pacific/Tongatapu', // UTC+13/+14 + expected: true + }, + { + time: '2024-01-01T23:59:59Z', + timezone: 'Pacific/Kiritimati', // UTC+14 + expected: true + } + ] + + testCases.forEach(({ time, timezone, expected }) => { + const mockDate = DateTime.fromISO(time) as DateTime + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, timezone)).toBe(expected) }) + }) + test('should handle monthly recurrence', () => { + const habit = testHabit('FREQ=MONTHLY;BYMONTHDAY=1') + const mockDate = DateTime.fromISO('2024-01-01T00:00:00Z') as DateTime // 1st of month + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, '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 // Jan 1st + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, '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 // Monday + DateTime.now = () => mockDate + expect(isHabitDueToday(habit, 'UTC')).toBe(true) + }) + + test('should return false for invalid recurrence rule', () => { + const habit = testHabit('INVALID_RRULE') + // Mock console.error to prevent test output pollution + const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) + + // Expect the function to throw an error + expect(() => isHabitDueToday(habit, 'UTC')).toThrow() + + consoleSpy.mockRestore() }) }) diff --git a/lib/utils.ts b/lib/utils.ts index 3991342..643b19a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,7 +1,9 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" import { DateTime } from "luxon" -import { Habit, CoinTransaction } from '@/lib/types' +import { datetime, RRule } from 'rrule' +import { Freq, Habit, CoinTransaction } from '@/lib/types' +import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -14,8 +16,8 @@ export function getTodayInTimezone(timezone: string): string { } // get datetime object of now -export function getNow({ timezone = 'utc' }: { timezone?: string }) { - return DateTime.now().setZone(timezone); +export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string, keepLocalTime?: boolean }) { + return DateTime.now().setZone(timezone, { keepLocalTime }); } // get current time in epoch milliseconds @@ -97,18 +99,6 @@ export function getCompletedHabitsForDate({ }) } -export function isHabitCompletedToday({ - habit, - timezone -}: { - habit: Habit, - timezone: string -}): boolean { - const today = getTodayInTimezone(timezone) - const completionsToday = getCompletionsForDate({ habit, date: today, timezone }) - return completionsToday >= (habit.targetCompletions || 1) -} - export function getHabitProgress({ habit, timezone @@ -135,7 +125,7 @@ export function calculateCoinsEarnedToday(transactions: CoinTransaction[], timez export function calculateTotalEarned(transactions: CoinTransaction[]): number { return transactions - .filter(transaction => + .filter(transaction => transaction.amount > 0 || transaction.type === 'HABIT_UNDO' ) .reduce((sum, transaction) => sum + transaction.amount, 0); @@ -144,7 +134,7 @@ export function calculateTotalEarned(transactions: CoinTransaction[]): number { export function calculateTotalSpent(transactions: CoinTransaction[]): number { return Math.abs( transactions - .filter(transaction => + .filter(transaction => transaction.amount < 0 && transaction.type !== 'HABIT_UNDO' ) @@ -173,3 +163,61 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time t2d({ timestamp: today, timezone })) ).length; } + +export function getRRuleUTC(recurrenceRule: string) { + return RRule.fromString(recurrenceRule); // this returns UTC +} + +export function parseNaturalLanguageRRule(ruleText: string) { + ruleText = ruleText.trim() + if (RECURRENCE_RULE_MAP[ruleText]) { + return RRule.fromString(RECURRENCE_RULE_MAP[ruleText]) + } + + return RRule.fromText(ruleText) +} + +export function parseRRule(ruleText: string) { + ruleText = ruleText.trim() + if (RECURRENCE_RULE_MAP[ruleText]) { + return RRule.fromString(RECURRENCE_RULE_MAP[ruleText]) + } + + return RRule.fromString(ruleText) +} + +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') + + const ruleText = habit.frequency + const rrule = parseRRule(ruleText) + + rrule.origOptions.tzid = timezone // set the target timezone, rrule will do calculation in this 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.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 + 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 + return startOfDay <= t && t <= endOfDay +} + +export function getHabitFreq(habit: Habit): Freq { + const rrule = parseRRule(habit.frequency) + const freq = rrule.origOptions.freq + switch (freq) { + case RRule.DAILY: return 'daily' + case RRule.WEEKLY: return 'weekly' + case RRule.MONTHLY: return 'monthly' + case RRule.YEARLY: return 'yearly' + default: throw new Error(`Invalid frequency: ${freq}`) + } +} diff --git a/package-lock.json b/package-lock.json index 5f324e7..8258735 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "react-dom": "^19.0.0", "react-markdown": "^9.0.1", "recharts": "^2.15.0", + "rrule": "^2.8.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7" }, @@ -7254,6 +7255,14 @@ "node": ">=0.10.0" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index 11e809d..ae0b9d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.1.17", + "version": "0.1.18", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -48,6 +48,7 @@ "react-dom": "^19.0.0", "react-markdown": "^9.0.1", "recharts": "^2.15.0", + "rrule": "^2.8.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7" },