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
|
||||
|
||||
## 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
|
||||
|
||||
### 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:
|
||||
|
||||
- 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! We appreciate both:
|
||||
|
||||
- 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
|
||||
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.
|
||||
|
||||
## 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 { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
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'
|
||||
|
||||
interface AddEditHabitModalProps {
|
||||
@@ -15,10 +22,12 @@ interface AddEditHabitModalProps {
|
||||
}
|
||||
|
||||
export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: AddEditHabitModalProps) {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'monthly'>('daily')
|
||||
const [coinReward, setCoinReward] = useState(1)
|
||||
const [targetCompletions, setTargetCompletions] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
if (habit) {
|
||||
@@ -26,6 +35,7 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
||||
setDescription(habit.description)
|
||||
setFrequency(habit.frequency)
|
||||
setCoinReward(habit.coinReward)
|
||||
setTargetCompletions(habit.targetCompletions || 1)
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
@@ -36,7 +46,14 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave({ name, description, frequency, coinReward, completions: habit?.completions || [] })
|
||||
onSave({
|
||||
name,
|
||||
description,
|
||||
frequency,
|
||||
coinReward,
|
||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||
completions: habit?.completions || []
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -51,13 +68,37 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
<div className='flex col-span-3 gap-2'>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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 className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
@@ -85,6 +126,42 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<Label htmlFor="coinReward" className="text-right">
|
||||
Coin Reward
|
||||
|
||||
@@ -4,6 +4,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
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'
|
||||
|
||||
interface AddEditWishlistItemModalProps {
|
||||
@@ -47,13 +51,38 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1"
|
||||
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 className="grid grid-cols-4 items-center gap-4">
|
||||
<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 { cn } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
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 { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { Habit } from '@/lib/types'
|
||||
import Linkify from './linkify'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
|
||||
interface UpcomingItemsProps {
|
||||
habits: Habit[]
|
||||
wishlistItems: WishlistItemType[]
|
||||
coinBalance: number
|
||||
onComplete: (habit: Habit) => void
|
||||
onUndo: (habit: Habit) => void
|
||||
}
|
||||
|
||||
export default function DailyOverview({
|
||||
habits,
|
||||
wishlistItems,
|
||||
coinBalance,
|
||||
onComplete,
|
||||
onUndo
|
||||
}: UpcomingItemsProps) {
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const today = getTodayInTimezone(settings.system.timezone)
|
||||
const todayCompletions = habits.filter(habit =>
|
||||
habit.completions.includes(today)
|
||||
)
|
||||
const todayCompletions = getCompletedHabitsForDate({
|
||||
habits,
|
||||
date: getNow({ timezone: settings.system.timezone }),
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
|
||||
// Filter daily habits
|
||||
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">
|
||||
<h3 className="font-semibold">Daily Habits</h3>
|
||||
<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>
|
||||
</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`}>
|
||||
@@ -66,7 +72,11 @@ export default function DailyOverview({
|
||||
})
|
||||
.slice(0, expandedHabits ? undefined : 3)
|
||||
.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 (
|
||||
<li
|
||||
key={habit.id}
|
||||
@@ -78,17 +88,30 @@ export default function DailyOverview({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (isCompleted) {
|
||||
onUndo(habit);
|
||||
undoComplete(habit);
|
||||
} else {
|
||||
onComplete(habit);
|
||||
completeHabit(habit);
|
||||
}
|
||||
}}
|
||||
className="hover:opacity-70 transition-opacity"
|
||||
className="relative hover:opacity-70 transition-opacity w-4 h-4"
|
||||
>
|
||||
{isCompleted ? (
|
||||
<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>
|
||||
<span className={isCompleted ? 'line-through' : ''}>
|
||||
@@ -97,9 +120,16 @@ export default function DailyOverview({
|
||||
</Linkify>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center text-xs text-muted-foreground">
|
||||
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
|
||||
{habit.coinReward}
|
||||
<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" />
|
||||
{habit.coinReward}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ import HabitStreak from './HabitStreak'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
const habits = habitsData.habits
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
@@ -28,8 +27,6 @@ export default function Dashboard() {
|
||||
wishlistItems={wishlistItems}
|
||||
habits={habits}
|
||||
coinBalance={coinBalance}
|
||||
onComplete={completeHabit}
|
||||
onUndo={undoComplete}
|
||||
/>
|
||||
|
||||
{/* <HabitHeatmap habits={habits} /> */}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useState } from 'react'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { DateTime } from 'luxon'
|
||||
import Linkify from './linkify'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
export default function HabitCalendar() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
@@ -17,10 +18,11 @@ export default function HabitCalendar() {
|
||||
const habits = habitsData.habits
|
||||
|
||||
const getHabitsForDate = (date: Date) => {
|
||||
const dateString = date.toISOString().split('T')[0]
|
||||
return habits.filter(habit =>
|
||||
habit.completions.includes(dateString)
|
||||
)
|
||||
return getCompletedHabitsForDate({
|
||||
habits,
|
||||
date: DateTime.fromJSDate(date),
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -50,7 +52,7 @@ export default function HabitCalendar() {
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{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'
|
||||
)}
|
||||
@@ -60,7 +62,7 @@ export default function HabitCalendar() {
|
||||
{selectedDate && (
|
||||
<ul className="space-y-2">
|
||||
{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 (
|
||||
<li key={habit.id} className="flex items-center justify-between">
|
||||
<span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import HeatMap from '@uiw/react-heat-map'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { getNow } from '@/lib/utils'
|
||||
import { getNow, d2s, t2d } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
|
||||
@@ -13,10 +13,21 @@ interface HabitHeatmapProps {
|
||||
export default function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||
// Aggregate all habit completions into a count per day
|
||||
const completionCounts = habits.reduce((acc: { [key: string]: number }, habit) => {
|
||||
habit.completions.forEach(date => {
|
||||
// Convert date format from ISO (YYYY-MM-DD) to YYYY/MM/DD for the heatmap
|
||||
const formattedDate = date.replace(/-/g, '/')
|
||||
acc[formattedDate] = (acc[formattedDate] || 0) + 1
|
||||
const target = habit.targetCompletions || 1
|
||||
const dailyCompletions = habit.completions.reduce((dailyAcc, completion) => {
|
||||
const formattedDate = d2s({
|
||||
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
|
||||
}, {})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Habit } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
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 { Button } from '@/components/ui/button'
|
||||
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 [settings] = useAtom(settingsAtom)
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,16 +72,31 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={isCompletedToday ? "secondary" : "default"}
|
||||
size="sm"
|
||||
onClick={async () => await completeHabit(habit)}
|
||||
disabled={isCompletedToday}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
{isCompletedToday ? "Completed" : "Complete"}
|
||||
</Button>
|
||||
{isCompletedToday && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant={isCompletedToday ? "secondary" : "default"}
|
||||
size="sm"
|
||||
onClick={async () => await completeHabit(habit)}
|
||||
disabled={isCompletedToday && completionsToday >= target}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
{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>
|
||||
</div>
|
||||
{completionsToday > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { BarChart } from 'lucide-react'
|
||||
import { getTodayInTimezone } from '@/lib/utils'
|
||||
import { getTodayInTimezone, getCompletedHabitsForDate } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
|
||||
@@ -11,9 +11,11 @@ export default function HabitOverview() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const today = getTodayInTimezone(settings.system.timezone)
|
||||
|
||||
const completedToday = habits.filter(habit =>
|
||||
habit.completions.includes(today)
|
||||
).length
|
||||
const completedToday = getCompletedHabitsForDate({
|
||||
habits,
|
||||
date: today,
|
||||
timezone: settings.system.timezone
|
||||
}).length
|
||||
|
||||
const habitsByFrequency = habits.reduce((acc, habit) => ({
|
||||
...acc,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Habit } from '@/lib/types'
|
||||
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 { useAtom } from 'jotai'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
@@ -20,9 +20,11 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||
}).reverse()
|
||||
|
||||
const completions = dates.map(date => {
|
||||
const completedCount = habits.reduce((count, habit) => {
|
||||
return count + (habit.completions.includes(date) ? 1 : 0);
|
||||
}, 0);
|
||||
const completedCount = getCompletedHabitsForDate({
|
||||
habits,
|
||||
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
|
||||
timezone: settings.system.timezone
|
||||
}).length;
|
||||
return {
|
||||
date,
|
||||
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 { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||
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 { ToastAction } from '@/components/ui/toast'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
@@ -15,71 +15,118 @@ export function useHabits() {
|
||||
const completeHabit = async (habit: Habit) => {
|
||||
const timezone = settings.system.timezone
|
||||
const today = getTodayInTimezone(timezone)
|
||||
if (!habit.completions.includes(today)) {
|
||||
const updatedHabit = {
|
||||
...habit,
|
||||
completions: [...habit.completions, today]
|
||||
}
|
||||
const updatedHabits = habitsData.habits.map(h =>
|
||||
h.id === habit.id ? updatedHabit : h
|
||||
)
|
||||
await saveHabitsData({ habits: updatedHabits })
|
||||
setHabitsData({ habits: updatedHabits })
|
||||
|
||||
const coinsData = await addCoins(habit.coinReward, `Completed habit: ${habit.name}`, 'HABIT_COMPLETION', habit.id)
|
||||
setCoins(coinsData)
|
||||
// Get current completions for today
|
||||
const completionsToday = getCompletionsForDate({
|
||||
habit,
|
||||
date: today,
|
||||
timezone
|
||||
})
|
||||
const target = habit.targetCompletions || 1
|
||||
|
||||
toast({
|
||||
title: "Habit completed!",
|
||||
description: `You earned ${habit.coinReward} coins.`,
|
||||
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(habit)}>
|
||||
<Undo2 className="h-4 w-4" />Undo
|
||||
</ToastAction>
|
||||
})
|
||||
|
||||
return {
|
||||
updatedHabits,
|
||||
newBalance: coinsData.balance,
|
||||
newTransactions: coinsData.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 timezone = settings.system.timezone
|
||||
const today = getTodayInTimezone(timezone)
|
||||
// Add new completion
|
||||
const updatedHabit = {
|
||||
...habit,
|
||||
completions: habit.completions.filter(date => date !== today)
|
||||
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })]
|
||||
}
|
||||
|
||||
const updatedHabits = habitsData.habits.map(h =>
|
||||
h.id === habit.id ? updatedHabit : h
|
||||
)
|
||||
|
||||
await saveHabitsData({ habits: updatedHabits })
|
||||
setHabitsData({ habits: updatedHabits })
|
||||
|
||||
const coinsData = await removeCoins(habit.coinReward, `Undid habit completion: ${habit.name}`, 'HABIT_UNDO', habit.id)
|
||||
setCoins(coinsData)
|
||||
// Check if we've now reached the target
|
||||
const isTargetReached = completionsToday + 1 === target
|
||||
if (isTargetReached) {
|
||||
const updatedCoins = await addCoins(
|
||||
habit.coinReward,
|
||||
`Completed habit: ${habit.name}`,
|
||||
'HABIT_COMPLETION',
|
||||
habit.id
|
||||
)
|
||||
setCoins(updatedCoins)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Completion undone",
|
||||
description: `${habit.coinReward} coins have been deducted.`,
|
||||
action: <ToastAction altText="Redo" onClick={() => completeHabit(habit)}>
|
||||
title: isTargetReached ? "Habit completed!" : "Progress!",
|
||||
description: isTargetReached
|
||||
? `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
|
||||
</ToastAction>
|
||||
})
|
||||
|
||||
return {
|
||||
updatedHabits,
|
||||
newBalance: coinsData.balance,
|
||||
newTransactions: coinsData.transactions
|
||||
newBalance: coins.balance,
|
||||
newTransactions: coins.transactions
|
||||
}
|
||||
}
|
||||
|
||||
const undoComplete = async (habit: Habit) => {
|
||||
const timezone = settings.system.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 = {
|
||||
...habit,
|
||||
completions: habit.completions.filter(
|
||||
(_, index) => index !== habit.completions.length - 1
|
||||
)
|
||||
}
|
||||
|
||||
const updatedHabits = habitsData.habits.map(h =>
|
||||
h.id === habit.id ? updatedHabit : h
|
||||
)
|
||||
|
||||
await saveHabitsData({ habits: updatedHabits })
|
||||
setHabitsData({ habits: updatedHabits })
|
||||
|
||||
// If we were at the target, remove the coins
|
||||
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({
|
||||
title: "Completion undone",
|
||||
description: `You have ${getCompletionsForDate({
|
||||
habit: updatedHabit,
|
||||
date: today,
|
||||
timezone
|
||||
})}/${target} completions today.`,
|
||||
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}>
|
||||
<Undo2 className="h-4 w-4" />Redo
|
||||
</ToastAction>
|
||||
})
|
||||
|
||||
return {
|
||||
updatedHabits,
|
||||
newBalance: coins.balance,
|
||||
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
|
||||
frequency: 'daily' | 'weekly' | 'monthly'
|
||||
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 = {
|
||||
|
||||
66
lib/utils.ts
66
lib/utils.ts
@@ -1,6 +1,7 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DateTime } from "luxon"
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -55,3 +56,68 @@ export function d2n({ dateTime }: { dateTime: DateTime }) {
|
||||
export function isSameDate(a: DateTime, b: DateTime) {
|
||||
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",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "habittrove",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@next/font": "^14.2.15",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@uiw/react-heat-map": "^2.3.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
@@ -89,6 +93,20 @@
|
||||
"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": {
|
||||
"version": "4.4.1",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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,
|
||||
"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": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@next/font": "^14.2.15",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@uiw/react-heat-map": "^2.3.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
|
||||
Reference in New Issue
Block a user