mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
enable completing past habit
This commit is contained in:
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
|
||||
|
||||
Reference in New Issue
Block a user