support archiving habit and wishlist + wishlist redeem count (#49)

This commit is contained in:
Doh
2025-01-24 20:41:26 -05:00
committed by GitHub
parent d3502e284d
commit 6fe10d9fa5
13 changed files with 374 additions and 83 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # Changelog
## Version 0.1.26
### Added
- archiving habits and wishlists (#44)
- wishlist item now supports redeem count (#36)
### Fixed
- pomodoro skip should update label
## Version 0.1.25 ## Version 0.1.25
### Added ### Added

View File

@@ -9,8 +9,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Info, SmilePlus } from 'lucide-react' import { Info, SmilePlus } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data' import data from '@emoji-mart/data'

View File

@@ -5,7 +5,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SmilePlus } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { SmilePlus, Info } from 'lucide-react'
import data from '@emoji-mart/data' import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react' import Picker from '@emoji-mart/react'
import { WishlistItemType } from '@/lib/types' import { WishlistItemType } from '@/lib/types'
@@ -18,25 +19,51 @@ interface AddEditWishlistItemModalProps {
} }
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) { export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) {
const [name, setName] = useState('') const [name, setName] = useState(item?.name || '')
const [description, setDescription] = useState('') const [description, setDescription] = useState(item?.description || '')
const [coinCost, setCoinCost] = useState(1) const [coinCost, setCoinCost] = useState(item?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(item?.targetCompletions)
const [errors, setErrors] = useState<{ [key: string]: string }>({})
useEffect(() => { useEffect(() => {
if (item) { if (item) {
setName(item.name) setName(item.name)
setDescription(item.description) setDescription(item.description)
setCoinCost(item.coinCost) setCoinCost(item.coinCost)
setTargetCompletions(item.targetCompletions)
} else { } else {
setName('') setName('')
setDescription('') setDescription('')
setCoinCost(1) setCoinCost(1)
setTargetCompletions(undefined)
} }
setErrors({})
}, [item]) }, [item])
const validate = () => {
const newErrors: { [key: string]: string } = {}
if (!name.trim()) {
newErrors.name = 'Name is required'
}
if (coinCost < 1) {
newErrors.coinCost = 'Coin cost must be at least 1'
}
if (targetCompletions !== undefined && targetCompletions < 1) {
newErrors.targetCompletions = 'Target completions must be at least 1'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
onSave({ name, description, coinCost }) if (!validate()) return
onSave({
name,
description,
coinCost,
targetCompletions: targetCompletions || undefined
})
} }
return ( return (
@@ -96,18 +123,90 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
/> />
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="coinCost" className="text-right"> <div className="flex items-center gap-2 justify-end">
Coin Cost <Label htmlFor="coinReward">
Cost
</Label> </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={() => setCoinCost(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 <Input
id="coinCost" id="coinReward"
type="number" type="number"
value={coinCost} value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))} onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
className="col-span-3" min={0}
min={1}
required 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={() => setCoinCost(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
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
Redeemable
</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={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
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 = e.target.value
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
}}
min={0}
placeholder="∞"
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 || 0) + 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
</span>
</div>
{errors.targetCompletions && (
<div className="text-sm text-red-500">
{errors.targetCompletions}
</div>
)}
</div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@@ -4,7 +4,7 @@ import { settingsAtom, pomodoroAtom, browserSettingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils' import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer } from 'lucide-react' import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore } from 'lucide-react'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -24,7 +24,7 @@ interface HabitItemProps {
} }
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete } = useHabits() const { completeHabit, undoComplete, archiveHabit, unarchiveHabit } = useHabits()
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [_, setPomo] = useAtom(pomodoroAtom) const [_, setPomo] = useAtom(pomodoroAtom)
const completionsToday = habit.completions?.filter(completion => const completionsToday = habit.completions?.filter(completion =>
@@ -59,21 +59,21 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
return ( return (
<Card <Card
id={`habit-${habit.id}`} id={`habit-${habit.id}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''}`} className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
> >
<CardHeader className="flex-none"> <CardHeader className="flex-none">
<CardTitle className="line-clamp-1">{habit.name}</CardTitle> <CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.name}</CardTitle>
{habit.description && ( {habit.description && (
<CardDescription className="whitespace-pre-line"> <CardDescription className={`whitespace-pre-line ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description} {habit.description}
</CardDescription> </CardDescription>
)} )}
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="flex-1">
<p className="text-sm text-gray-500">When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</p> <p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</p>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<Coins className="h-4 w-4 text-yellow-400 mr-1" /> <Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span> <span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="flex justify-between gap-2"> <CardFooter className="flex justify-between gap-2">
@@ -83,8 +83,8 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant={isCompletedToday ? "secondary" : "default"} variant={isCompletedToday ? "secondary" : "default"}
size="sm" size="sm"
onClick={async () => await completeHabit(habit)} onClick={async () => await completeHabit(habit)}
disabled={isCompletedToday && completionsToday >= target} disabled={habit.archived || (isCompletedToday && completionsToday >= target)}
className="overflow-hidden w-24 sm:w-auto" className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
> >
<Check className="h-4 w-4 sm:mr-2" /> <Check className="h-4 w-4 sm:mr-2" />
<span> <span>
@@ -116,7 +116,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
)} )}
</Button> </Button>
</div> </div>
{completionsToday > 0 && ( {completionsToday > 0 && !habit.archived && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -129,6 +129,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{!habit.archived && (
<Button <Button
variant="edit" variant="edit"
size="sm" size="sm"
@@ -138,6 +139,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
<span className="ml-2">Edit</span> <span className="ml-2">Edit</span>
</Button> </Button>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
@@ -145,6 +147,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{!habit.archived && (
<DropdownMenuItem onClick={() => { <DropdownMenuItem onClick={() => {
setPomo((prev) => ({ setPomo((prev) => ({
...prev, ...prev,
@@ -155,7 +158,24 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<Timer className="mr-2 h-4 w-4" /> <Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span> <span>Start Pomodoro</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={onEdit} className="sm:hidden"> )}
{!habit.archived && (
<DropdownMenuItem onClick={() => archiveHabit(habit.id)}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
)}
{habit.archived && (
<DropdownMenuItem onClick={() => unarchiveHabit(habit.id)}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onEdit}
className="sm:hidden"
disabled={habit.archived}
>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -21,6 +21,8 @@ export default function HabitList() {
const habits = habitsData.habits.filter(habit => const habits = habitsData.habits.filter(habit =>
isTasksView ? habit.isTask : !habit.isTask isTasksView ? habit.isTask : !habit.isTask
) )
const activeHabits = habits.filter(h => !h.archived)
const archivedHabits = habits.filter(h => h.archived)
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [editingHabit, setEditingHabit] = useState<Habit | null>(null) const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
@@ -41,7 +43,7 @@ export default function HabitList() {
</Button> </Button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{habits.length === 0 ? ( {activeHabits.length === 0 ? (
<div className="col-span-2"> <div className="col-span-2">
<EmptyState <EmptyState
icon={isTasksView ? TaskIcon : HabitIcon} icon={isTasksView ? TaskIcon : HabitIcon}
@@ -50,7 +52,7 @@ export default function HabitList() {
/> />
</div> </div>
) : ( ) : (
habits.map((habit) => ( activeHabits.map((habit: Habit) => (
<HabitItem <HabitItem
key={habit.id} key={habit.id}
habit={habit} habit={habit}
@@ -62,6 +64,27 @@ export default function HabitList() {
/> />
)) ))
)} )}
{archivedHabits.length > 0 && (
<>
<div className="col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setIsModalOpen(true)
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))}
</>
)}
</div> </div>
{isModalOpen && {isModalOpen &&
<AddEditHabitModal <AddEditHabitModal

View File

@@ -184,6 +184,16 @@ export default function PomodoroTimer() {
setTimeLeft(currentTimer.current.duration) setTimeLeft(currentTimer.current.duration)
} }
const skipTimer = () => {
currentTimer.current = currentTimer.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer()
setCurrentLabel(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
}
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60)
const secs = seconds % 60 const secs = seconds % 60
@@ -314,12 +324,7 @@ export default function PomodoroTimer() {
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={skipTimer}
currentTimer.current = currentTimer.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer()
}}
disabled={state === "started"} disabled={state === "started"}
className="sm:px-4" className="sm:px-4"
> >

View File

@@ -2,7 +2,7 @@ import { WishlistItemType } from '@/lib/types'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Gift, MoreVertical } from 'lucide-react' import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -16,9 +16,12 @@ interface WishlistItemProps {
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onRedeem: () => void onRedeem: () => void
onArchive: () => void
onUnarchive: () => void
canRedeem: boolean canRedeem: boolean
isHighlighted?: boolean isHighlighted?: boolean
isRecentlyRedeemed?: boolean isRecentlyRedeemed?: boolean
isArchived?: boolean
} }
export default function WishlistItem({ export default function WishlistItem({
@@ -26,6 +29,8 @@ export default function WishlistItem({
onEdit, onEdit,
onDelete, onDelete,
onRedeem, onRedeem,
onArchive,
onUnarchive,
canRedeem, canRedeem,
isHighlighted, isHighlighted,
isRecentlyRedeemed isRecentlyRedeemed
@@ -35,20 +40,31 @@ export default function WishlistItem({
id={`wishlist-${item.id}`} id={`wishlist-${item.id}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : '' className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : '' } ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
}`} } ${item.archived ? 'opacity-75' : ''}`}
> >
<CardHeader className="flex-none"> <CardHeader className="flex-none">
<CardTitle className="line-clamp-1">{item.name}</CardTitle> <div className="flex items-center gap-2">
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.name}
</CardTitle>
{item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400">
({item.targetCompletions} {item.targetCompletions === 1 ? 'use' : 'uses'} left)
</span>
)}
</div>
{item.description && ( {item.description && (
<CardDescription className="whitespace-pre-line"> <CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description} {item.description}
</CardDescription> </CardDescription>
)} )}
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="flex-1">
<div className="flex items-center"> <div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-yellow-400 mr-1" /> <Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className="text-sm font-medium">{item.coinCost} coins</span> <span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.coinCost} coins
</span>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="flex justify-between gap-2"> <CardFooter className="flex justify-between gap-2">
@@ -57,8 +73,8 @@ export default function WishlistItem({
variant={canRedeem ? "default" : "secondary"} variant={canRedeem ? "default" : "secondary"}
size="sm" size="sm"
onClick={onRedeem} onClick={onRedeem}
disabled={!canRedeem} disabled={!canRedeem || item.archived}
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''}`} className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''} ${item.archived ? 'cursor-not-allowed' : ''}`}
> >
<Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} /> <Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} />
<span> <span>
@@ -77,6 +93,7 @@ export default function WishlistItem({
</Button> </Button>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{!item.archived && (
<Button <Button
variant="edit" variant="edit"
size="sm" size="sm"
@@ -86,6 +103,7 @@ export default function WishlistItem({
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
<span className="ml-2">Edit</span> <span className="ml-2">Edit</span>
</Button> </Button>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
@@ -93,6 +111,18 @@ export default function WishlistItem({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{!item.archived && (
<DropdownMenuItem onClick={onArchive}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
)}
{item.archived && (
<DropdownMenuItem onClick={onUnarchive}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onEdit} className="sm:hidden"> <DropdownMenuItem onClick={onEdit} className="sm:hidden">
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit

View File

@@ -16,10 +16,15 @@ export default function WishlistManager() {
editWishlistItem, editWishlistItem,
deleteWishlistItem, deleteWishlistItem,
redeemWishlistItem, redeemWishlistItem,
archiveWishlistItem,
unarchiveWishlistItem,
canRedeem, canRedeem,
wishlistItems wishlistItems
} = useWishlist() } = useWishlist()
const activeItems = wishlistItems.filter(item => !item.archived)
const archivedItems = wishlistItems.filter(item => item.archived)
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null) const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null)
const [recentlyRedeemedId, setRecentlyRedeemedId] = useState<string | null>(null) const [recentlyRedeemedId, setRecentlyRedeemedId] = useState<string | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
@@ -69,7 +74,7 @@ export default function WishlistManager() {
</Button> </Button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{wishlistItems.length === 0 ? ( {activeItems.length === 0 ? (
<div className="col-span-2"> <div className="col-span-2">
<EmptyState <EmptyState
icon={Gift} icon={Gift}
@@ -78,7 +83,7 @@ export default function WishlistManager() {
/> />
</div> </div>
) : ( ) : (
wishlistItems.map((item) => ( activeItems.map((item) => (
<div <div
key={item.id} key={item.id}
ref={(el) => { ref={(el) => {
@@ -97,11 +102,38 @@ export default function WishlistManager() {
}} }}
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })} onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
onRedeem={() => handleRedeem(item)} onRedeem={() => handleRedeem(item)}
onArchive={() => archiveWishlistItem(item.id)}
onUnarchive={() => unarchiveWishlistItem(item.id)}
canRedeem={canRedeem(item.coinCost)} canRedeem={canRedeem(item.coinCost)}
/> />
</div> </div>
)) ))
)} )}
{archivedItems.length > 0 && (
<>
<div className="col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedItems.map((item) => (
<WishlistItem
key={item.id}
item={item}
onEdit={() => {
setEditingItem(item)
setIsModalOpen(true)
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
onRedeem={() => handleRedeem(item)}
onArchive={() => archiveWishlistItem(item.id)}
onUnarchive={() => unarchiveWishlistItem(item.id)}
canRedeem={canRedeem(item.coinCost)}
/>
))}
</>
)}
</div> </div>
<AddEditWishlistItemModal <AddEditWishlistItemModal
isOpen={isModalOpen} isOpen={isModalOpen}

View File

@@ -231,11 +231,29 @@ export function useHabits() {
} }
} }
const archiveHabit = async (id: string) => {
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: true } : h
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
}
const unarchiveHabit = async (id: string) => {
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: undefined } : h
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
}
return { return {
completeHabit, completeHabit,
undoComplete, undoComplete,
saveHabit, saveHabit,
deleteHabit, deleteHabit,
completePastHabit completePastHabit,
archiveHabit,
unarchiveHabit
} }
} }

