added recurrence (#35)

This commit is contained in:
Doh
2025-01-10 22:54:41 -05:00
committed by GitHub
parent 889391fcfe
commit 6c5853adea
14 changed files with 780 additions and 357 deletions

View File

@@ -1,4 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
@@ -13,51 +16,40 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { Habit } from '@/lib/types'
import { parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
interface AddEditHabitModalProps {
isOpen: boolean
onClose: () => void
onSave: (habit: Omit<Habit, 'id'>) => void
onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
habit?: Habit | null
}
export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: AddEditHabitModalProps) {
export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) {
const [settings] = useAtom(settingsAtom)
const [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)
const [name, setName] = useState(habit?.name || '')
const [description, setDescription] = useState(habit?.description || '')
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const origRuleText = parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText()
const [ruleText, setRuleText] = useState<string>(origRuleText)
useEffect(() => {
if (habit) {
setName(habit.name)
setDescription(habit.description)
setFrequency(habit.frequency)
setCoinReward(habit.coinReward)
setTargetCompletions(habit.targetCompletions || 1)
} else {
setName('')
setDescription('')
setFrequency('daily')
setCoinReward(1)
}
}, [habit])
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
onSave({
await onSave({
name,
description,
frequency,
coinReward,
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || []
completions: habit?.completions || [],
frequency: habit ? (
origRuleText === ruleText ? habit.frequency : serializeRRule(parseNaturalLanguageRRule(ruleText))
) : serializeRRule(parseNaturalLanguageRRule(ruleText)),
})
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{habit ? 'Edit Habit' : 'Add New Habit'}</DialogTitle>
@@ -90,7 +82,11 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => `${prev}${emoji.native}`)
setName(prev => {
// Add space before emoji if there isn't one already
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji.native}`;
})
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
@@ -112,69 +108,109 @@ export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: Ad
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="frequency" className="text-right">
<Label htmlFor="recurrence" className="text-right">
Frequency
</Label>
<Select value={frequency} onValueChange={(value: 'daily' | 'weekly' | 'monthly') => setFrequency(value)}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
</SelectContent>
</Select>
<div className="col-span-3 space-y-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
/>
</div>
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
<span>
{(() => {
try {
return parseNaturalLanguageRRule(ruleText).toText()
} catch (e: any) {
return `Invalid rule: ${e.message}`
}
})()}
</span>
</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
Repetitions
</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"
/>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="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 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
times per day
times per occurrence
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="coinReward" className="text-right">
Coin Reward
</Label>
<Input
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
className="col-span-3"
min={1}
required
/>
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
Coin Reward
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
min={0}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinReward(prev => prev + 1)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
coins per completion
</span>
</div>
</div>
</div>
</div>
<DialogFooter>

View File

@@ -1,7 +1,7 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
import Link from 'next/link'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils'
@@ -26,6 +26,7 @@ export default function DailyOverview({
}: UpcomingItemsProps) {
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
const today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = getCompletedHabitsForDate({
habits,
@@ -33,12 +34,14 @@ export default function DailyOverview({
timezone: settings.system.timezone
})
// Filter daily habits
const dailyHabits = habits.filter(habit => habit.frequency === 'daily')
useEffect(() => {
// Filter habits that are due today based on their recurrence rule
const filteredHabits = habits.filter(habit => isHabitDueToday(habit, settings.system.timezone))
setDailyHabits(filteredHabits)
}, [habits])
// Get achievable wishlist items sorted by coin cost
const achievableWishlistItems = wishlistItems
.filter(item => item.coinCost > coinBalance)
// Get all wishlist items sorted by coin cost
const sortedWishlistItems = wishlistItems
.sort((a, b) => a.coinCost - b.coinCost)
const [expandedHabits, setExpandedHabits] = useState(false)
@@ -68,11 +71,32 @@ export default function DailyOverview({
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-[500px] opacity-100' : 'max-h-[200px] opacity-100'} overflow-hidden`}>
{dailyHabits
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
return aCompleted === bCompleted ? 0 : aCompleted ? 1 : -1;
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, expandedHabits ? undefined : 3)
.slice(0, expandedHabits ? undefined : 5)
.map((habit) => {
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 }))
@@ -128,85 +152,38 @@ export default function DailyOverview({
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
{habit.coinReward}
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
</div>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedHabits(!expandedHabits)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedHabits ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Wishlist Goals</h3>
<Badge variant="secondary">
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
</Badge>
</div>
{achievableWishlistItems.length > 0 && (
<div>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-[500px]' : 'max-h-[200px]'} overflow-hidden`}>
{achievableWishlistItems
.slice(0, expandedWishlist ? undefined : 1)
.map((item) => (
<div key={item.id} className="bg-secondary/20 p-3 rounded-md">
<div className="flex items-center justify-between mb-2">
<span className="text-sm">
<Linkify>
{item.name}
</Linkify>
</span>
<span className="text-xs flex items-center">
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
{item.coinCost}
</span>
</div>
<Progress
value={(coinBalance / item.coinCost) * 100}
className="h-2"
/>
<p className="text-xs text-muted-foreground mt-2">
{item.coinCost - coinBalance} coins to go
</p>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedWishlist(!expandedWishlist)}
onClick={() => setExpandedHabits(!expandedHabits)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedWishlist ? (
{expandedHabits ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
@@ -219,7 +196,7 @@ export default function DailyOverview({
)}
</button>
<Link
href="/wishlist"
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
@@ -227,9 +204,104 @@ export default function DailyOverview({
</Link>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Wishlist Goals</h3>
<Badge variant="secondary">
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
</Badge>
</div>
<div>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-[500px]' : 'max-h-[200px]'} overflow-hidden`}>
{sortedWishlistItems.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-4">
No wishlist items yet. Add some goals to work towards!
</div>
) : (
<>
{sortedWishlistItems
.slice(0, expandedWishlist ? undefined : 5)
.map((item) => {
const isRedeemable = item.coinCost <= coinBalance
return (
<Link
key={item.id}
href={`/wishlist?highlight=${item.id}`}
className={cn(
"block p-3 rounded-md hover:bg-secondary/30 transition-colors",
isRedeemable ? 'bg-green-500/10' : 'bg-secondary/20'
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
<span className="text-xs flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isRedeemable
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isRedeemable
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{item.coinCost}
</span>
</span>
</div>
<Progress
value={(coinBalance / item.coinCost) * 100}
className={cn(
"h-2",
isRedeemable ? "bg-green-500/20" : ""
)}
/>
<p className="text-xs text-muted-foreground mt-2">
{isRedeemable
? "Ready to redeem!"
: `${item.coinCost - coinBalance} coins to go`
}
</p>
</Link>
)
})}
</>
)}
</div>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedWishlist(!expandedWishlist)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedWishlist ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/wishlist"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -21,7 +21,6 @@ export default function Dashboard() {
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CoinBalance coinBalance={coinBalance} />
{/* <HabitOverview /> */}
<HabitStreak habits={habits} />
<DailyOverview
wishlistItems={wishlistItems}

View File

@@ -1,12 +1,14 @@
import { Habit } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { RRule } from 'rrule'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
interface HabitItemProps {
habit: Habit
@@ -54,7 +56,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<CardDescription>{habit.description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-500">Frequency: {habit.frequency}</p>
<p className="text-sm text-gray-500">Frequency: {parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText()}</p>
<div className="flex items-center mt-2">
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span>
@@ -87,7 +89,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
target > 1 ? `Complete (${completionsToday}/${target})` : 'Complete'
)}
{habit.targetCompletions && habit.targetCompletions > 1 && (
<div
<div
className="absolute bottom-0 left-0 h-1 bg-white/50"
style={{
width: `${(completionsToday / target) * 100}%`

View File

@@ -56,19 +56,20 @@ export default function HabitList() {
))
)}
</div>
<AddEditHabitModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingHabit(null)
}}
onSave={async (habit) => {
await saveHabit({ ...habit, id: editingHabit?.id })
setIsModalOpen(false)
setEditingHabit(null)
}}
habit={editingHabit}
/>
{isModalOpen &&
<AddEditHabitModal
onClose={() => {
setIsModalOpen(false)
setEditingHabit(null)
}}
onSave={async (habit) => {
await saveHabit({ ...habit, id: editingHabit?.id })
setIsModalOpen(false)
setEditingHabit(null)
}}
habit={editingHabit}
/>
}
<ConfirmDialog
isOpen={deleteConfirmation.isOpen}
onClose={() => setDeleteConfirmation({ isOpen: false, habitId: null })}

View File

@@ -1,60 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart } from 'lucide-react'
import { getTodayInTimezone, getCompletedHabitsForDate } from '@/lib/utils'
import { useAtom } from 'jotai'
import { habitsAtom, settingsAtom } from '@/lib/atoms'
export default function HabitOverview() {
const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
const today = getTodayInTimezone(settings.system.timezone)
const completedToday = getCompletedHabitsForDate({
habits,
date: today,
timezone: settings.system.timezone
}).length
const habitsByFrequency = habits.reduce((acc, habit) => ({
...acc,
[habit.frequency]: (acc[habit.frequency] || 0) + 1
}), {} as Record<string, number>)
return (
<Card>
<CardHeader>
<CardTitle>Habit Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Today's Progress */}
<div className="bg-secondary/20 p-4 rounded-lg">
<h3 className="font-semibold mb-2">Today&apos;s Progress</h3>
<div className="flex items-center justify-between">
<span>{completedToday}/{habits.length} completed</span>
<BarChart className="h-5 w-5" />
</div>
</div>
{/* Frequency Breakdown */}
<div>
<h3 className="font-semibold mb-2">Habit Frequency</h3>
<div className="space-y-2">
{Object.entries(habitsByFrequency).map(([frequency, count]) => (
<div key={frequency} className="flex items-center justify-between text-sm">
<span className="capitalize">{frequency}</span>
<span className="bg-secondary/30 px-2 py-1 rounded">
{count} habits
</span>
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)
}