mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62cf77ba8 | ||
|
|
c66e28162c | ||
|
|
6fe10d9fa5 | ||
|
|
d3502e284d | ||
|
|
3b33719e1a |
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,5 +1,47 @@
|
||||
# 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
|
||||
|
||||
- dark mode toggle (#48)
|
||||
- notification badge for tasks (#51)
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
- added support for tasks (#41)
|
||||
|
||||
## Version 0.1.24
|
||||
|
||||
### Fixed
|
||||
|
||||
- completed habits atom should not store partially completed habits (#46)
|
||||
|
||||
## Version 0.1.23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||
return saveData('habits', data)
|
||||
}
|
||||
|
||||
|
||||
// Coins specific functions
|
||||
export async function loadCoinsData(): Promise<CoinsData> {
|
||||
try {
|
||||
@@ -100,13 +101,19 @@ export async function saveCoinsData(data: CoinsData): Promise<void> {
|
||||
return saveData('coins', data)
|
||||
}
|
||||
|
||||
export async function addCoins(
|
||||
amount: number,
|
||||
description: string,
|
||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId?: string,
|
||||
export async function addCoins({
|
||||
amount,
|
||||
description,
|
||||
type = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId,
|
||||
note,
|
||||
}: {
|
||||
amount: number
|
||||
description: string
|
||||
type?: TransactionType
|
||||
relatedItemId?: string
|
||||
note?: string
|
||||
): Promise<CoinsData> {
|
||||
}): Promise<CoinsData> {
|
||||
const data = await loadCoinsData()
|
||||
const newTransaction: CoinTransaction = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -142,13 +149,19 @@ export async function saveSettings(settings: Settings): Promise<void> {
|
||||
return saveData('settings', settings)
|
||||
}
|
||||
|
||||
export async function removeCoins(
|
||||
amount: number,
|
||||
description: string,
|
||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId?: string,
|
||||
export async function removeCoins({
|
||||
amount,
|
||||
description,
|
||||
type = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId,
|
||||
note,
|
||||
}: {
|
||||
amount: number
|
||||
description: string
|
||||
type?: TransactionType
|
||||
relatedItemId?: string
|
||||
note?: string
|
||||
): Promise<CoinsData> {
|
||||
}): Promise<CoinsData> {
|
||||
const data = await loadCoinsData()
|
||||
const newTransaction: CoinTransaction = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -172,7 +185,7 @@ export async function removeCoins(
|
||||
export async function uploadAvatar(formData: FormData) {
|
||||
const file = formData.get('avatar') as File
|
||||
if (!file) throw new Error('No file provided')
|
||||
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) { // 5MB
|
||||
throw new Error('File size must be less than 5MB')
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import HabitCalendar from '@/components/HabitCalendar'
|
||||
import { ViewToggle } from '@/components/ViewToggle'
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
<HabitCalendar />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
{/* <ViewToggle /> */}
|
||||
</div>
|
||||
<HabitCalendar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import HabitList from '@/components/HabitList'
|
||||
import { ViewToggle } from '@/components/ViewToggle'
|
||||
|
||||
export default function HabitsPage() {
|
||||
return (
|
||||
<HabitList />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
{/* <ViewToggle /> */}
|
||||
</div>
|
||||
<HabitList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data'
|
||||
import Layout from '@/components/Layout'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
|
||||
|
||||
// Inter (clean, modern, excellent readability)
|
||||
// const inter = Inter({
|
||||
@@ -42,7 +44,8 @@ export default async function RootLayout({
|
||||
])
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={activeFont.className}>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -71,9 +74,16 @@ export default async function RootLayout({
|
||||
wishlist: initialWishlist
|
||||
}}
|
||||
>
|
||||
<Layout>
|
||||
{children}
|
||||
</Layout>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Layout>
|
||||
{children}
|
||||
</Layout>
|
||||
</ThemeProvider>
|
||||
</JotaiHydrate>
|
||||
</Suspense>
|
||||
</JotaiProvider>
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RRule, RRuleSet, rrulestr } from 'rrule'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
import { settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
||||
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
||||
import * as chrono from 'chrono-node';
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
interface AddEditHabitModalProps {
|
||||
onClose: () => void
|
||||
@@ -27,12 +27,16 @@ interface AddEditHabitModalProps {
|
||||
|
||||
export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const [name, setName] = useState(habit?.name || '')
|
||||
const [description, setDescription] = useState(habit?.description || '')
|
||||
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||
const origRuleText = parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText()
|
||||
const isRecurRule = !isTasksView
|
||||
const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE
|
||||
const [ruleText, setRuleText] = useState<string>(origRuleText)
|
||||
const now = getNow({ timezone: settings.system.timezone })
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -42,9 +46,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
|
||||
coinReward,
|
||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||
completions: habit?.completions || [],
|
||||
frequency: habit ? (
|
||||
origRuleText === ruleText ? habit.frequency : serializeRRule(parseNaturalLanguageRRule(ruleText))
|
||||
) : serializeRRule(parseNaturalLanguageRRule(ruleText)),
|
||||
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }),
|
||||
isTask: isTasksView ? true : undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,13 +55,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{habit ? 'Edit Habit' : 'Add New Habit'}</DialogTitle>
|
||||
<DialogTitle>{habit ? `Edit ${isTasksView ? 'Task' : 'Habit'}` : `Add New ${isTasksView ? 'Task' : 'Habit'}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<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
|
||||
@@ -109,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">
|
||||
Frequency
|
||||
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>
|
||||
@@ -123,7 +127,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
|
||||
<span>
|
||||
{(() => {
|
||||
try {
|
||||
return parseNaturalLanguageRRule(ruleText).toText()
|
||||
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
||||
} catch (e: unknown) {
|
||||
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
|
||||
}
|
||||
@@ -134,7 +138,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="targetCompletions">
|
||||
Repetitions
|
||||
Complete
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
@@ -168,7 +172,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
times per occurrence
|
||||
times
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,7 +180,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="coinReward">
|
||||
Coin Reward
|
||||
Reward
|
||||
</Label>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
@@ -207,14 +211,14 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
coins per completion
|
||||
coins
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{habit ? 'Save Changes' : 'Add Habit'}</Button>
|
||||
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTasksView ? 'Task' : 'Habit'}`}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,51 +5,118 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
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 Picker from '@emoji-mart/react'
|
||||
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('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [coinCost, setCoinCost] = useState(1)
|
||||
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)
|
||||
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('')
|
||||
}
|
||||
}, [item])
|
||||
setErrors({})
|
||||
}, [editingItem])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
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'
|
||||
}
|
||||
if (link && !isValidUrl(link)) {
|
||||
newErrors.link = 'Please enter a valid URL'
|
||||
}
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
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()
|
||||
onSave({ name, description, coinCost })
|
||||
if (!validate()) return
|
||||
|
||||
const itemData = {
|
||||
name,
|
||||
description,
|
||||
coinCost,
|
||||
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
|
||||
@@ -96,22 +163,114 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="coinCost" className="text-right">
|
||||
Coin Cost
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Label htmlFor="coinReward">
|
||||
Cost
|
||||
</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
|
||||
id="coinReward"
|
||||
type="number"
|
||||
value={coinCost}
|
||||
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
|
||||
min={0}
|
||||
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 className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="link" className="text-right">
|
||||
Link
|
||||
</Label>
|
||||
<Input
|
||||
id="coinCost"
|
||||
type="number"
|
||||
value={coinCost}
|
||||
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
|
||||
className="col-span-3"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
|
||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -32,16 +32,21 @@ export default function DailyOverview({
|
||||
}: UpcomingItemsProps) {
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
|
||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||
const today = getTodayInTimezone(settings.system.timezone)
|
||||
const todayCompletions = completedHabitsMap.get(today) || []
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
|
||||
useEffect(() => {
|
||||
// Filter habits that are due today based on their recurrence rule
|
||||
const filteredHabits = habits.filter(habit => isHabitDueToday({ habit, timezone: settings.system.timezone }))
|
||||
const filteredHabits = habits.filter(habit =>
|
||||
(isTasksView ? habit.isTask : !habit.isTask) &&
|
||||
isHabitDueToday({ habit, timezone: settings.system.timezone })
|
||||
)
|
||||
setDailyHabits(filteredHabits)
|
||||
}, [habits])
|
||||
}, [habits, isTasksView])
|
||||
|
||||
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
|
||||
const sortedWishlistItems = wishlistItems
|
||||
@@ -72,13 +77,13 @@ export default function DailyOverview({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold">Daily Habits</h3>
|
||||
<h3 className="font-semibold">{isTasksView ? 'Daily Tasks' : 'Daily Habits'}</h3>
|
||||
<Badge variant="secondary">
|
||||
{dailyHabits.filter(habit => {
|
||||
{`${dailyHabits.filter(habit => {
|
||||
const completions = (completedHabitsMap.get(today) || [])
|
||||
.filter(h => h.id === habit.id).length;
|
||||
return completions >= (habit.targetCompletions || 1);
|
||||
}).length}/{dailyHabits.length} Completed
|
||||
}).length}/${dailyHabits.length} Completed`}
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import DailyOverview from './DailyOverview'
|
||||
import HabitStreak from './HabitStreak'
|
||||
import CoinBalance from './CoinBalance'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { ViewToggle } from './ViewToggle'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [habitsData] = useAtom(habitsAtom)
|
||||
@@ -18,7 +19,10 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<ViewToggle />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<CoinBalance coinBalance={coinBalance} />
|
||||
<HabitStreak habits={habits} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, Circle, CircleCheck } from 'lucide-react'
|
||||
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate } from '@/lib/utils'
|
||||
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
|
||||
@@ -87,9 +87,8 @@ export default function HabitCalendar() {
|
||||
date: selectedDate
|
||||
}))
|
||||
.map((habit) => {
|
||||
const habitsForDate = completedHabitsMap.get(getISODate({ dateTime: selectedDate, timezone: settings.system.timezone })) || []
|
||||
const completionsToday = habitsForDate.filter((h: Habit) => h.id === habit.id).length
|
||||
const isCompleted = completionsToday >= (habit.targetCompletions || 1)
|
||||
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
|
||||
const isCompleted = completions >= (habit.targetCompletions || 1)
|
||||
return (
|
||||
<li key={habit.id} className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
@@ -99,7 +98,7 @@ export default function HabitCalendar() {
|
||||
<div className="flex items-center gap-2">
|
||||
{habit.targetCompletions && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{completionsToday}/{habit.targetCompletions}
|
||||
{completions}/{habit.targetCompletions}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -116,8 +115,8 @@ export default function HabitCalendar() {
|
||||
className="absolute h-4 w-4 rounded-full overflow-hidden"
|
||||
style={{
|
||||
background: `conic-gradient(
|
||||
currentColor ${(completionsToday / (habit.targetCompletions ?? 1)) * 360}deg,
|
||||
transparent ${(completionsToday / (habit.targetCompletions ?? 1)) * 360}deg 360deg
|
||||
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
|
||||
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
|
||||
)`,
|
||||
mask: 'radial-gradient(transparent 50%, black 51%)',
|
||||
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Habit } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom, pomodoroAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule } from '@/lib/utils'
|
||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
interface HabitItemProps {
|
||||
habit: Habit
|
||||
@@ -23,7 +24,7 @@ interface HabitItemProps {
|
||||
}
|
||||
|
||||
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
const { completeHabit, undoComplete } = useHabits()
|
||||
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit } = useHabits()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
||||
const completionsToday = habit.completions?.filter(completion =>
|
||||
@@ -32,6 +33,9 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
const target = habit.targetCompletions || 1
|
||||
const isCompletedToday = completionsToday >= target
|
||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const isRecurRule = !isTasksView
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -55,21 +59,21 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
return (
|
||||
<Card
|
||||
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">
|
||||
<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 && (
|
||||
<CardDescription className="whitespace-pre-line">
|
||||
<CardDescription className={`whitespace-pre-line ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{habit.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className="text-sm text-gray-500">Frequency: {parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText()}</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">
|
||||
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
|
||||
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span>
|
||||
<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.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between gap-2">
|
||||
@@ -79,8 +83,8 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
variant={isCompletedToday ? "secondary" : "default"}
|
||||
size="sm"
|
||||
onClick={async () => await completeHabit(habit)}
|
||||
disabled={isCompletedToday && completionsToday >= target}
|
||||
className="overflow-hidden w-24 sm:w-auto"
|
||||
disabled={habit.archived || (isCompletedToday && completionsToday >= target)}
|
||||
className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Check className="h-4 w-4 sm:mr-2" />
|
||||
<span>
|
||||
@@ -112,7 +116,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{completionsToday > 0 && (
|
||||
{completionsToday > 0 && !habit.archived && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -125,15 +129,17 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="edit"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="ml-2">Edit</span>
|
||||
</Button>
|
||||
{!habit.archived && (
|
||||
<Button
|
||||
variant="edit"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="ml-2">Edit</span>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
@@ -141,17 +147,35 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setPomo((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
selectedHabitId: habit.id
|
||||
}))
|
||||
}}>
|
||||
<Timer className="mr-2 h-4 w-4" />
|
||||
<span>Start Pomodoro</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
|
||||
{!habit.archived && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
setPomo((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
selectedHabitId: habit.id
|
||||
}))
|
||||
}}>
|
||||
<Timer className="mr-2 h-4 w-4" />
|
||||
<span>Start Pomodoro</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!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
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, ListTodo } from 'lucide-react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import EmptyState from './EmptyState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import HabitItem from './HabitItem'
|
||||
@@ -11,11 +11,18 @@ import AddEditHabitModal from './AddEditHabitModal'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
|
||||
export default function HabitList() {
|
||||
const { saveHabit, deleteHabit } = useHabits()
|
||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
||||
const habits = habitsData.habits
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const habits = habitsData.habits.filter(habit =>
|
||||
isTasksView ? habit.isTask : !habit.isTask
|
||||
)
|
||||
const activeHabits = habits.filter(h => !h.archived)
|
||||
const archivedHabits = habits.filter(h => h.archived)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
|
||||
@@ -28,22 +35,24 @@ export default function HabitList() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">My Habits</h1>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isTasksView ? 'My Tasks' : 'My Habits'}
|
||||
</h1>
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Habit
|
||||
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
|
||||
</Button>
|
||||
</div>
|
||||
<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">
|
||||
<EmptyState
|
||||
icon={ListTodo}
|
||||
title="No habits yet"
|
||||
description="Create your first habit to start tracking your progress"
|
||||
icon={isTasksView ? TaskIcon : HabitIcon}
|
||||
title={isTasksView ? "No tasks yet" : "No habits yet"}
|
||||
description={isTasksView ? "Create your first task to start tracking your progress" : "Create your first habit to start tracking your progress"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
habits.map((habit) => (
|
||||
activeHabits.map((habit: Habit) => (
|
||||
<HabitItem
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
@@ -55,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>
|
||||
{isModalOpen &&
|
||||
<AddEditHabitModal
|
||||
@@ -79,8 +109,8 @@ export default function HabitList() {
|
||||
}
|
||||
setDeleteConfirmation({ isOpen: false, habitId: null })
|
||||
}}
|
||||
title="Delete Habit"
|
||||
message="Are you sure you want to delete this habit? This action cannot be undone."
|
||||
title={isTasksView ? "Delete Task" : "Delete Habit"}
|
||||
message={isTasksView ? "Are you sure you want to delete this task? This action cannot be undone." : "Are you sure you want to delete this habit? This action cannot be undone."}
|
||||
confirmText="Delete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { coinsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
|
||||
@@ -18,6 +18,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import AboutModal from './AboutModal'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Profile } from './Profile'
|
||||
|
||||
interface HeaderProps {
|
||||
className?: string
|
||||
@@ -26,9 +27,10 @@ interface HeaderProps {
|
||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||
|
||||
export default function Header({ className }: HeaderProps) {
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [coins] = useAtom(coinsAtom)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
return (
|
||||
<>
|
||||
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
||||
@@ -54,45 +56,11 @@ export default function Header({ className }: HeaderProps) {
|
||||
<Button variant="ghost" size="icon" aria-label="Notifications">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-2">
|
||||
{/* <Menu className="h-5 w-5" /> */}
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={settings?.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
|
||||
<AvatarFallback>
|
||||
<User className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 p-2">
|
||||
<DropdownMenuItem className="cursor-pointer px-3 py-2" asChild>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-label='settings'
|
||||
className="flex items-center w-full gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-3 py-2" asChild>
|
||||
<button
|
||||
onClick={() => setShowAbout(true)}
|
||||
className="flex items-center w-full gap-2"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span>About</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Profile />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Home, Calendar, List, Gift, Coins, Settings, Info } from 'lucide-react'
|
||||
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AboutModal from './AboutModal'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
|
||||
const navItems = [
|
||||
const navItems = (isTasksView: boolean) => [
|
||||
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' },
|
||||
{ icon: List, label: 'Habits', href: '/habits', position: 'main' },
|
||||
{
|
||||
icon: isTasksView ? TaskIcon : HabitIcon,
|
||||
label: isTasksView ? 'Tasks' : 'Habits',
|
||||
href: '/habits',
|
||||
position: 'main'
|
||||
},
|
||||
{ icon: Calendar, label: 'Calendar', href: '/calendar', position: 'main' },
|
||||
{ icon: Gift, label: 'Wishlist', href: '/wishlist', position: 'main' },
|
||||
{ icon: Coins, label: 'Coins', href: '/coins', position: 'main' },
|
||||
@@ -23,6 +31,8 @@ interface NavigationProps {
|
||||
export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [isMobileView, setIsMobileView] = useState(false)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -45,7 +55,7 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
<div className="pb-16" /> {/* Add padding at the bottom to prevent content from being hidden */}
|
||||
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg">
|
||||
<div className="flex justify-around">
|
||||
{[...navItems.filter(item => item.position === 'main'), ...navItems.filter(item => item.position === 'bottom')].map((item) => (
|
||||
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
@@ -69,7 +79,7 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{navItems.filter(item => item.position === 'main').map((item) => (
|
||||
{navItems(isTasksView).filter(item => item.position === 'main').map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -184,6 +173,16 @@ export default function PomodoroTimer() {
|
||||
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 minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
@@ -314,12 +313,7 @@ export default function PomodoroTimer() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
currentTimer.current = currentTimer.current.type === 'focus'
|
||||
? PomoConfigs.break
|
||||
: PomoConfigs.focus
|
||||
resetTimer()
|
||||
}}
|
||||
onClick={skipTimer}
|
||||
disabled={state === "started"}
|
||||
className="sm:px-4"
|
||||
>
|
||||
|
||||
92
components/Profile.tsx
Normal file
92
components/Profile.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Settings, Info, User, Moon, Sun, Palette } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useAtom } from "jotai"
|
||||
import { settingsAtom } from "@/lib/atoms"
|
||||
import AboutModal from "./AboutModal"
|
||||
import { useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
export function Profile() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={settings?.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
|
||||
<AvatarFallback>
|
||||
<User className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px] p-2">
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-label='settings'
|
||||
className="flex items-center w-full gap-3"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||
<button
|
||||
onClick={() => setShowAbout(true)}
|
||||
className="flex items-center w-full gap-3"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span>About</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>Theme</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className={`
|
||||
w-12 h-6 rounded-full relative transition-all duration-300 ease-in-out
|
||||
hover:scale-105 shadow-inner
|
||||
${theme === 'dark'
|
||||
? 'bg-blue-600/90 hover:bg-blue-600'
|
||||
: 'bg-gray-200 hover:bg-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
w-5 h-5 rounded-full absolute top-0.5 left-0.5
|
||||
transition-all duration-300 ease-in-out
|
||||
shadow-md bg-white
|
||||
${theme === 'dark' ? 'translate-x-6' : 'translate-x-0'}
|
||||
`}>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="h-3 w-3 text-gray-600" />
|
||||
) : (
|
||||
<Sun className="h-3 w-3 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
77
components/ViewToggle.tsx
Normal file
77
components/ViewToggle.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { CheckSquare, ListChecks } from 'lucide-react'
|
||||
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import type { ViewType } from '@/lib/types'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { isHabitDueToday } from '@/lib/utils'
|
||||
import { NotificationBadge } from './ui/notification-badge'
|
||||
|
||||
interface ViewToggleProps {
|
||||
defaultView?: ViewType
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ViewToggle({
|
||||
defaultView = 'habits',
|
||||
className
|
||||
}: ViewToggleProps) {
|
||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||
const [habits] = useAtom(habitsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
|
||||
const handleViewChange = (checked: boolean) => {
|
||||
const newView = checked ? 'tasks' : 'habits'
|
||||
setBrowserSettings({
|
||||
...browserSettings,
|
||||
viewType: newView,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate due tasks count
|
||||
const dueTasksCount = habits.habits.filter(habit =>
|
||||
habit.isTask && !habit.archived && isHabitDueToday({ habit, timezone: settings.system.timezone })
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
|
||||
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
|
||||
<button
|
||||
onClick={() => handleViewChange(false)}
|
||||
className={cn(
|
||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
||||
browserSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<HabitIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Habits</span>
|
||||
</button>
|
||||
<NotificationBadge
|
||||
label={dueTasksCount}
|
||||
show={dueTasksCount > 0}
|
||||
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
|
||||
className="shadow-md"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleViewChange(true)}
|
||||
className={cn(
|
||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
||||
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<TaskIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Tasks</span>
|
||||
</button>
|
||||
</NotificationBadge>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
|
||||
browserSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { WishlistItemType } from '@/lib/types'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,9 +16,12 @@ interface WishlistItemProps {
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onRedeem: () => void
|
||||
onArchive: () => void
|
||||
onUnarchive: () => void
|
||||
canRedeem: boolean
|
||||
isHighlighted?: boolean
|
||||
isRecentlyRedeemed?: boolean
|
||||
isArchived?: boolean
|
||||
}
|
||||
|
||||
export default function WishlistItem({
|
||||
@@ -26,6 +29,8 @@ export default function WishlistItem({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRedeem,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
canRedeem,
|
||||
isHighlighted,
|
||||
isRecentlyRedeemed
|
||||
@@ -35,20 +40,31 @@ export default function WishlistItem({
|
||||
id={`wishlist-${item.id}`}
|
||||
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' : ''
|
||||
}`}
|
||||
} ${item.archived ? 'opacity-75' : ''}`}
|
||||
>
|
||||
<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 && (
|
||||
<CardDescription className="whitespace-pre-line">
|
||||
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
|
||||
<span className="text-sm font-medium">{item.coinCost} coins</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<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.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
|
||||
{item.coinCost} coins
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between gap-2">
|
||||
@@ -57,8 +73,8 @@ export default function WishlistItem({
|
||||
variant={canRedeem ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={onRedeem}
|
||||
disabled={!canRedeem}
|
||||
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''}`}
|
||||
disabled={!canRedeem || item.archived}
|
||||
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' : ''}`} />
|
||||
<span>
|
||||
@@ -77,15 +93,17 @@ export default function WishlistItem({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="edit"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="ml-2">Edit</span>
|
||||
</Button>
|
||||
{!item.archived && (
|
||||
<Button
|
||||
variant="edit"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="ml-2">Edit</span>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
@@ -93,6 +111,18 @@ export default function WishlistItem({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
|
||||
@@ -16,10 +16,15 @@ export default function WishlistManager() {
|
||||
editWishlistItem,
|
||||
deleteWishlistItem,
|
||||
redeemWishlistItem,
|
||||
archiveWishlistItem,
|
||||
unarchiveWishlistItem,
|
||||
canRedeem,
|
||||
wishlistItems
|
||||
} = useWishlist()
|
||||
|
||||
const activeItems = wishlistItems.filter(item => !item.archived)
|
||||
const archivedItems = wishlistItems.filter(item => item.archived)
|
||||
|
||||
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null)
|
||||
const [recentlyRedeemedId, setRecentlyRedeemedId] = useState<string | null>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
@@ -69,7 +74,7 @@ export default function WishlistManager() {
|
||||
</Button>
|
||||
</div>
|
||||
<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">
|
||||
<EmptyState
|
||||
icon={Gift}
|
||||
@@ -78,7 +83,7 @@ export default function WishlistManager() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
wishlistItems.map((item) => (
|
||||
activeItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
@@ -97,28 +102,46 @@ export default function WishlistManager() {
|
||||
}}
|
||||
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
|
||||
onRedeem={() => handleRedeem(item)}
|
||||
onArchive={() => archiveWishlistItem(item.id)}
|
||||
onUnarchive={() => unarchiveWishlistItem(item.id)}
|
||||
canRedeem={canRedeem(item.coinCost)}
|
||||
/>
|
||||
</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>
|
||||
<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}
|
||||
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
40
components/theme-toggle.tsx
Normal file
40
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Moon, MoonIcon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
41
components/ui/notification-badge.tsx
Normal file
41
components/ui/notification-badge.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import {Badge, BadgeProps} from './badge';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
export interface NotificationBadgeProps extends BadgeProps {
|
||||
label?: string | number;
|
||||
show?: boolean;
|
||||
variant?: 'destructive' | 'default' | 'secondary';
|
||||
}
|
||||
|
||||
export const NotificationBadge = ({
|
||||
label,
|
||||
className,
|
||||
show,
|
||||
variant = 'destructive',
|
||||
children,
|
||||
...props
|
||||
}: NotificationBadgeProps) => {
|
||||
const showBadge =
|
||||
typeof label !== 'undefined' && (typeof show === 'undefined' || show);
|
||||
return (
|
||||
<div className='inline-flex relative'>
|
||||
{children}
|
||||
{showBadge && (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
'absolute rounded-full -top-1.5 -right-1.5 z-20 border h-4 w-4 p-0 flex items-center justify-center text-xs',
|
||||
typeof label !== 'undefined' && ('' + label).length === 0
|
||||
? ''
|
||||
: 'min-w-[1rem]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{'' + label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -28,7 +28,12 @@ export function useCoins() {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await addCoins(amount, description, 'MANUAL_ADJUSTMENT', undefined, note)
|
||||
const data = await addCoins({
|
||||
amount,
|
||||
description,
|
||||
type: 'MANUAL_ADJUSTMENT',
|
||||
note
|
||||
})
|
||||
setCoins(data)
|
||||
toast({ title: "Success", description: `Added ${amount} coins` })
|
||||
return data
|
||||
@@ -44,7 +49,12 @@ export function useCoins() {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await removeCoins(numAmount, description, 'MANUAL_ADJUSTMENT', undefined, note)
|
||||
const data = await removeCoins({
|
||||
amount: numAmount,
|
||||
description,
|
||||
type: 'MANUAL_ADJUSTMENT',
|
||||
note
|
||||
})
|
||||
setCoins(data)
|
||||
toast({ title: "Success", description: `Removed ${numAmount} coins` })
|
||||
return data
|
||||
|
||||
@@ -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'
|
||||
@@ -38,7 +49,9 @@ 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 =>
|
||||
@@ -46,29 +59,36 @@ export function useHabits() {
|
||||
)
|
||||
|
||||
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(
|
||||
habit.coinReward,
|
||||
`Completed habit: ${habit.name}`,
|
||||
'HABIT_COMPLETION',
|
||||
habit.id
|
||||
)
|
||||
const updatedCoins = await addCoins({
|
||||
amount: habit.coinReward,
|
||||
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,
|
||||
@@ -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 =>
|
||||
@@ -105,12 +126,12 @@ export function useHabits() {
|
||||
// If we were at the target, remove the coins
|
||||
const target = habit.targetCompletions || 1
|
||||
if (todayCompletions.length === target) {
|
||||
const updatedCoins = await removeCoins(
|
||||
habit.coinReward,
|
||||
`Undid habit completion: ${habit.name}`,
|
||||
'HABIT_UNDO',
|
||||
habit.id
|
||||
)
|
||||
const updatedCoins = await removeCoins({
|
||||
amount: habit.coinReward,
|
||||
description: `Undid completion: ${habit.name}`,
|
||||
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
|
||||
relatedItemId: habit.id,
|
||||
})
|
||||
setCoins(updatedCoins)
|
||||
}
|
||||
|
||||
@@ -205,12 +226,12 @@ export function useHabits() {
|
||||
// Check if we've now reached the target
|
||||
const isTargetReached = completionsOnDate + 1 === target
|
||||
if (isTargetReached) {
|
||||
const updatedCoins = await addCoins(
|
||||
habit.coinReward,
|
||||
`Completed habit: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
|
||||
'HABIT_COMPLETION',
|
||||
habit.id
|
||||
)
|
||||
const updatedCoins = await addCoins({
|
||||
amount: habit.coinReward,
|
||||
description: `Completed: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
|
||||
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
||||
relatedItemId: habit.id,
|
||||
})
|
||||
setCoins(updatedCoins)
|
||||
}
|
||||
|
||||
@@ -231,11 +252,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 {
|
||||
completeHabit,
|
||||
undoComplete,
|
||||
saveHabit,
|
||||
deleteHabit,
|
||||
completePastHabit
|
||||
completePastHabit,
|
||||
archiveHabit,
|
||||
unarchiveHabit
|
||||
}
|
||||
}
|
||||
|
||||
0
hooks/useTasks.tsx
Normal file
0
hooks/useTasks.tsx
Normal file
@@ -33,14 +33,48 @@ export function useWishlist() {
|
||||
|
||||
const redeemWishlistItem = async (item: WishlistItemType) => {
|
||||
if (balance >= item.coinCost) {
|
||||
const data = await removeCoins(
|
||||
item.coinCost,
|
||||
`Redeemed reward: ${item.name}`,
|
||||
'WISH_REDEMPTION',
|
||||
item.id
|
||||
)
|
||||
// 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({
|
||||
amount: item.coinCost,
|
||||
description: `Redeemed reward: ${item.name}`,
|
||||
type: 'WISH_REDEMPTION',
|
||||
relatedItemId: item.id
|
||||
})
|
||||
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
|
||||
const celebrationEffects = [
|
||||
celebrations.emojiParty
|
||||
@@ -66,11 +100,29 @@ export function useWishlist() {
|
||||
|
||||
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 {
|
||||
addWishlistItem,
|
||||
editWishlistItem,
|
||||
deleteWishlistItem,
|
||||
redeemWishlistItem,
|
||||
archiveWishlistItem,
|
||||
unarchiveWishlistItem,
|
||||
canRedeem,
|
||||
wishlistItems: wishlist.items
|
||||
}
|
||||
|
||||
34
lib/atoms.ts
34
lib/atoms.ts
@@ -4,7 +4,8 @@ import {
|
||||
getDefaultHabitsData,
|
||||
getDefaultCoinsData,
|
||||
getDefaultWishlistData,
|
||||
Habit
|
||||
Habit,
|
||||
ViewType,
|
||||
} from "./types";
|
||||
import {
|
||||
getTodayInTimezone,
|
||||
@@ -18,6 +19,15 @@ import {
|
||||
getCompletionsForToday,
|
||||
getISODate
|
||||
} from "@/lib/utils";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
export interface BrowserSettings {
|
||||
viewType: ViewType
|
||||
}
|
||||
|
||||
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||
viewType: 'habits'
|
||||
} as BrowserSettings)
|
||||
|
||||
export const settingsAtom = atom(getDefaultSettings());
|
||||
export const habitsAtom = atom(getDefaultHabitsData());
|
||||
@@ -72,24 +82,38 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
minimized: false,
|
||||
})
|
||||
|
||||
// Derived atom for today's completions of selected habit
|
||||
// Derived atom for *fully* completed habits by date, respecting target completions
|
||||
export const completedHabitsMapAtom = atom((get) => {
|
||||
const habits = get(habitsAtom).habits
|
||||
const timezone = get(settingsAtom).system.timezone
|
||||
|
||||
const map = new Map<string, Habit[]>()
|
||||
|
||||
habits.forEach(habit => {
|
||||
// Group completions by date
|
||||
const completionsByDate = new Map<string, number>()
|
||||
|
||||
habit.completions.forEach(completion => {
|
||||
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone })
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, [])
|
||||
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1)
|
||||
})
|
||||
|
||||
// Check if habit meets target completions for each date
|
||||
completionsByDate.forEach((count, dateKey) => {
|
||||
const target = habit.targetCompletions || 1
|
||||
if (count >= target) {
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, [])
|
||||
}
|
||||
map.get(dateKey)!.push(habit)
|
||||
}
|
||||
map.get(dateKey)!.push(habit)
|
||||
})
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
|
||||
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
||||
const pomo = get(pomodoroAtom)
|
||||
const habits = get(habitsAtom)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CheckSquare, Target } from "lucide-react"
|
||||
|
||||
export const INITIAL_RECURRENCE_RULE = 'daily'
|
||||
export const INITIAL_DUE = 'today'
|
||||
|
||||
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
||||
'daily': 'FREQ=DAILY',
|
||||
@@ -8,3 +11,11 @@ export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
||||
'': 'invalid',
|
||||
}
|
||||
|
||||
export const DUE_MAP: { [key: string]: string } = {
|
||||
'tom': 'tomorrow',
|
||||
'tod': 'today',
|
||||
'yes': 'yesterday',
|
||||
}
|
||||
|
||||
export const HabitIcon = Target
|
||||
export const TaskIcon = CheckSquare;
|
||||
|
||||
12
lib/types.ts
12
lib/types.ts
@@ -6,8 +6,11 @@ export type Habit = {
|
||||
coinReward: number
|
||||
targetCompletions?: number // Optional field, default to 1
|
||||
completions: string[] // Array of UTC ISO date strings
|
||||
isTask?: boolean // mark the habit as a task
|
||||
archived?: boolean // mark the habit as archived
|
||||
}
|
||||
|
||||
|
||||
export type Freq = 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
|
||||
export type WishlistItemType = {
|
||||
@@ -15,9 +18,12 @@ export type WishlistItemType = {
|
||||
name: string
|
||||
description: string
|
||||
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';
|
||||
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
|
||||
|
||||
export interface CoinTransaction {
|
||||
id: string;
|
||||
@@ -33,6 +39,7 @@ export interface HabitsData {
|
||||
habits: Habit[];
|
||||
}
|
||||
|
||||
|
||||
export interface CoinsData {
|
||||
balance: number;
|
||||
transactions: CoinTransaction[];
|
||||
@@ -49,6 +56,7 @@ export const getDefaultHabitsData = (): HabitsData => ({
|
||||
habits: []
|
||||
});
|
||||
|
||||
|
||||
export const getDefaultCoinsData = (): CoinsData => ({
|
||||
balance: 0,
|
||||
transactions: []
|
||||
@@ -103,6 +111,8 @@ export interface Settings {
|
||||
profile: ProfileSettings;
|
||||
}
|
||||
|
||||
export type ViewType = 'habits' | 'tasks'
|
||||
|
||||
export interface JotaiHydrateInitialValues {
|
||||
settings: Settings;
|
||||
coins: CoinsData;
|
||||
|
||||
41
lib/utils.ts
41
lib/utils.ts
@@ -1,9 +1,10 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DateTime } from "luxon"
|
||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||
import { datetime, RRule } from 'rrule'
|
||||
import { Freq, Habit, CoinTransaction } from '@/lib/types'
|
||||
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import { DUE_MAP, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import * as chrono from 'chrono-node';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -41,9 +42,13 @@ export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezo
|
||||
}
|
||||
|
||||
// convert datetime object to string, mostly for display
|
||||
export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format?: string, timezone: string }) {
|
||||
export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format?: string | DateTimeFormatOptions, timezone: string }) {
|
||||
if (format) {
|
||||
return dateTime.setZone(timezone).toFormat(format);
|
||||
if (typeof format === 'string') {
|
||||
return dateTime.setZone(timezone).toFormat(format);
|
||||
} else {
|
||||
return dateTime.setZone(timezone).toLocaleString(format);
|
||||
}
|
||||
}
|
||||
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
|
||||
}
|
||||
@@ -204,6 +209,17 @@ export function serializeRRule(rrule: RRule) {
|
||||
return rrule.toString()
|
||||
}
|
||||
|
||||
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) {
|
||||
if (DUE_MAP[text]) {
|
||||
text = DUE_MAP[text]
|
||||
}
|
||||
const now = getNow({ timezone })
|
||||
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
|
||||
if (!due) throw Error('invalid rule')
|
||||
// return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
||||
return DateTime.fromJSDate(due).setZone(timezone)
|
||||
}
|
||||
|
||||
export function isHabitDue({
|
||||
habit,
|
||||
timezone,
|
||||
@@ -213,6 +229,11 @@ export function isHabitDue({
|
||||
timezone: string
|
||||
date: DateTime
|
||||
}): boolean {
|
||||
if (habit.isTask) {
|
||||
// For tasks, frequency is stored as a UTC ISO timestamp
|
||||
const taskDueDate = t2d({ timestamp: habit.frequency, timezone })
|
||||
return isSameDate(taskDueDate, date);
|
||||
}
|
||||
const startOfDay = date.setZone(timezone).startOf('day')
|
||||
const endOfDay = date.setZone(timezone).endOf('day')
|
||||
|
||||
@@ -244,6 +265,10 @@ export function isHabitDueToday({
|
||||
}
|
||||
|
||||
export function getHabitFreq(habit: Habit): Freq {
|
||||
if (habit.isTask) {
|
||||
// don't support recurring task yet
|
||||
return 'daily'
|
||||
}
|
||||
const rrule = parseRRule(habit.frequency)
|
||||
const freq = rrule.origOptions.freq
|
||||
switch (freq) {
|
||||
@@ -254,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)
|
||||
})
|
||||
}
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.26",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "habittrove",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.26",
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -26,6 +26,7 @@
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@uiw/react-heat-map": "^2.3.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"chrono-node": "^2.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
@@ -36,6 +37,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "15.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-confetti": "^6.2.2",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -3137,6 +3139,17 @@
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chrono-node": {
|
||||
"version": "2.7.7",
|
||||
"resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.7.7.tgz",
|
||||
"integrity": "sha512-p3S7gotuTPu5oqhRL2p1fLwQXGgdQaRTtWR3e8Di9P1Pa9mzkK5DWR5AWBieMUh2ZdOnPgrK+zCrbbtyuA+D/Q==",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
@@ -3428,6 +3441,11 @@
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
@@ -6472,6 +6490,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz",
|
||||
"integrity": "sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.28",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -33,6 +33,7 @@
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@uiw/react-heat-map": "^2.3.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"chrono-node": "^2.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "15.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-confetti": "^6.2.2",
|
||||
"react-day-picker": "^8.10.1",
|
||||
|
||||
Reference in New Issue
Block a user