Skip to content

Commit

Permalink
redeem link + completing task + play sound
Browse files Browse the repository at this point in the history
  • Loading branch information
dohsimpson committed Jan 27, 2025
1 parent c66e281 commit b62cf77
Show file tree
Hide file tree
Showing 9 changed files with 152 additions and 69 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# 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

### Added
Expand Down
5 changes: 3 additions & 2 deletions components/AddEditHabitModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
Name *
</Label>
<div className='flex col-span-3 gap-2'>
<Input
Expand Down Expand Up @@ -112,13 +112,14 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
When
When *
</Label>
<div className="col-span-3 space-y-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
required
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
/>
</div>
Expand Down
106 changes: 83 additions & 23 deletions components/AddEditWishlistItemModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,44 @@ import { WishlistItemType } from '@/lib/types'

interface AddEditWishlistItemModalProps {
isOpen: boolean
onClose: () => void
onSave: (item: Omit<WishlistItemType, 'id'>) => void
item?: WishlistItemType | null
setIsOpen: (isOpen: boolean) => void
editingItem: 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) {
const [name, setName] = useState(item?.name || '')
const [description, setDescription] = useState(item?.description || '')
const [coinCost, setCoinCost] = useState(item?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(item?.targetCompletions)
export default function AddEditWishlistItemModal({
isOpen,
setIsOpen,
editingItem,
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 }>({})

useEffect(() => {
if (item) {
setName(item.name)
setDescription(item.description)
setCoinCost(item.coinCost)
setTargetCompletions(item.targetCompletions)
if (editingItem) {
setName(editingItem.name)
setDescription(editingItem.description)
setCoinCost(editingItem.coinCost)
setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '')
} else {
setName('')
setDescription('')
setCoinCost(1)
setTargetCompletions(undefined)
setLink('')
}
setErrors({})
}, [item])
}, [editingItem])

const validate = () => {
const newErrors: { [key: string]: string } = {}
Expand All @@ -51,32 +63,60 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
if (targetCompletions !== undefined && targetCompletions < 1) {
newErrors.targetCompletions = 'Target completions must be at least 1'
}
if (link && !isValidUrl(link)) {
newErrors.link = 'Please enter a valid URL'
}
setErrors(newErrors)
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()
if (!validate()) return
onSave({

const itemData = {
name,
description,
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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
<DialogTitle>{editingItem ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSave}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
Name *
</Label>
<div className="col-span-3 flex gap-2">
<Input
Expand Down Expand Up @@ -208,9 +248,29 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
)}
</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>
<DialogFooter>
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button>
<Button type="submit">{editingItem ? 'Save Changes' : 'Add Reward'}</Button>
</DialogFooter>
</form>
</DialogContent>
Expand Down
11 changes: 0 additions & 11 deletions components/PomodoroTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,6 @@ export default function PomodoroTimer() {
}
}, [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 () => {
setState("stopped")
const currentTimerType = currentTimer.current.type
Expand All @@ -165,9 +157,6 @@ export default function PomodoroTimer() {
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)

// Play sound
playSound()

// update habits only after focus sessions
if (selectedHabit && currentTimerType === 'focus') {
await completeHabit(selectedHabit)
Expand Down
19 changes: 5 additions & 14 deletions components/WishlistManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,11 @@ export default function WishlistManager() {
</div>
<AddEditWishlistItemModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingItem(null)
}}
onSave={(item) => {
if (editingItem) {
editWishlistItem({ ...item, id: editingItem.id })
} else {
addWishlistItem(item)
}
setIsModalOpen(false)
setEditingItem(null)
}}
item={editingItem}
setIsOpen={setIsModalOpen}
editingItem={editingItem}
setEditingItem={setEditingItem}
addWishlistItem={addWishlistItem}
editWishlistItem={editWishlistItem}
/>
<ConfirmDialog
isOpen={deleteConfirmation.isOpen}
Expand Down
57 changes: 39 additions & 18 deletions hooks/useHabits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
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 { ToastAction } from '@/components/ui/toast'
import { Undo2 } from 'lucide-react'
Expand Down Expand Up @@ -38,37 +49,46 @@ export function useHabits() {
// Add new completion
const updatedHabit = {
...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 =>
h.id === habit.id ? updatedHabit : h
)

await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })

// Check if we've now reached the target
const isTargetReached = completionsToday + 1 === target
if (isTargetReached) {
const updatedCoins = await addCoins({
amount: habit.coinReward,
description: `Completed habit: ${habit.name}`,
description: `Completed: ${habit.name}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
isTargetReached && playSound()
toast({
title: "Habit completed!",
description: `You earned ${habit.coinReward} coins.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
</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>
})
}

toast({
title: isTargetReached ? "Habit completed!" : "Progress!",
description: isTargetReached
? `You earned ${habit.coinReward} coins.`
: `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 {
updatedHabits,
Expand All @@ -87,12 +107,13 @@ export function useHabits() {
)

if (todayCompletions.length > 0) {
// Remove the most recent completion
// Remove the most recent completion and unarchive if needed
const updatedHabit = {
...habit,
completions: habit.completions.filter(
(_, index) => index !== habit.completions.length - 1
)
),
archived: habit.isTask ? undefined : habit.archived // Unarchive if it's a task
}

const updatedHabits = habitsData.habits.map(h =>
Expand All @@ -107,7 +128,7 @@ export function useHabits() {
if (todayCompletions.length === target) {
const updatedCoins = await removeCoins({
amount: habit.coinReward,
description: `Undid habit completion: ${habit.name}`,
description: `Undid completion: ${habit.name}`,
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
relatedItemId: habit.id,
})
Expand Down Expand Up @@ -207,7 +228,7 @@ export function useHabits() {
if (isTargetReached) {
const updatedCoins = await addCoins({
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',
relatedItemId: habit.id,
})
Expand Down
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type WishlistItemType = {
coinCost: number
archived?: boolean // mark the wishlist item as archived
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';
Expand Down
8 changes: 8 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,11 @@ export function getHabitFreq(habit: Habit): 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)
})
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.1.27",
"version": "0.1.28",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
Expand Down

0 comments on commit b62cf77

Please sign in to comment.