Skip to content

Commit b62cf77

Browse files
committed
redeem link + completing task + play sound
1 parent c66e281 commit b62cf77

9 files changed

+152
-69
lines changed

CHANGELOG.md

+12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## Version 0.1.28
4+
5+
### Added
6+
7+
- redeem link for wishlist items (#52)
8+
- sound effect for habit / task completion (#53)
9+
10+
### Fixed
11+
12+
- fail habit create or edit if frequency is not set (#54)
13+
- archive task when completed (#50)
14+
315
## Version 0.1.27
416

517
### Added

components/AddEditHabitModal.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
6161
<div className="grid gap-4 py-4">
6262
<div className="grid grid-cols-4 items-center gap-4">
6363
<Label htmlFor="name" className="text-right">
64-
Name
64+
Name *
6565
</Label>
6666
<div className='flex col-span-3 gap-2'>
6767
<Input
@@ -112,13 +112,14 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
112112
</div>
113113
<div className="grid grid-cols-4 items-center gap-4">
114114
<Label htmlFor="recurrence" className="text-right">
115-
When
115+
When *
116116
</Label>
117117
<div className="col-span-3 space-y-2">
118118
<Input
119119
id="recurrence"
120120
value={ruleText}
121121
onChange={(e) => setRuleText(e.target.value)}
122+
required
122123
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
123124
/>
124125
</div>

components/AddEditWishlistItemModal.tsx

+83-23
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,44 @@ import { WishlistItemType } from '@/lib/types'
1313

1414
interface AddEditWishlistItemModalProps {
1515
isOpen: boolean
16-
onClose: () => void
17-
onSave: (item: Omit<WishlistItemType, 'id'>) => void
18-
item?: WishlistItemType | null
16+
setIsOpen: (isOpen: boolean) => void
17+
editingItem: WishlistItemType | null
18+
setEditingItem: (item: WishlistItemType | null) => void
19+
addWishlistItem: (item: Omit<WishlistItemType, 'id'>) => void
20+
editWishlistItem: (item: WishlistItemType) => void
1921
}
2022

21-
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) {
22-
const [name, setName] = useState(item?.name || '')
23-
const [description, setDescription] = useState(item?.description || '')
24-
const [coinCost, setCoinCost] = useState(item?.coinCost || 1)
25-
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(item?.targetCompletions)
23+
export default function AddEditWishlistItemModal({
24+
isOpen,
25+
setIsOpen,
26+
editingItem,
27+
setEditingItem,
28+
addWishlistItem,
29+
editWishlistItem
30+
}: AddEditWishlistItemModalProps) {
31+
const [name, setName] = useState(editingItem?.name || '')
32+
const [description, setDescription] = useState(editingItem?.description || '')
33+
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
34+
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
35+
const [link, setLink] = useState(editingItem?.link || '')
2636
const [errors, setErrors] = useState<{ [key: string]: string }>({})
2737

2838
useEffect(() => {
29-
if (item) {
30-
setName(item.name)
31-
setDescription(item.description)
32-
setCoinCost(item.coinCost)
33-
setTargetCompletions(item.targetCompletions)
39+
if (editingItem) {
40+
setName(editingItem.name)
41+
setDescription(editingItem.description)
42+
setCoinCost(editingItem.coinCost)
43+
setTargetCompletions(editingItem.targetCompletions)
44+
setLink(editingItem.link || '')
3445
} else {
3546
setName('')
3647
setDescription('')
3748
setCoinCost(1)
3849
setTargetCompletions(undefined)
50+
setLink('')
3951
}
4052
setErrors({})
41-
}, [item])
53+
}, [editingItem])
4254

4355
const validate = () => {
4456
const newErrors: { [key: string]: string } = {}
@@ -51,32 +63,60 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
5163
if (targetCompletions !== undefined && targetCompletions < 1) {
5264
newErrors.targetCompletions = 'Target completions must be at least 1'
5365
}
66+
if (link && !isValidUrl(link)) {
67+
newErrors.link = 'Please enter a valid URL'
68+
}
5469
setErrors(newErrors)
5570
return Object.keys(newErrors).length === 0
5671
}
5772

58-
const handleSubmit = (e: React.FormEvent) => {
73+
const isValidUrl = (url: string) => {
74+
try {
75+
new URL(url)
76+
return true
77+
} catch {
78+
return false
79+
}
80+
}
81+
82+
const handleClose = () => {
83+
setIsOpen(false)
84+
setEditingItem(null)
85+
}
86+
87+
const handleSave = (e: React.FormEvent) => {
5988
e.preventDefault()
6089
if (!validate()) return
61-
onSave({
90+
91+
const itemData = {
6292
name,
6393
description,
6494
coinCost,
65-
targetCompletions: targetCompletions || undefined
66-
})
95+
targetCompletions: targetCompletions || undefined,
96+
link: link.trim() || undefined
97+
}
98+
99+
if (editingItem) {
100+
editWishlistItem({ ...itemData, id: editingItem.id })
101+
} else {
102+
addWishlistItem(itemData)
103+
}
104+
105+
setIsOpen(false)
106+
setEditingItem(null)
67107
}
68108

69109
return (
70-
<Dialog open={isOpen} onOpenChange={onClose}>
110+
<Dialog open={isOpen} onOpenChange={handleClose}>
71111
<DialogContent>
72112
<DialogHeader>
73-
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
113+
<DialogTitle>{editingItem ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
74114
</DialogHeader>
75-
<form onSubmit={handleSubmit}>
115+
<form onSubmit={handleSave}>
76116
<div className="grid gap-4 py-4">
77117
<div className="grid grid-cols-4 items-center gap-4">
78118
<Label htmlFor="name" className="text-right">
79-
Name
119+
Name *
80120
</Label>
81121
<div className="col-span-3 flex gap-2">
82122
<Input
@@ -208,9 +248,29 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
208248
)}
209249
</div>
210250
</div>
251+
<div className="grid grid-cols-4 items-center gap-4">
252+
<Label htmlFor="link" className="text-right">
253+
Link
254+
</Label>
255+
<div className="col-span-3">
256+
<Input
257+
id="link"
258+
type="url"
259+
placeholder="https://..."
260+
value={link}
261+
onChange={(e) => setLink(e.target.value)}
262+
className="col-span-3"
263+
/>
264+
{errors.link && (
265+
<div className="text-sm text-red-500">
266+
{errors.link}
267+
</div>
268+
)}
269+
</div>
270+
</div>
211271
</div>
212272
<DialogFooter>
213-
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button>
273+
<Button type="submit">{editingItem ? 'Save Changes' : 'Add Reward'}</Button>
214274
</DialogFooter>
215275
</form>
216276
</DialogContent>

components/PomodoroTimer.tsx

-11
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,6 @@ export default function PomodoroTimer() {
148148
}
149149
}, [state])
150150

151-
152-
const playSound = useCallback(() => {
153-
const audio = new Audio('/sounds/timer-end.wav')
154-
audio.play().catch(error => {
155-
console.error('Error playing sound:', error)
156-
})
157-
}, [])
158-
159151
const handleTimerEnd = async () => {
160152
setState("stopped")
161153
const currentTimerType = currentTimer.current.type
@@ -165,9 +157,6 @@ export default function PomodoroTimer() {
165157
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
166158
)
167159

168-
// Play sound
169-
playSound()
170-
171160
// update habits only after focus sessions
172161
if (selectedHabit && currentTimerType === 'focus') {
173162
await completeHabit(selectedHabit)

components/WishlistManager.tsx

+5-14
Original file line numberDiff line numberDiff line change
@@ -137,20 +137,11 @@ export default function WishlistManager() {
137137
</div>
138138
<AddEditWishlistItemModal
139139
isOpen={isModalOpen}
140-
onClose={() => {
141-
setIsModalOpen(false)
142-
setEditingItem(null)
143-
}}
144-
onSave={(item) => {
145-
if (editingItem) {
146-
editWishlistItem({ ...item, id: editingItem.id })
147-
} else {
148-
addWishlistItem(item)
149-
}
150-
setIsModalOpen(false)
151-
setEditingItem(null)
152-
}}
153-
item={editingItem}
140+
setIsOpen={setIsModalOpen}
141+
editingItem={editingItem}
142+
setEditingItem={setEditingItem}
143+
addWishlistItem={addWishlistItem}
144+
editWishlistItem={editWishlistItem}
154145
/>
155146
<ConfirmDialog
156147
isOpen={deleteConfirmation.isOpen}

hooks/useHabits.tsx

+39-18
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,18 @@ import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
33
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
44
import { Habit } from '@/lib/types'
55
import { DateTime } from 'luxon'
6-
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate, getISODate, d2s } from '@/lib/utils'
6+
import {
7+
getNowInMilliseconds,
8+
getTodayInTimezone,
9+
isSameDate,
10+
t2d,
11+
d2t,
12+
getNow,
13+
getCompletionsForDate,
14+
getISODate,
15+
d2s,
16+
playSound
17+
} from '@/lib/utils'
718
import { toast } from '@/hooks/use-toast'
819
import { ToastAction } from '@/components/ui/toast'
920
import { Undo2 } from 'lucide-react'
@@ -38,37 +49,46 @@ export function useHabits() {
3849
// Add new completion
3950
const updatedHabit = {
4051
...habit,
41-
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })]
52+
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })],
53+
// Archive the habit if it's a task and we're about to reach the target
54+
archived: habit.isTask && completionsToday + 1 === target ? true : habit.archived
4255
}
4356

