redeem link + completing task + play sound

This commit is contained in:
dohsimpson
2025-01-27 18:24:53 -05:00
parent c66e28162c
commit b62cf77ba8
9 changed files with 152 additions and 69 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## Version 0.1.28
### Added
- redeem link for wishlist items (#52)
- sound effect for habit / task completion (#53)
### Fixed
- fail habit create or edit if frequency is not set (#54)
- archive task when completed (#50)
## Version 0.1.27 ## Version 0.1.27
### Added ### Added

View File

@@ -61,7 +61,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
Name Name *
</Label> </Label>
<div className='flex col-span-3 gap-2'> <div className='flex col-span-3 gap-2'>
<Input <Input
@@ -112,13 +112,14 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right"> <Label htmlFor="recurrence" className="text-right">
When When *
</Label> </Label>
<div className="col-span-3 space-y-2"> <div className="col-span-3 space-y-2">
<Input <Input
id="recurrence" id="recurrence"
value={ruleText} value={ruleText}
onChange={(e) => setRuleText(e.target.value)} onChange={(e) => setRuleText(e.target.value)}
required
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'" // placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
/> />
</div> </div>

View File

@@ -13,32 +13,44 @@ import { WishlistItemType } from '@/lib/types'
interface AddEditWishlistItemModalProps { interface AddEditWishlistItemModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void setIsOpen: (isOpen: boolean) => void
onSave: (item: Omit<WishlistItemType, 'id'>) => void editingItem: WishlistItemType | null
item?: WishlistItemType | null setEditingItem: (item: WishlistItemType | null) => void
addWishlistItem: (item: Omit<WishlistItemType, 'id'>) => void
editWishlistItem: (item: WishlistItemType) => void
} }
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) { export default function AddEditWishlistItemModal({
const [name, setName] = useState(item?.name || '') isOpen,
const [description, setDescription] = useState(item?.description || '') setIsOpen,
const [coinCost, setCoinCost] = useState(item?.coinCost || 1) editingItem,
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(item?.targetCompletions) setEditingItem,
addWishlistItem,
editWishlistItem
}: AddEditWishlistItemModalProps) {
const [name, setName] = useState(editingItem?.name || '')
const [description, setDescription] = useState(editingItem?.description || '')
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '')
const [errors, setErrors] = useState<{ [key: string]: string }>({}) const [errors, setErrors] = useState<{ [key: string]: string }>({})
useEffect(() => { useEffect(() => {
if (item) { if (editingItem) {
setName(item.name) setName(editingItem.name)
setDescription(item.description) setDescription(editingItem.description)
setCoinCost(item.coinCost) setCoinCost(editingItem.coinCost)
setTargetCompletions(item.targetCompletions) setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '')
} else { } else {
setName('') setName('')
setDescription('') setDescription('')
setCoinCost(1) setCoinCost(1)
setTargetCompletions(undefined) setTargetCompletions(undefined)
setLink('')
} }
setErrors({}) setErrors({})
}, [item]) }, [editingItem])
const validate = () => { const validate = () => {
const newErrors: { [key: string]: string } = {} const newErrors: { [key: string]: string } = {}
@@ -51,32 +63,60 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
if (targetCompletions !== undefined && targetCompletions < 1) { if (targetCompletions !== undefined && targetCompletions < 1) {
newErrors.targetCompletions = 'Target completions must be at least 1' newErrors.targetCompletions = 'Target completions must be at least 1'
} }
if (link && !isValidUrl(link)) {
newErrors.link = 'Please enter a valid URL'
}
setErrors(newErrors) setErrors(newErrors)
return Object.keys(newErrors).length === 0 return Object.keys(newErrors).length === 0
} }
const handleSubmit = (e: React.FormEvent) => { const isValidUrl = (url: string) => {
try {
new URL(url)
return true
} catch {
return false
}
}
const handleClose = () => {
setIsOpen(false)
setEditingItem(null)
}
const handleSave = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!validate()) return if (!validate()) return
onSave({
const itemData = {
name, name,
description, description,
coinCost, coinCost,
targetCompletions: targetCompletions || undefined targetCompletions: targetCompletions || undefined,
}) link: link.trim() || undefined
}
if (editingItem) {
editWishlistItem({ ...itemData, id: editingItem.id })
} else {
addWishlistItem(itemData)
}
setIsOpen(false)
setEditingItem(null)
} }
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle> <DialogTitle>{editingItem ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSave}>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
Name Name *
</Label> </Label>
<div className="col-span-3 flex gap-2"> <div className="col-span-3 flex gap-2">
<Input <Input
@@ -208,9 +248,29 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
)} )}
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right">
Link
</Label>
<div className="col-span-3">
<Input
id="link"
type="url"
placeholder="https://..."
value={link}
onChange={(e) => setLink(e.target.value)}
className="col-span-3"
/>
{errors.link && (
<div className="text-sm text-red-500">
{errors.link}
</div>
)}
</div>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button> <Button type="submit">{editingItem ? 'Save Changes' : 'Add Reward'}</Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>

View File

