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

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