View File

@@ -33,6 +33,16 @@ export function useWishlist() {
const redeemWishlistItem = async (item: WishlistItemType) => { const redeemWishlistItem = async (item: WishlistItemType) => {
if (balance >= item.coinCost) { if (balance >= item.coinCost) {
// Check if item has target completions and if we've reached the limit
if (item.targetCompletions && item.targetCompletions <= 0) {
toast({
title: "Redemption limit reached",
description: `You've reached the maximum redemptions for "${item.name}".`,
variant: "destructive",
})
return false
}
const data = await removeCoins({ const data = await removeCoins({
amount: item.coinCost, amount: item.coinCost,
description: `Redeemed reward: ${item.name}`, description: `Redeemed reward: ${item.name}`,
@@ -41,6 +51,30 @@ export function useWishlist() {
}) })
setCoins(data) setCoins(data)
// Update target completions if set
if (item.targetCompletions !== undefined) {
const newItems = wishlist.items.map(wishlistItem => {
if (wishlistItem.id === item.id) {
const newTarget = wishlistItem.targetCompletions! - 1
// If target reaches 0, archive the item
if (newTarget <= 0) {
return {
...wishlistItem,
targetCompletions: undefined,
archived: true
}
}
return {
...wishlistItem,
targetCompletions: newTarget
}
}
return wishlistItem
})
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
}
// Randomly choose a celebration effect // Randomly choose a celebration effect
const celebrationEffects = [ const celebrationEffects = [
celebrations.emojiParty celebrations.emojiParty
@@ -66,11 +100,29 @@ export function useWishlist() {
const canRedeem = (cost: number) => balance >= cost const canRedeem = (cost: number) => balance >= cost
const archiveWishlistItem = async (id: string) => {
const newItems = wishlist.items.map(item =>
item.id === id ? { ...item, archived: true } : item
)
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
}
const unarchiveWishlistItem = async (id: string) => {
const newItems = wishlist.items.map(item =>
item.id === id ? { ...item, archived: undefined } : item
)
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
}
return { return {
addWishlistItem, addWishlistItem,
editWishlistItem, editWishlistItem,
deleteWishlistItem, deleteWishlistItem,
redeemWishlistItem, redeemWishlistItem,
archiveWishlistItem,
unarchiveWishlistItem,
canRedeem, canRedeem,
wishlistItems: wishlist.items wishlistItems: wishlist.items
} }

View File

@@ -7,6 +7,7 @@ export type Habit = {
targetCompletions?: number // Optional field, default to 1 targetCompletions?: number // Optional field, default to 1
completions: string[] // Array of UTC ISO date strings completions: string[] // Array of UTC ISO date strings
isTask?: boolean // mark the habit as a task isTask?: boolean // mark the habit as a task
archived?: boolean // mark the habit as archived
} }
@@ -17,6 +18,8 @@ export type WishlistItemType = {
name: string name: string
description: string description: string
coinCost: number coinCost: number
archived?: boolean // mark the wishlist item as archived
targetCompletions?: number // Optional field, infinity when unset
} }
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO'; export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.1.24", "version": "0.1.26",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "habittrove", "name": "habittrove",
"version": "0.1.24", "version": "0.1.26",
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.1.25", "version": "0.1.26",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",