mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-20 22:24:28 +01:00
added recurrence (#35)
This commit is contained in:
@@ -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
|
npm run typecheck && npm run test
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
## Version 0.1.18
|
## Version 0.1.18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- flexible recurrence rule using natural language (#1)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- add modal state not cleared after adding habit (#34)
|
||||||
- daily overview habit count should not show target completions
|
- daily overview habit count should not show target completions
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- habits and wishlist presentation in daily overview
|
||||||
|
|
||||||
## Version 0.1.17
|
## Version 0.1.17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { RRule, RRuleSet, rrulestr } from 'rrule'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
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 data from '@emoji-mart/data'
|
||||||
import Picker from '@emoji-mart/react'
|
import Picker from '@emoji-mart/react'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
|
import { parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
||||||
|
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
||||||
|
|
||||||
interface AddEditHabitModalProps {
|
interface AddEditHabitModalProps {
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (habit: Omit<Habit, 'id'>) => void
|
onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
|
||||||
habit?: Habit | null
|
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 [settings] = useAtom(settingsAtom)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState(habit?.name || '')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState(habit?.description || '')
|
||||||
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'monthly'>('daily')
|
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||||
const [coinReward, setCoinReward] = useState(1)
|
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||||
const [targetCompletions, setTargetCompletions] = useState(1)
|
const origRuleText = parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText()
|
||||||
|
const [ruleText, setRuleText] = useState<string>(origRuleText)
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
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) => {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSave({
|
await onSave({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
frequency,
|
|
||||||
coinReward,
|
coinReward,
|
||||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||||
completions: habit?.completions || []
|
completions: habit?.completions || [],
|
||||||
|
frequency: habit ? (
|
||||||
|
origRuleText === ruleText ? habit.frequency : serializeRRule(parseNaturalLanguageRRule(ruleText))
|
||||||
|
) : serializeRRule(parseNaturalLanguageRRule(ruleText)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{habit ? 'Edit Habit' : 'Add New Habit'}</DialogTitle>
|
<DialogTitle>{habit ? 'Edit Habit' : 'Add New Habit'}</DialogTitle>
|
||||||
@@ -90,7 +82,11 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
|||||||
<Picker
|
<Picker
|
||||||
data={data}
|
data={data}
|
||||||
onEmojiSelect={(emoji: { native: string }) => {
|
onEmojiSelect={(emoji: { native: string }) => {
|
||||||
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
|
// Focus back on input after selection
|
||||||
const input = document.getElementById('name') as HTMLInputElement
|
const input = document.getElementById('name') as HTMLInputElement
|
||||||
input?.focus()
|
input?.focus()
|
||||||
@@ -112,69 +108,109 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="frequency" className="text-right">
|
<Label htmlFor="recurrence" className="text-right">
|
||||||
Frequency
|
Frequency
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={frequency} onValueChange={(value: 'daily' | 'weekly' | 'monthly') => setFrequency(value)}>
|
<div className="col-span-3 space-y-2">
|
||||||
<SelectTrigger className="col-span-3">
|
<Input
|
||||||
<SelectValue placeholder="Select frequency" />
|
id="recurrence"
|
||||||
</SelectTrigger>
|
value={ruleText}
|
||||||
<SelectContent>
|
onChange={(e) => setRuleText(e.target.value)}
|
||||||
<SelectItem value="daily">Daily</SelectItem>
|
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
|
||||||
<SelectItem value="weekly">Weekly</SelectItem>
|
/>
|
||||||
<SelectItem value="monthly">Monthly</SelectItem>
|
</div>
|
||||||
</SelectContent>
|
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
|
||||||
</Select>
|
<span>
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
return parseNaturalLanguageRRule(ruleText).toText()
|
||||||
|
} catch (e: any) {
|
||||||
|
return `Invalid rule: ${e.message}`
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<Label htmlFor="targetCompletions">
|
<Label htmlFor="targetCompletions">
|
||||||
Daily Target
|
Repetitions
|
||||||
</Label>
|
</Label>
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className='text-sm'>
|
|
||||||
<p>How many times you want to complete this habit each day.<br />For example: drink 7 glasses of water or take 3 walks<br /><br />You'll only receive the coin reward after reaching the daily target.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 space-y-2">
|
<div className="col-span-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<Input
|
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||||
id="targetCompletions"
|
<button
|
||||||
type="number"
|
type="button"
|
||||||
value={targetCompletions}
|
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
|
||||||
onChange={(e) => {
|
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||||
const value = parseInt(e.target.value)
|
>
|
||||||
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
|
-
|
||||||
}}
|
</button>
|
||||||
min={1}
|
<Input
|
||||||
max={10}
|
id="targetCompletions"
|
||||||
className="w-20"
|
type="number"
|
||||||
/>
|
value={targetCompletions}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
|
||||||
|
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
times per day
|
times per occurrence
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="coinReward" className="text-right">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
Coin Reward
|
<Label htmlFor="coinReward">
|
||||||
</Label>
|
Coin Reward
|
||||||
<Input
|
</Label>
|
||||||
id="coinReward"
|
</div>
|
||||||
type="number"
|
<div className="col-span-3">
|
||||||
value={coinReward}
|
<div className="flex items-center gap-4">
|
||||||
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
|
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||||
className="col-span-3"
|
<button
|
||||||
min={1}
|
type="button"
|
||||||
required
|
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
|
||||||
/>
|
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
id="coinReward"
|
||||||
|
type="number"
|
||||||
|
value={coinReward}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCoinReward(prev => prev + 1)}
|
||||||
|
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
coins per completion
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react'
|
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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils'
|
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils'
|
||||||
@@ -26,6 +26,7 @@ export default function DailyOverview({
|
|||||||
}: UpcomingItemsProps) {
|
}: UpcomingItemsProps) {
|
||||||
const { completeHabit, undoComplete } = useHabits()
|
const { completeHabit, undoComplete } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
|
||||||
const today = getTodayInTimezone(settings.system.timezone)
|
const today = getTodayInTimezone(settings.system.timezone)
|
||||||
const todayCompletions = getCompletedHabitsForDate({
|
const todayCompletions = getCompletedHabitsForDate({
|
||||||
habits,
|
habits,
|
||||||
@@ -33,12 +34,14 @@ export default function DailyOverview({
|
|||||||
timezone: settings.system.timezone
|
timezone: settings.system.timezone
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter daily habits
|
useEffect(() => {
|
||||||
const dailyHabits = habits.filter(habit => habit.frequency === 'daily')
|
// 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
|
// Get all wishlist items sorted by coin cost
|
||||||
const achievableWishlistItems = wishlistItems
|
const sortedWishlistItems = wishlistItems
|
||||||
.filter(item => item.coinCost > coinBalance)
|
|
||||||
.sort((a, b) => a.coinCost - b.coinCost)
|
.sort((a, b) => a.coinCost - b.coinCost)
|
||||||
|
|
||||||
const [expandedHabits, setExpandedHabits] = useState(false)
|
const [expandedHabits, setExpandedHabits] = useState(false)
|
||||||
@@ -68,11 +71,32 @@ export default function DailyOverview({
|
|||||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-[500px] opacity-100' : 'max-h-[200px] opacity-100'} overflow-hidden`}>
|
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-[500px] opacity-100' : 'max-h-[200px] opacity-100'} overflow-hidden`}>
|
||||||
{dailyHabits
|
{dailyHabits
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
// First by completion status
|
||||||
const aCompleted = todayCompletions.includes(a);
|
const aCompleted = todayCompletions.includes(a);
|
||||||
const bCompleted = todayCompletions.includes(b);
|
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) => {
|
.map((habit) => {
|
||||||
const completionsToday = habit.completions.filter(completion =>
|
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 }))
|
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}
|
{completionsToday}/{target}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{getHabitFreq(habit) !== 'daily' && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{getHabitFreq(habit)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
|
<Coins className={cn(
|
||||||
{habit.coinReward}
|
"h-3 w-3 mr-1 transition-all",
|
||||||
|
isCompleted
|
||||||
|
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
|
||||||
|
: "text-gray-400"
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
"transition-all",
|
||||||
|
isCompleted
|
||||||
|
? "text-yellow-500 font-medium"
|
||||||
|
: "text-gray-400"
|
||||||
|
)}>
|
||||||
|
{habit.coinReward}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpandedHabits(!expandedHabits)}
|
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{expandedHabits ? (
|
|
||||||
<>
|
|
||||||
Show less
|
|
||||||
<ChevronUp className="h-3 w-3" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Show all
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/habits"
|
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
<ArrowRight className="h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className="font-semibold">Wishlist Goals</h3>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{achievableWishlistItems.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-[500px]' : 'max-h-[200px]'} overflow-hidden`}>
|
|
||||||
{achievableWishlistItems
|
|
||||||
.slice(0, expandedWishlist ? undefined : 1)
|
|
||||||
.map((item) => (
|
|
||||||
<div key={item.id} className="bg-secondary/20 p-3 rounded-md">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm">
|
|
||||||
<Linkify>
|
|
||||||
{item.name}
|
|
||||||
</Linkify>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs flex items-center">
|
|
||||||
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
|
|
||||||
{item.coinCost}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={(coinBalance / item.coinCost) * 100}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
{item.coinCost - coinBalance} coins to go
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedWishlist(!expandedWishlist)}
|
onClick={() => setExpandedHabits(!expandedHabits)}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{expandedWishlist ? (
|
{expandedHabits ? (
|
||||||
<>
|
<>
|
||||||
Show less
|
Show less
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -219,7 +196,7 @@ export default function DailyOverview({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/wishlist"
|
href="/habits"
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
@@ -227,9 +204,104 @@ export default function DailyOverview({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold">Wishlist Goals</h3>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-[500px]' : 'max-h-[200px]'} overflow-hidden`}>
|
||||||
|
{sortedWishlistItems.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground text-sm py-4">
|
||||||
|
No wishlist items yet. Add some goals to work towards!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{sortedWishlistItems
|
||||||
|
.slice(0, expandedWishlist ? undefined : 5)
|
||||||
|
.map((item) => {
|
||||||
|
const isRedeemable = item.coinCost <= coinBalance
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/wishlist?highlight=${item.id}`}
|
||||||
|
className={cn(
|
||||||
|
"block p-3 rounded-md hover:bg-secondary/30 transition-colors",
|
||||||
|
isRedeemable ? 'bg-green-500/10' : 'bg-secondary/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm">
|
||||||
|
<Linkify>{item.name}</Linkify>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs flex items-center">
|
||||||
|
<Coins className={cn(
|
||||||
|
"h-3 w-3 mr-1 transition-all",
|
||||||
|
isRedeemable
|
||||||
|
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
|
||||||
|
: "text-gray-400"
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
"transition-all",
|
||||||
|
isRedeemable
|
||||||
|
? "text-yellow-500 font-medium"
|
||||||
|
: "text-gray-400"
|
||||||
|
)}>
|
||||||
|
{item.coinCost}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(coinBalance / item.coinCost) * 100}
|
||||||
|
className={cn(
|
||||||
|
"h-2",
|
||||||
|
isRedeemable ? "bg-green-500/20" : ""
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{isRedeemable
|
||||||
|
? "Ready to redeem!"
|
||||||
|
: `${item.coinCost - coinBalance} coins to go`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedWishlist(!expandedWishlist)}
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{expandedWishlist ? (
|
||||||
|
<>
|
||||||
|
Show less
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Show all
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/wishlist"
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export default function Dashboard() {
|
|||||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<CoinBalance coinBalance={coinBalance} />
|
<CoinBalance coinBalance={coinBalance} />
|
||||||
{/* <HabitOverview /> */}
|
|
||||||
<HabitStreak habits={habits} />
|
<HabitStreak habits={habits} />
|
||||||
<DailyOverview
|
<DailyOverview
|
||||||
wishlistItems={wishlistItems}
|
wishlistItems={wishlistItems}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule } from '@/lib/utils'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Coins, Edit, Trash2, Check, Undo2 } from 'lucide-react'
|
import { Coins, Edit, Trash2, Check, Undo2 } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
import { RRule } from 'rrule'
|
||||||
|
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
||||||
|
|
||||||
interface HabitItemProps {
|
interface HabitItemProps {
|
||||||
habit: Habit
|
habit: Habit
|
||||||
@@ -54,7 +56,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
<CardDescription>{habit.description}</CardDescription>
|
<CardDescription>{habit.description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-gray-500">Frequency: {habit.frequency}</p>
|
<p className="text-sm text-gray-500">Frequency: {parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText()}</p>
|
||||||
<div className="flex items-center mt-2">
|
<div className="flex items-center mt-2">
|
||||||
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
|
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
|
||||||
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span>
|
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span>
|
||||||
@@ -87,7 +89,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
target > 1 ? `Complete (${completionsToday}/${target})` : 'Complete'
|
target > 1 ? `Complete (${completionsToday}/${target})` : 'Complete'
|
||||||
)}
|
)}
|
||||||
{habit.targetCompletions && habit.targetCompletions > 1 && (
|
{habit.targetCompletions && habit.targetCompletions > 1 && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 left-0 h-1 bg-white/50"
|
className="absolute bottom-0 left-0 h-1 bg-white/50"
|
||||||
style={{
|
style={{
|
||||||
width: `${(completionsToday / target) * 100}%`
|
width: `${(completionsToday / target) * 100}%`
|
||||||
|
|||||||
@@ -56,19 +56,20 @@ export default function HabitList() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AddEditHabitModal
|
{isModalOpen &&
|
||||||
isOpen={isModalOpen}
|
<AddEditHabitModal
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
setEditingHabit(null)
|
setEditingHabit(null)
|
||||||
}}
|
}}
|
||||||
onSave={async (habit) => {
|
onSave={async (habit) => {
|
||||||
await saveHabit({ ...habit, id: editingHabit?.id })
|
await saveHabit({ ...habit, id: editingHabit?.id })
|
||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
setEditingHabit(null)
|
setEditingHabit(null)
|
||||||
}}
|
}}
|
||||||
habit={editingHabit}
|
habit={editingHabit}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirmation.isOpen}
|
isOpen={deleteConfirmation.isOpen}
|
||||||
onClose={() => setDeleteConfirmation({ isOpen: false, habitId: null })}
|
onClose={() => setDeleteConfirmation({ isOpen: false, habitId: 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<string, number>)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Habit Overview</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Today's Progress */}
|
|
||||||
<div className="bg-secondary/20 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold mb-2">Today's Progress</h3>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>{completedToday}/{habits.length} completed</span>
|
|
||||||
<BarChart className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Frequency Breakdown */}
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-2">Habit Frequency</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(habitsByFrequency).map(([frequency, count]) => (
|
|
||||||
<div key={frequency} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="capitalize">{frequency}</span>
|
|
||||||
<span className="bg-secondary/30 px-2 py-1 rounded">
|
|
||||||
{count} habits
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
10
lib/constants.ts
Normal file
10
lib/constants.ts
Normal file
@@ -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',
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,12 +2,14 @@ export type Habit = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
frequency: 'daily' | 'weekly' | 'monthly'
|
frequency: string
|
||||||
coinReward: number
|
coinReward: number
|
||||||
targetCompletions?: number // Optional field, default to 1
|
targetCompletions?: number // Optional field, default to 1
|
||||||
completions: string[] // Array of UTC ISO date strings
|
completions: string[] // Array of UTC ISO date strings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Freq = 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||||
|
|
||||||
export type WishlistItemType = {
|
export type WishlistItemType = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
cn,
|
cn,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
@@ -14,9 +14,12 @@ import {
|
|||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
calculateTotalSpent,
|
calculateTotalSpent,
|
||||||
calculateCoinsSpentToday,
|
calculateCoinsSpentToday,
|
||||||
|
isHabitDueToday
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { CoinTransaction } from './types'
|
import { CoinTransaction } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import { RRule } from 'rrule';
|
||||||
|
import { Habit } from '@/lib/types';
|
||||||
|
|
||||||
describe('cn utility', () => {
|
describe('cn utility', () => {
|
||||||
test('should merge class names correctly', () => {
|
test('should merge class names correctly', () => {
|
||||||
@@ -29,146 +32,431 @@ describe('cn utility', () => {
|
|||||||
|
|
||||||
describe('datetime utilities', () => {
|
describe('datetime utilities', () => {
|
||||||
let fixedNow: DateTime;
|
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(() => {
|
beforeEach(() => {
|
||||||
// Fix the current time to 2024-01-01T00:00:00Z
|
// Set fixed date for each test
|
||||||
fixedNow = DateTime.fromISO('2024-01-01T00:00:00Z');
|
const date = DateTime.fromISO(testDates[currentDateIndex]) as DateTime<true>;
|
||||||
DateTime.now = () => fixedNow;
|
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 handle timezone transitions correctly', () => {
|
||||||
test('should return today in YYYY-MM-DD format for timezone', () => {
|
// Test a date that crosses midnight in different timezones
|
||||||
expect(getTodayInTimezone('America/New_York')).toBe('2023-12-31')
|
const testDate = DateTime.fromISO('2024-01-01T23:30:00Z') as DateTime<true>
|
||||||
expect(getTodayInTimezone('Asia/Tokyo')).toBe('2024-01-01')
|
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 handle daylight saving time transitions', () => {
|
||||||
test('should return current datetime in specified timezone', () => {
|
// Test a date during DST transition
|
||||||
const nyNow = getNow({ timezone: 'America/New_York' });
|
const dstDate = DateTime.fromISO('2024-03-10T02:30:00Z') as DateTime<true>
|
||||||
expect(nyNow.zoneName).toBe('America/New_York')
|
DateTime.now = () => dstDate
|
||||||
expect(nyNow.year).toBe(2023)
|
|
||||||
expect(nyNow.month).toBe(12)
|
|
||||||
expect(nyNow.day).toBe(31)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should default to UTC', () => {
|
// In New York (UTC-4 during DST)
|
||||||
const utcNow = getNow({});
|
expect(getTodayInTimezone('America/New_York')).toBe('2024-03-09')
|
||||||
expect(utcNow.zoneName).toBe('UTC')
|
|
||||||
})
|
// In London (UTC+0/BST+1)
|
||||||
|
expect(getTodayInTimezone('Europe/London')).toBe('2024-03-10')
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNowInMilliseconds', () => {
|
test('should handle edge cases around midnight', () => {
|
||||||
test('should return current time in milliseconds', () => {
|
// Test just before and after midnight in different timezones
|
||||||
expect(getNowInMilliseconds()).toBe('1704067200000')
|
const justBeforeMidnight = DateTime.fromISO('2024-01-01T23:59:59Z') as DateTime<true>
|
||||||
})
|
const justAfterMidnight = DateTime.fromISO('2024-01-02T00:00:01Z') as DateTime<true>
|
||||||
|
|
||||||
|
// 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', () => {
|
test('should handle all timezones correctly', () => {
|
||||||
const testTimestamp = '2024-01-01T00:00:00.000Z';
|
const testZones = [
|
||||||
const testDateTime = DateTime.fromISO(testTimestamp);
|
'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 testDate = DateTime.fromISO('2024-01-01T12:00:00Z') as DateTime<true>
|
||||||
const result = t2d({ timestamp: testTimestamp, timezone: 'utc' });
|
DateTime.now = () => testDate
|
||||||
// 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', () => {
|
testZones.forEach(zone => {
|
||||||
const result = d2t({ dateTime: testDateTime });
|
const expected = testDate.setZone(zone).toFormat('yyyy-MM-dd')
|
||||||
expect(result).toBe(testTimestamp)
|
expect(getTodayInTimezone(zone)).toBe(expected)
|
||||||
})
|
|
||||||
|
|
||||||
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', () => {
|
describe('getNow', () => {
|
||||||
test('should compare dates correctly', () => {
|
test('should return current datetime in specified timezone', () => {
|
||||||
const date1 = DateTime.fromISO('2024-01-01T12:00:00Z');
|
const nyNow = getNow({ timezone: 'America/New_York' });
|
||||||
const date2 = DateTime.fromISO('2024-01-01T15:00:00Z');
|
expect(nyNow.zoneName).toBe('America/New_York')
|
||||||
const date3 = DateTime.fromISO('2024-01-02T12:00:00Z');
|
|
||||||
|
|
||||||
expect(isSameDate(date1, date2)).toBe(true)
|
// Get the expected values from the fixed test date
|
||||||
expect(isSameDate(date1, date3)).toBe(false)
|
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', () => {
|
test('should default to UTC', () => {
|
||||||
const testTransactions: CoinTransaction[] = [
|
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<true>
|
||||||
|
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<true> // 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<true> // 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',
|
time: '2024-01-01T04:00:00Z', // UTC time that's still previous day in New York
|
||||||
amount: 10,
|
timezone: 'America/New_York',
|
||||||
type: 'HABIT_COMPLETION',
|
expected: true
|
||||||
description: 'Test habit',
|
|
||||||
timestamp: '2024-01-01T12:00:00Z'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
time: '2024-01-01T04:00:00Z',
|
||||||
amount: -5,
|
timezone: 'UTC',
|
||||||
type: 'HABIT_UNDO',
|
expected: true
|
||||||
description: 'Undo test habit',
|
|
||||||
timestamp: '2024-01-01T13:00:00Z',
|
|
||||||
relatedItemId: '1'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
time: '2024-01-01T23:00:00Z', // Just before midnight in UTC
|
||||||
amount: 20,
|
timezone: 'Asia/Tokyo', // Already next day in Tokyo
|
||||||
type: 'HABIT_COMPLETION',
|
expected: true
|
||||||
description: 'Another habit',
|
|
||||||
timestamp: '2024-01-01T14:00:00Z'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
time: '2024-01-01T01:00:00Z', // Just after midnight in UTC
|
||||||
amount: -15,
|
timezone: 'Pacific/Honolulu', // Still previous day in Hawaii
|
||||||
type: 'WISH_REDEMPTION',
|
expected: true // Changed from false to true since it's a daily habit
|
||||||
description: 'Redeemed wish',
|
|
||||||
timestamp: '2024-01-01T15:00:00Z'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
time: '2024-01-01T12:00:00Z', // Midday UTC
|
||||||
amount: 5,
|
timezone: 'Australia/Sydney', // Evening in Sydney
|
||||||
type: 'HABIT_COMPLETION',
|
expected: true
|
||||||
description: 'Yesterday habit',
|
},
|
||||||
timestamp: '2023-12-31T23:00:00Z'
|
{
|
||||||
|
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', () => {
|
testCases.forEach(({ time, timezone, expected }) => {
|
||||||
const result = calculateCoinsEarnedToday(testTransactions, 'UTC')
|
const mockDate = DateTime.fromISO(time) as DateTime<true>
|
||||||
expect(result).toBe(25) // 10 + 20 - 5 (including the -5 undo)
|
DateTime.now = () => mockDate
|
||||||
|
expect(isHabitDueToday(habit, timezone)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('calculateTotalEarned should calculate lifetime earnings including undos', () => {
|
test('should handle daylight saving time transitions', () => {
|
||||||
const result = calculateTotalEarned(testTransactions)
|
const habit = testHabit('FREQ=DAILY')
|
||||||
expect(result).toBe(30) // 10 + 20 + 5 - 5 (including the -5 undo)
|
|
||||||
|
// 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<true>
|
||||||
|
DateTime.now = () => mockDate
|
||||||
|
expect(isHabitDueToday(habit, timezone)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('calculateTotalSpent should calculate total spent excluding undos', () => {
|
test('should handle timezones with half-hour offsets', () => {
|
||||||
const result = calculateTotalSpent(testTransactions)
|
const habit = testHabit('FREQ=DAILY')
|
||||||
expect(result).toBe(15) // Only the 15 wish redemption (excluding the 5 undo)
|
|
||||||
|
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<true>
|
||||||
|
DateTime.now = () => mockDate
|
||||||
|
expect(isHabitDueToday(habit, timezone)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('calculateCoinsSpentToday should calculate today\'s spending excluding undos', () => {
|
test('should handle timezones that cross the international date line', () => {
|
||||||
const result = calculateCoinsSpentToday(testTransactions, 'UTC')
|
const habit = testHabit('FREQ=DAILY')
|
||||||
expect(result).toBe(15) // Only the 15 wish redemption (excluding the 5 undo)
|
|
||||||
|
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<true>
|
||||||
|
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<true> // 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<true> // 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<true> // 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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
82
lib/utils.ts
82
lib/utils.ts
@@ -1,7 +1,9 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { DateTime } from "luxon"
|
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[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -14,8 +16,8 @@ export function getTodayInTimezone(timezone: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get datetime object of now
|
// get datetime object of now
|
||||||
export function getNow({ timezone = 'utc' }: { timezone?: string }) {
|
export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string, keepLocalTime?: boolean }) {
|
||||||
return DateTime.now().setZone(timezone);
|
return DateTime.now().setZone(timezone, { keepLocalTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
// get current time in epoch milliseconds
|
// 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({
|
export function getHabitProgress({
|
||||||
habit,
|
habit,
|
||||||
timezone
|
timezone
|
||||||
@@ -135,7 +125,7 @@ export function calculateCoinsEarnedToday(transactions: CoinTransaction[], timez
|
|||||||
|
|
||||||
export function calculateTotalEarned(transactions: CoinTransaction[]): number {
|
export function calculateTotalEarned(transactions: CoinTransaction[]): number {
|
||||||
return transactions
|
return transactions
|
||||||
.filter(transaction =>
|
.filter(transaction =>
|
||||||
transaction.amount > 0 || transaction.type === 'HABIT_UNDO'
|
transaction.amount > 0 || transaction.type === 'HABIT_UNDO'
|
||||||
)
|
)
|
||||||
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||||
@@ -144,7 +134,7 @@ export function calculateTotalEarned(transactions: CoinTransaction[]): number {
|
|||||||
export function calculateTotalSpent(transactions: CoinTransaction[]): number {
|
export function calculateTotalSpent(transactions: CoinTransaction[]): number {
|
||||||
return Math.abs(
|
return Math.abs(
|
||||||
transactions
|
transactions
|
||||||
.filter(transaction =>
|
.filter(transaction =>
|
||||||
transaction.amount < 0 &&
|
transaction.amount < 0 &&
|
||||||
transaction.type !== 'HABIT_UNDO'
|
transaction.type !== 'HABIT_UNDO'
|
||||||
)
|
)
|
||||||
@@ -173,3 +163,61 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
|
|||||||
t2d({ timestamp: today, timezone }))
|
t2d({ timestamp: today, timezone }))
|
||||||
).length;
|
).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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -41,6 +41,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
|
"rrule": "^2.8.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
@@ -7254,6 +7255,14 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.17",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
|
"rrule": "^2.8.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user