added habit daily completion target (#26)

This commit is contained in:
Doh
2025-01-05 16:56:04 -05:00
committed by GitHub
parent 86a517a859
commit aaa7e384bc
19 changed files with 577 additions and 132 deletions

View File

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

View File

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

View File

View 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>
)

View File

@@ -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} /> */}

View File

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

View File

@@ -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
}, {})

View File

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

View File

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

View File

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