4457
const updatedHabits = habitsData.habits.map(h =>
4558
h.id === habit.id ? updatedHabit : h
4659
)
4760

4861
await saveHabitsData({ habits: updatedHabits })
49-
setHabitsData({ habits: updatedHabits })
5062

5163
// Check if we've now reached the target
5264
const isTargetReached = completionsToday + 1 === target
5365
if (isTargetReached) {
5466
const updatedCoins = await addCoins({
5567
amount: habit.coinReward,
56-
description: `Completed habit: ${habit.name}`,
68+
description: `Completed: ${habit.name}`,
5769
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
5870
relatedItemId: habit.id,
5971
})
72+
isTargetReached && playSound()
73+
toast({
74+
title: "Habit completed!",
75+
description: `You earned ${habit.coinReward} coins.`,
76+
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
77+
<Undo2 className="h-4 w-4" />Undo
78+
</ToastAction>
79+
})
6080
setCoins(updatedCoins)
81+
} else {
82+
toast({
83+
title: "Progress!",
84+
description: `You've completed ${completionsToday + 1}/${target} times today.`,
85+
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
86+
<Undo2 className="h-4 w-4" />Undo
87+
</ToastAction>
88+
})
6189
}
62-
63-
toast({
64-
title: isTargetReached ? "Habit completed!" : "Progress!",
65-
description: isTargetReached
66-
? `You earned ${habit.coinReward} coins.`
67-
: `You've completed ${completionsToday + 1}/${target} times today.`,
68-
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
69-
<Undo2 className="h-4 w-4" />Undo
70-
</ToastAction>
71-
})
90+
// move atom update at the end of function to improve UI responsiveness
91+
setHabitsData({ habits: updatedHabits })
7292

