mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
added habit daily completion target (#26)
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.1.13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- habits now support daily completion target (e.g. 7 cups of water)
|
||||||
|
- Added emoji picker for habit and wishlist names
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- habit completion now stores as ISO format
|
||||||
|
|
||||||
## Version 0.1.12
|
## Version 0.1.12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -142,24 +142,13 @@ This will create an optimized production build in the `.next` directory.
|
|||||||
The project uses several tools to maintain code quality:
|
The project uses several tools to maintain code quality:
|
||||||
|
|
||||||
- ESLint for linting: `npm run lint`
|
- ESLint for linting: `npm run lint`
|
||||||
- TypeScript type checking: `npm run type-check`
|
- TypeScript type checking: `npm run typecheck`
|
||||||
|
|
||||||
Run these commands regularly during development to catch issues early.
|
Run these commands regularly during development to catch issues early.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! We appreciate both:
|
We welcome feature requests and bug reports! Please [open an issue](https://github.com/dohsimpson/habittrove/issues/new). We do not accept pull request at the moment.
|
||||||
|
|
||||||
- Issue submissions for bug reports and feature requests
|
|
||||||
- Pull Requests for code contributions
|
|
||||||
|
|
||||||
For major changes, please open an issue first to discuss what you would like to change.
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
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'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { Info, SmilePlus } from 'lucide-react'
|
||||||
|
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 { Habit } from '@/lib/types'
|
||||||
|
|
||||||
interface AddEditHabitModalProps {
|
interface AddEditHabitModalProps {
|
||||||
@@ -15,10 +22,12 @@ interface AddEditHabitModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: AddEditHabitModalProps) {
|
export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: AddEditHabitModalProps) {
|
||||||
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'monthly'>('daily')
|
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'monthly'>('daily')
|
||||||
const [coinReward, setCoinReward] = useState(1)
|
const [coinReward, setCoinReward] = useState(1)
|
||||||
|
const [targetCompletions, setTargetCompletions] = useState(1)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (habit) {
|
if (habit) {
|
||||||
@@ -26,6 +35,7 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
|||||||
setDescription(habit.description)
|
setDescription(habit.description)
|
||||||
setFrequency(habit.frequency)
|
setFrequency(habit.frequency)
|
||||||
setCoinReward(habit.coinReward)
|
setCoinReward(habit.coinReward)
|
||||||
|
setTargetCompletions(habit.targetCompletions || 1)
|
||||||
} else {
|
} else {
|
||||||
setName('')
|
setName('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
@@ -36,7 +46,14 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSave({ name, description, frequency, coinReward, completions: habit?.completions || [] })
|
onSave({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
frequency,
|
||||||
|
coinReward,
|
||||||
|
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||||
|
completions: habit?.completions || []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,13 +68,37 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
|||||||
<Label htmlFor="name" className="text-right">
|
<Label htmlFor="name" className="text-right">
|
||||||
Name
|
Name
|
||||||
</Label>
|
</Label>
|
||||||
|
<div className='flex col-span-3 gap-2'>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="col-span-3"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<SmilePlus className="h-8 w-8" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0">
|
||||||
|
<Picker
|
||||||
|
data={data}
|
||||||
|
onEmojiSelect={(emoji: any) => {
|
||||||
|
setName(prev => `${prev}${emoji.native}`)
|
||||||
|
// Focus back on input after selection
|
||||||
|
const input = document.getElementById('name') as HTMLInputElement
|
||||||
|
input?.focus()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</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="description" className="text-right">
|
<Label htmlFor="description" className="text-right">
|
||||||
@@ -85,6 +126,42 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<Label htmlFor="targetCompletions">
|
||||||
|
Daily Target
|
||||||
|
</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 className="col-span-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="targetCompletions"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
times per day
|
||||||
|
</span>
|
||||||
|
</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">
|
<Label htmlFor="coinReward" className="text-right">
|
||||||
Coin Reward
|
Coin Reward
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { SmilePlus } from 'lucide-react'
|
||||||
|
import data from '@emoji-mart/data'
|
||||||
|
import Picker from '@emoji-mart/react'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
|
||||||
interface AddEditWishlistItemModalProps {
|
interface AddEditWishlistItemModalProps {
|
||||||
@@ -47,13 +51,38 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
|
|||||||
<Label htmlFor="name" className="text-right">
|
<Label htmlFor="name" className="text-right">
|
||||||
Name
|
Name
|
||||||
</Label>
|
</Label>
|
||||||
|
<div className="col-span-3 flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="col-span-3"
|
className="flex-1"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<SmilePlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0">
|
||||||
|
<Picker
|
||||||
|
data={data}
|
||||||
|
onEmojiSelect={(emoji: any) => {
|
||||||
|
setName(prev => `${prev}${emoji.native}`)
|
||||||
|
// Focus back on input after selection
|
||||||
|
const input = document.getElementById('name') as HTMLInputElement
|
||||||
|
input?.focus()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</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="description" className="text-right">
|
<Label htmlFor="description" className="text-right">
|
||||||
|
|||||||
0
components/CoinBalance.test.tsx
Normal file
0
components/CoinBalance.test.tsx
Normal file
@@ -1,36 +1,37 @@
|
|||||||
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 Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone } from '@/lib/utils'
|
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
|
||||||
interface UpcomingItemsProps {
|
interface UpcomingItemsProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
wishlistItems: WishlistItemType[]
|
wishlistItems: WishlistItemType[]
|
||||||
coinBalance: number
|
coinBalance: number
|
||||||
onComplete: (habit: Habit) => void
|
|
||||||
onUndo: (habit: Habit) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DailyOverview({
|
export default function DailyOverview({
|
||||||
habits,
|
habits,
|
||||||
wishlistItems,
|
wishlistItems,
|
||||||
coinBalance,
|
coinBalance,
|
||||||
onComplete,
|
|
||||||
onUndo
|
|
||||||
}: UpcomingItemsProps) {
|
}: UpcomingItemsProps) {
|
||||||
|
const { completeHabit, undoComplete } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const today = getTodayInTimezone(settings.system.timezone)
|
const today = getTodayInTimezone(settings.system.timezone)
|
||||||
const todayCompletions = habits.filter(habit =>
|
const todayCompletions = getCompletedHabitsForDate({
|
||||||
habit.completions.includes(today)
|
habits,
|
||||||
)
|
date: getNow({ timezone: settings.system.timezone }),
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
})
|
||||||
|
|
||||||
// Filter daily habits
|
// Filter daily habits
|
||||||
const dailyHabits = habits.filter(habit => habit.frequency === 'daily')
|
const dailyHabits = habits.filter(habit => habit.frequency === 'daily')
|
||||||
@@ -54,7 +55,12 @@ export default function DailyOverview({
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="font-semibold">Daily Habits</h3>
|
<h3 className="font-semibold">Daily Habits</h3>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{todayCompletions.length}/{dailyHabits.length} Complete
|
{dailyHabits.reduce((sum, habit) => sum + getCompletionsForDate({
|
||||||
|
habit,
|
||||||
|
date: today,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
}), 0)}/
|
||||||
|
{dailyHabits.reduce((sum, habit) => sum + (habit.targetCompletions || 1), 0)} Completions
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<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`}>
|
||||||
@@ -66,7 +72,11 @@ export default function DailyOverview({
|
|||||||
})
|
})
|
||||||
.slice(0, expandedHabits ? undefined : 3)
|
.slice(0, expandedHabits ? undefined : 3)
|
||||||
.map((habit) => {
|
.map((habit) => {
|
||||||
const isCompleted = todayCompletions.includes(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 }))
|
||||||
|
).length
|
||||||
|
const target = habit.targetCompletions || 1
|
||||||
|
const isCompleted = completionsToday >= target
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={habit.id}
|
key={habit.id}
|
||||||
@@ -78,17 +88,30 @@ export default function DailyOverview({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isCompleted) {
|
if (isCompleted) {
|
||||||
onUndo(habit);
|
undoComplete(habit);
|
||||||
} else {
|
} else {
|
||||||
onComplete(habit);
|
completeHabit(habit);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="hover:opacity-70 transition-opacity"
|
className="relative hover:opacity-70 transition-opacity w-4 h-4"
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<Circle className="h-4 w-4" />
|
<div className="relative h-4 w-4">
|
||||||
|
<Circle className="absolute h-4 w-4 text-muted-foreground" />
|
||||||
|
<div
|
||||||
|
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(
|
||||||
|
currentColor ${(completionsToday / target) * 360}deg,
|
||||||
|
transparent ${(completionsToday / target) * 360}deg 360deg
|
||||||
|
)`,
|
||||||
|
mask: 'radial-gradient(transparent 50%, black 51%)',
|
||||||
|
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span className={isCompleted ? 'line-through' : ''}>
|
<span className={isCompleted ? 'line-through' : ''}>
|
||||||
@@ -97,10 +120,17 @@ export default function DailyOverview({
|
|||||||
</Linkify>
|
</Linkify>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center text-xs text-muted-foreground">
|
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{habit.targetCompletions && (
|
||||||
|
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
|
||||||
|
{completionsToday}/{target}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center">
|
||||||
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
|
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
|
||||||
{habit.coinReward}
|
{habit.coinReward}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import HabitStreak from './HabitStreak'
|
|||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { completeHabit, undoComplete } = useHabits()
|
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
const habits = habitsData.habits
|
const habits = habitsData.habits
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
@@ -28,8 +27,6 @@ export default function Dashboard() {
|
|||||||
wishlistItems={wishlistItems}
|
wishlistItems={wishlistItems}
|
||||||
habits={habits}
|
habits={habits}
|
||||||
coinBalance={coinBalance}
|
coinBalance={coinBalance}
|
||||||
onComplete={completeHabit}
|
|
||||||
onUndo={undoComplete}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* <HabitHeatmap habits={habits} /> */}
|
{/* <HabitHeatmap habits={habits} /> */}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useState } from 'react'
|
|||||||
import { Calendar } from '@/components/ui/calendar'
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { d2s, getNow } from '@/lib/utils'
|
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { habitsAtom, settingsAtom } from '@/lib/atoms'
|
import { habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
|
import { Habit } from '@/lib/types'
|
||||||
|
|
||||||
export default function HabitCalendar() {
|
export default function HabitCalendar() {
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
@@ -17,10 +18,11 @@ export default function HabitCalendar() {
|
|||||||
const habits = habitsData.habits
|
const habits = habitsData.habits
|
||||||
|
|
||||||
const getHabitsForDate = (date: Date) => {
|
const getHabitsForDate = (date: Date) => {
|
||||||
const dateString = date.toISOString().split('T')[0]
|
return getCompletedHabitsForDate({
|
||||||
return habits.filter(habit =>
|
habits,
|
||||||
habit.completions.includes(dateString)
|
date: DateTime.fromJSDate(date),
|
||||||
)
|
timezone: settings.system.timezone
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +52,7 @@ export default function HabitCalendar() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{selectedDate ? (
|
{selectedDate ? (
|
||||||
<>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone })}</>
|
<>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: "yyyy-MM-dd" })}</>
|
||||||
) : (
|
) : (
|
||||||
'Select a date'
|
'Select a date'
|
||||||
)}
|
)}
|
||||||
@@ -60,7 +62,7 @@ export default function HabitCalendar() {
|
|||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{habits.map((habit) => {
|
{habits.map((habit) => {
|
||||||
const isCompleted = getHabitsForDate(selectedDate.toJSDate()).some(h => h.id === habit.id)
|
const isCompleted = getHabitsForDate(selectedDate.toJSDate()).some((h: Habit) => h.id === habit.id)
|
||||||
return (
|
return (
|
||||||
<li key={habit.id} className="flex items-center justify-between">
|
<li key={habit.id} className="flex items-center justify-between">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import HeatMap from '@uiw/react-heat-map'
|
import HeatMap from '@uiw/react-heat-map'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { getNow } from '@/lib/utils'
|
import { getNow, d2s, t2d } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
|
|
||||||
@@ -13,10 +13,21 @@ interface HabitHeatmapProps {
|
|||||||
export default function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
export default function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||||
// Aggregate all habit completions into a count per day
|
// Aggregate all habit completions into a count per day
|
||||||
const completionCounts = habits.reduce((acc: { [key: string]: number }, habit) => {
|
const completionCounts = habits.reduce((acc: { [key: string]: number }, habit) => {
|
||||||
habit.completions.forEach(date => {
|
const target = habit.targetCompletions || 1
|
||||||
// Convert date format from ISO (YYYY-MM-DD) to YYYY/MM/DD for the heatmap
|
const dailyCompletions = habit.completions.reduce((dailyAcc, completion) => {
|
||||||
const formattedDate = date.replace(/-/g, '/')
|
const formattedDate = d2s({
|
||||||
acc[formattedDate] = (acc[formattedDate] || 0) + 1
|
dateTime: t2d({ timestamp: completion, timezone: settings.system.timezone }),
|
||||||
|
format: 'yyyy-MM-dd',
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
})
|
||||||
|
dailyAcc[formattedDate] = (dailyAcc[formattedDate] || 0) + 1
|
||||||
|
return dailyAcc
|
||||||
|
}, {} as { [key: string]: number })
|
||||||
|
|
||||||
|
Object.entries(dailyCompletions).forEach(([date, count]) => {
|
||||||
|
if (count >= target) {
|
||||||
|
acc[date] = (acc[date] || 0) + 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from '@/lib/utils'
|
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } 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'
|
||||||
@@ -18,7 +18,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
const { completeHabit, undoComplete } = useHabits()
|
const { completeHabit, undoComplete } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const today = getTodayInTimezone(settings.system.timezone)
|
const today = getTodayInTimezone(settings.system.timezone)
|
||||||
const isCompletedToday = habit.completions?.includes(today)
|
const completionsToday = habit.completions?.filter(completion =>
|
||||||
|
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||||
|
).length || 0
|
||||||
|
const target = habit.targetCompletions || 1
|
||||||
|
const isCompletedToday = completionsToday >= target
|
||||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,16 +72,31 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant={isCompletedToday ? "secondary" : "default"}
|
variant={isCompletedToday ? "secondary" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => await completeHabit(habit)}
|
onClick={async () => await completeHabit(habit)}
|
||||||
disabled={isCompletedToday}
|
disabled={isCompletedToday && completionsToday >= target}
|
||||||
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-2" />
|
<Check className="h-4 w-4 mr-2" />
|
||||||
{isCompletedToday ? "Completed" : "Complete"}
|
{isCompletedToday ? (
|
||||||
|
target > 1 ? `Completed (${completionsToday}/${target})` : 'Completed'
|
||||||
|
) : (
|
||||||
|
target > 1 ? `Complete (${completionsToday}/${target})` : 'Complete'
|
||||||
|
)}
|
||||||
|
{habit.targetCompletions && habit.targetCompletions > 1 && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 h-1 bg-white/50"
|
||||||
|
style={{
|
||||||
|
width: `${(completionsToday / target) * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{isCompletedToday && (
|
</div>
|
||||||
|
{completionsToday > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { BarChart } from 'lucide-react'
|
import { BarChart } from 'lucide-react'
|
||||||
import { getTodayInTimezone } from '@/lib/utils'
|
import { getTodayInTimezone, getCompletedHabitsForDate } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { habitsAtom, settingsAtom } from '@/lib/atoms'
|
import { habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
|
|
||||||
@@ -11,9 +11,11 @@ export default function HabitOverview() {
|
|||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const today = getTodayInTimezone(settings.system.timezone)
|
const today = getTodayInTimezone(settings.system.timezone)
|
||||||
|
|
||||||
const completedToday = habits.filter(habit =>
|
const completedToday = getCompletedHabitsForDate({
|
||||||
habit.completions.includes(today)
|
habits,
|
||||||
).length
|
date: today,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
}).length
|
||||||
|
|
||||||
const habitsByFrequency = habits.reduce((acc, habit) => ({
|
const habitsByFrequency = habits.reduce((acc, habit) => ({
|
||||||
...acc,
|
...acc,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { d2s, getNow } from '@/lib/utils'
|
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
import { settingsAtom } from '@/lib/atoms'
|
||||||
@@ -20,9 +20,11 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
|||||||
}).reverse()
|
}).reverse()
|
||||||
|
|
||||||
const completions = dates.map(date => {
|
const completions = dates.map(date => {
|
||||||
const completedCount = habits.reduce((count, habit) => {
|
const completedCount = getCompletedHabitsForDate({
|
||||||
return count + (habit.completions.includes(date) ? 1 : 0);
|
habits,
|
||||||
}, 0);
|
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
}).length;
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
completed: completedCount
|
completed: completedCount
|
||||||
|
|||||||
33
components/ui/popover.tsx
Normal file
33
components/ui/popover.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
32
components/ui/tooltip.tsx
Normal file
32
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -2,7 +2,7 @@ import { useAtom } from 'jotai'
|
|||||||
import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
|
import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { getNowInMilliseconds, getTodayInTimezone } from '@/lib/utils'
|
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate } from '@/lib/utils'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { Undo2 } from 'lucide-react'
|
import { Undo2 } from 'lucide-react'
|
||||||
@@ -15,71 +15,118 @@ export function useHabits() {
|
|||||||
const completeHabit = async (habit: Habit) => {
|
const completeHabit = async (habit: Habit) => {
|
||||||
const timezone = settings.system.timezone
|
const timezone = settings.system.timezone
|
||||||
const today = getTodayInTimezone(timezone)
|
const today = getTodayInTimezone(timezone)
|
||||||
if (!habit.completions.includes(today)) {
|
|
||||||
|
// Get current completions for today
|
||||||
|
const completionsToday = getCompletionsForDate({
|
||||||
|
habit,
|
||||||
|
date: today,
|
||||||
|
timezone
|
||||||
|
})
|
||||||
|
const target = habit.targetCompletions || 1
|
||||||
|
|
||||||
|
// Add new completion
|
||||||
const updatedHabit = {
|
const updatedHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
completions: [...habit.completions, today]
|
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })]
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedHabits = habitsData.habits.map(h =>
|
const updatedHabits = habitsData.habits.map(h =>
|
||||||
h.id === habit.id ? updatedHabit : h
|
h.id === habit.id ? updatedHabit : h
|
||||||
)
|
)
|
||||||
|
|
||||||
await saveHabitsData({ habits: updatedHabits })
|
await saveHabitsData({ habits: updatedHabits })
|
||||||
setHabitsData({ habits: updatedHabits })
|
setHabitsData({ habits: updatedHabits })
|
||||||
|
|
||||||
const coinsData = await addCoins(habit.coinReward, `Completed habit: ${habit.name}`, 'HABIT_COMPLETION', habit.id)
|
// Check if we've now reached the target
|
||||||
setCoins(coinsData)
|
const isTargetReached = completionsToday + 1 === target
|
||||||
|
if (isTargetReached) {
|
||||||
|
const updatedCoins = await addCoins(
|
||||||
|
habit.coinReward,
|
||||||
|
`Completed habit: ${habit.name}`,
|
||||||
|
'HABIT_COMPLETION',
|
||||||
|
habit.id
|
||||||
|
)
|
||||||
|
setCoins(updatedCoins)
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Habit completed!",
|
title: isTargetReached ? "Habit completed!" : "Progress!",
|
||||||
description: `You earned ${habit.coinReward} coins.`,
|
description: isTargetReached
|
||||||
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(habit)}>
|
? `You earned ${habit.coinReward} coins.`
|
||||||
|
: `You've completed ${completionsToday + 1}/${target} times today.`,
|
||||||
|
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
|
||||||
<Undo2 className="h-4 w-4" />Undo
|
<Undo2 className="h-4 w-4" />Undo
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updatedHabits,
|
updatedHabits,
|
||||||
newBalance: coinsData.balance,
|
newBalance: coins.balance,
|
||||||
newTransactions: coinsData.transactions
|
newTransactions: coins.transactions
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "Habit already completed",
|
|
||||||
description: "You've already completed this habit today.",
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const undoComplete = async (habit: Habit) => {
|
const undoComplete = async (habit: Habit) => {
|
||||||
const timezone = settings.system.timezone
|
const timezone = settings.system.timezone
|
||||||
const today = getTodayInTimezone(timezone)
|
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
|
||||||
|
|
||||||
|
// Get today's completions
|
||||||
|
const todayCompletions = habit.completions.filter(completion =>
|
||||||
|
isSameDate(t2d({ timestamp: completion, timezone }), today)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (todayCompletions.length > 0) {
|
||||||
|
// Remove the most recent completion
|
||||||
const updatedHabit = {
|
const updatedHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
completions: habit.completions.filter(date => date !== today)
|
completions: habit.completions.filter(
|
||||||
|
(_, index) => index !== habit.completions.length - 1
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedHabits = habitsData.habits.map(h =>
|
const updatedHabits = habitsData.habits.map(h =>
|
||||||
h.id === habit.id ? updatedHabit : h
|
h.id === habit.id ? updatedHabit : h
|
||||||
)
|
)
|
||||||
|
|
||||||
await saveHabitsData({ habits: updatedHabits })
|
await saveHabitsData({ habits: updatedHabits })
|
||||||
setHabitsData({ habits: updatedHabits })
|
setHabitsData({ habits: updatedHabits })
|
||||||
|
|
||||||
const coinsData = await removeCoins(habit.coinReward, `Undid habit completion: ${habit.name}`, 'HABIT_UNDO', habit.id)
|
// If we were at the target, remove the coins
|
||||||
setCoins(coinsData)
|
const target = habit.targetCompletions || 1
|
||||||
|
if (todayCompletions.length === target) {
|
||||||
|
const updatedCoins = await removeCoins(
|
||||||
|
habit.coinReward,
|
||||||
|
`Undid habit completion: ${habit.name}`,
|
||||||
|
'HABIT_UNDO',
|
||||||
|
habit.id
|
||||||
|
)
|
||||||
|
setCoins(updatedCoins)
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Completion undone",
|
title: "Completion undone",
|
||||||
description: `${habit.coinReward} coins have been deducted.`,
|
description: `You have ${getCompletionsForDate({
|
||||||
action: <ToastAction altText="Redo" onClick={() => completeHabit(habit)}>
|
habit: updatedHabit,
|
||||||
<Undo2 className="h-4 w-4" />Undo
|
date: today,
|
||||||
|
timezone
|
||||||
|
})}/${target} completions today.`,
|
||||||
|
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}>
|
||||||
|
<Undo2 className="h-4 w-4" />Redo
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updatedHabits,
|
updatedHabits,
|
||||||
newBalance: coinsData.balance,
|
newBalance: coins.balance,
|
||||||
newTransactions: coinsData.transactions
|
newTransactions: coins.transactions
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "No completions to undo",
|
||||||
|
description: "This habit hasn't been completed today.",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ export type Habit = {
|
|||||||
description: string
|
description: string
|
||||||
frequency: 'daily' | 'weekly' | 'monthly'
|
frequency: 'daily' | 'weekly' | 'monthly'
|
||||||
coinReward: number
|
coinReward: number
|
||||||
completions: string[] // Array of ISO date strings
|
targetCompletions?: number // Optional field, default to 1
|
||||||
|
completions: string[] // Array of UTC ISO date strings
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WishlistItemType = {
|
export type WishlistItemType = {
|
||||||
|
|||||||
66
lib/utils.ts
66
lib/utils.ts
@@ -1,6 +1,7 @@
|
|||||||
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 } from '@/lib/types'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -55,3 +56,68 @@ export function d2n({ dateTime }: { dateTime: DateTime }) {
|
|||||||
export function isSameDate(a: DateTime, b: DateTime) {
|
export function isSameDate(a: DateTime, b: DateTime) {
|
||||||
return a.hasSame(b, 'day');
|
return a.hasSame(b, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeCompletionDate(date: string, timezone: string): string {
|
||||||
|
// If already in ISO format, return as is
|
||||||
|
if (date.includes('T')) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
// Convert from yyyy-MM-dd to ISO format
|
||||||
|
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompletionsForDate({
|
||||||
|
habit,
|
||||||
|
date,
|
||||||
|
timezone
|
||||||
|
}: {
|
||||||
|
habit: Habit,
|
||||||
|
date: DateTime | string,
|
||||||
|
timezone: string
|
||||||
|
}): number {
|
||||||
|
const dateObj = typeof date === 'string' ? DateTime.fromISO(date) : date
|
||||||
|
return habit.completions.filter((completion: string) =>
|
||||||
|
isSameDate(t2d({ timestamp: completion, timezone }), dateObj)
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompletedHabitsForDate({
|
||||||
|
habits,
|
||||||
|
date,
|
||||||
|
timezone
|
||||||
|
}: {
|
||||||
|
habits: Habit[],
|
||||||
|
date: DateTime | string,
|
||||||
|
timezone: string
|
||||||
|
}): Habit[] {
|
||||||
|
return habits.filter(habit => {
|
||||||
|
const completionsToday = getCompletionsForDate({ habit, date, timezone })
|
||||||
|
const target = habit.targetCompletions || 1
|
||||||
|
return completionsToday >= target
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}: {
|
||||||
|
habit: Habit,
|
||||||
|
timezone: string
|
||||||
|
}): number {
|
||||||
|
const today = getTodayInTimezone(timezone)
|
||||||
|
const completionsToday = getCompletionsForDate({ habit, date: today, timezone })
|
||||||
|
const target = habit.targetCompletions || 1
|
||||||
|
return Math.min(100, (completionsToday / target) * 100)
|
||||||
|
}
|
||||||
|
|||||||
97
package-lock.json
generated
97
package-lock.json
generated
@@ -1,23 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@next/font": "^14.2.15",
|
"@next/font": "^14.2.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-progress": "^1.1.1",
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@uiw/react-heat-map": "^2.3.2",
|
"@uiw/react-heat-map": "^2.3.2",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
@@ -89,6 +93,20 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emoji-mart/data": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="
|
||||||
|
},
|
||||||
|
"node_modules/@emoji-mart/react": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"emoji-mart": "^5.2",
|
||||||
|
"react": "^16.8 || ^17 || ^18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
|
||||||
@@ -1235,6 +1253,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-popover": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-aUACAkXx8LaFymDma+HQVji7WhvEhpFJ7+qPz17Nf4lLZqtreGOFRiNQWQmhzp7kEWg9cOyyQJpdIMUMPc/CPw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.1",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.0",
|
||||||
|
"@radix-ui/react-popper": "1.2.1",
|
||||||
|
"@radix-ui/react-portal": "1.1.3",
|
||||||
|
"@radix-ui/react-presence": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.0.1",
|
||||||
|
"@radix-ui/react-slot": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||||
|
"aria-hidden": "^1.1.1",
|
||||||
|
"react-remove-scroll": "^2.6.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
|
||||||
@@ -1507,6 +1561,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.3",
|
||||||
|
"@radix-ui/react-id": "1.1.0",
|
||||||
|
"@radix-ui/react-popper": "1.2.1",
|
||||||
|
"@radix-ui/react-portal": "1.1.3",
|
||||||
|
"@radix-ui/react-presence": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.0.1",
|
||||||
|
"@radix-ui/react-slot": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
||||||
@@ -3436,6 +3523,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-mart": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -15,16 +15,20 @@
|
|||||||
"setup:dev": "if ! command -v bun > /dev/null; then echo 'Installing bun...'; curl -fsSL https://bun.sh/install | bash; fi && npm install --force && npm run typecheck && npm run lint"
|
"setup:dev": "if ! command -v bun > /dev/null; then echo 'Installing bun...'; curl -fsSL https://bun.sh/install | bash; fi && npm install --force && npm run typecheck && npm run lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@next/font": "^14.2.15",
|
"@next/font": "^14.2.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-progress": "^1.1.1",
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@uiw/react-heat-map": "^2.3.2",
|
"@uiw/react-heat-map": "^2.3.2",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user