mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
redeem link + completing task + play sound
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user