7393
return {
7494
updatedHabits,
@@ -87,12 +107,13 @@ export function useHabits() {
87107
)
88108

89109
if (todayCompletions.length > 0) {
90-
// Remove the most recent completion
110+
// Remove the most recent completion and unarchive if needed
91111
const updatedHabit = {
92112
...habit,
93113
completions: habit.completions.filter(
94114
(_, index) => index !== habit.completions.length - 1
95-
)
115+
),
116+
archived: habit.isTask ? undefined : habit.archived // Unarchive if it's a task
96117
}
97118

98119
const updatedHabits = habitsData.habits.map(h =>
@@ -107,7 +128,7 @@ export function useHabits() {
107128
if (todayCompletions.length === target) {
108129
const updatedCoins = await removeCoins({
109130
amount: habit.coinReward,
110-
description: `Undid habit completion: ${habit.name}`,
131+
description: `Undid completion: ${habit.name}`,
111132
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
112133
relatedItemId: habit.id,
113134
})
@@ -207,7 +228,7 @@ export function useHabits() {
207228
if (isTargetReached) {
208229
const updatedCoins = await addCoins({
209230
amount: habit.coinReward,
210-
description: `Completed habit: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
231+
description: `Completed: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
211232
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
212233
relatedItemId: habit.id,
213234
})

lib/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type WishlistItemType = {
2020
coinCost: number
2121
archived?: boolean // mark the wishlist item as archived
2222
targetCompletions?: number // Optional field, infinity when unset
23+
link?: string // Optional URL to external resource
2324
}
2425

2526
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';

lib/utils.ts

+8
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,11 @@ export function getHabitFreq(habit: Habit): Freq {
279279
default: throw new Error(`Invalid frequency: ${freq}`)
280280
}
281281
}
282+
283+
// play sound (client side only, must be run in browser)
284+
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
285+
const audio = new Audio(soundPath)
286+
audio.play().catch(error => {
287+
console.error('Error playing sound:', error)
288+
})
289+
}

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "habittrove",
3-
"version": "0.1.27",
3+
"version": "0.1.28",
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbopack",

0 commit comments

Comments
 (0)