Skip to content

Commit

Permalink
added habit daily completion target (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
dohsimpson authored Jan 5, 2025
1 parent 86a517a commit aaa7e38
Show file tree
Hide file tree
Showing 19 changed files with 574 additions and 129 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 2 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
95 changes: 86 additions & 9 deletions components/AddEditHabitModal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -15,17 +22,20 @@ 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) {
setName(habit.name)
setDescription(habit.description)
setFrequency(habit.frequency)
setCoinReward(habit.coinReward)
setTargetCompletions(habit.targetCompletions || 1)
} else {
setName('')
setDescription('')
Expand All @@ -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 (
Expand All @@ -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">
Expand Down Expand Up @@ -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
Expand Down
43 changes: 36 additions & 7 deletions components/AddEditWishlistItemModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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">
Expand Down
Empty file added components/CoinBalance.test.tsx
Empty file.
64 changes: 47 additions & 17 deletions components/DailyOverview.tsx
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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`}>
Expand All @@ -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}
Expand All @@ -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' : ''}>
Expand All @@ -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>
)
Expand Down
3 changes: 0 additions & 3 deletions components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -28,8 +27,6 @@ export default function Dashboard() {
wishlistItems={wishlistItems}
habits={habits}
coinBalance={coinBalance}
onComplete={completeHabit}
onUndo={undoComplete}
/>

{/* <HabitHeatmap habits={habits} /> */}
Expand Down
Loading

0 comments on commit aaa7e38

Please sign in to comment.