mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
support archiving habit and wishlist + wishlist redeem count (#49)
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user