@@ -148,14 +148,6 @@ export default function PomodoroTimer() {
} }
}, [state]) }, [state])
const playSound = useCallback(() => {
const audio = new Audio('/sounds/timer-end.wav')
audio.play().catch(error => {
console.error('Error playing sound:', error)
})
}, [])
const handleTimerEnd = async () => { const handleTimerEnd = async () => {
setState("stopped") setState("stopped")
const currentTimerType = currentTimer.current.type const currentTimerType = currentTimer.current.type
@@ -165,9 +157,6 @@ export default function PomodoroTimer() {
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)] currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
) )
// Play sound
playSound()
// update habits only after focus sessions // update habits only after focus sessions
if (selectedHabit && currentTimerType === 'focus') { if (selectedHabit && currentTimerType === 'focus') {
await completeHabit(selectedHabit) await completeHabit(selectedHabit)

View File

@@ -137,20 +137,11 @@ export default function WishlistManager() {
</div> </div>
<AddEditWishlistItemModal <AddEditWishlistItemModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => { setIsOpen={setIsModalOpen}
setIsModalOpen(false) editingItem={editingItem}
setEditingItem(null) setEditingItem={setEditingItem}
}} addWishlistItem={addWishlistItem}
onSave={(item) => { editWishlistItem={editWishlistItem}
if (editingItem) {
editWishlistItem({ ...item, id: editingItem.id })
} else {
addWishlistItem(item)
}
setIsModalOpen(false)
setEditingItem(null)
}}
item={editingItem}
/> />
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmation.isOpen} isOpen={deleteConfirmation.isOpen}

View File

@@ -3,7 +3,18 @@ import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data' import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate, getISODate, d2s } from '@/lib/utils' import {
getNowInMilliseconds,
getTodayInTimezone,
isSameDate,
t2d,
d2t,
getNow,
getCompletionsForDate,
getISODate,
d2s,
playSound
} from '@/lib/utils'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { Undo2 } from 'lucide-react' import { Undo2 } from 'lucide-react'
@@ -38,7 +49,9 @@ export function useHabits() {
// Add new completion // Add new completion
const updatedHabit = { const updatedHabit = {
...habit, ...habit,
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })] completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })],
// Archive the habit if it's a task and we're about to reach the target
archived: habit.isTask && completionsToday + 1 === target ? true : habit.archived
} }
const updatedHabits = habitsData.habits.map(h => const updatedHabits = habitsData.habits.map(h =>
@@ -46,29 +59,36 @@ export function useHabits() {
) )
await saveHabitsData({ habits: updatedHabits }) await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
// Check if we've now reached the target // Check if we've now reached the target
const isTargetReached = completionsToday + 1 === target const isTargetReached = completionsToday + 1 === target
if (isTargetReached) { if (isTargetReached) {
const updatedCoins = await addCoins({ const updatedCoins = await addCoins({
amount: habit.coinReward, amount: habit.coinReward,
description: `Completed habit: ${habit.name}`, description: `Completed: ${habit.name}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION', type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id, relatedItemId: habit.id,
}) })
setCoins(updatedCoins) isTargetReached && playSound()
}
toast({ toast({
title: isTargetReached ? "Habit completed!" : "Progress!", title: "Habit completed!",
description: isTargetReached description: `You earned ${habit.coinReward} coins.`,
? `You earned ${habit.coinReward} coins.`
: `You've completed ${completionsToday + 1}/${target} times today.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}> action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo <Undo2 className="h-4 w-4" />Undo
</ToastAction> </ToastAction>
}) })
setCoins(updatedCoins)
} else {
toast({
title: "Progress!",
description: `You've completed ${completionsToday + 1}/${target} times today.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
</ToastAction>
})
}
// move atom update at the end of function to improve UI responsiveness
setHabitsData({ habits: updatedHabits })
return { return {
updatedHabits, updatedHabits,
@@ -87,12 +107,13 @@ export function useHabits() {
) )
if (todayCompletions.length > 0) { if (todayCompletions.length > 0) {
// Remove the most recent completion // Remove the most recent completion and unarchive if needed
const updatedHabit = { const updatedHabit = {
...habit, ...habit,
completions: habit.completions.filter( completions: habit.completions.filter(
(_, index) => index !== habit.completions.length - 1 (_, index) => index !== habit.completions.length - 1
) ),
archived: habit.isTask ? undefined : habit.archived // Unarchive if it's a task
} }
const updatedHabits = habitsData.habits.map(h => const updatedHabits = habitsData.habits.map(h =>
@@ -107,7 +128,7 @@ export function useHabits() {
if (todayCompletions.length === target) { if (todayCompletions.length === target) {
const updatedCoins = await removeCoins({ const updatedCoins = await removeCoins({
amount: habit.coinReward, amount: habit.coinReward,
description: `Undid habit completion: ${habit.name}`, description: `Undid completion: ${habit.name}`,
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO', type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
relatedItemId: habit.id, relatedItemId: habit.id,
}) })
@@ -207,7 +228,7 @@ export function useHabits() {
if (isTargetReached) { if (isTargetReached) {
const updatedCoins = await addCoins({ const updatedCoins = await addCoins({
amount: habit.coinReward, amount: habit.coinReward,
description: `Completed habit: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`, description: `Completed: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION', type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id, relatedItemId: habit.id,
}) })

View File

@@ -20,6 +20,7 @@ export type WishlistItemType = {
coinCost: number coinCost: number
archived?: boolean // mark the wishlist item as archived archived?: boolean // mark the wishlist item as archived
targetCompletions?: number // Optional field, infinity when unset targetCompletions?: number // Optional field, infinity when unset
link?: string // Optional URL to external resource
} }
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';

View File

@@ -279,3 +279,11 @@ export function getHabitFreq(habit: Habit): Freq {
default: throw new Error(`Invalid frequency: ${freq}`) default: throw new Error(`Invalid frequency: ${freq}`)
} }
} }
// play sound (client side only, must be run in browser)
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
const audio = new Audio(soundPath)
audio.play().catch(error => {
console.error('Error playing sound:', error)
})
}

View File

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