Compare commits

...

5 Commits

Author SHA1 Message Date
dohsimpson
b62cf77ba8 redeem link + completing task + play sound 2025-01-27 18:24:53 -05:00
dohsimpson
c66e28162c dark mode 2025-01-25 13:03:07 -05:00
Doh
6fe10d9fa5 support archiving habit and wishlist + wishlist redeem count (#49) 2025-01-24 20:41:26 -05:00
dohsimpson
d3502e284d added support for tasks 2025-01-22 17:59:59 -05:00
dohsimpson
3b33719e1a fix completed habits map 2025-01-21 22:28:51 -05:00
32 changed files with 1071 additions and 274 deletions

View File

@@ -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

View File

@@ -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')
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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`}>

View File

@@ -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} />

View File

@@ -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%)'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)} />
</>
)
}

View File

@@ -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}

View File

@@ -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
View 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
View 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>
)
}

View File

@@ -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

View File

@@ -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}

View 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>
}

View 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>
)
}

View 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>
);
};

View File

@@ -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

View File

@@ -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
View File

View 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
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
View File

@@ -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",

View File

@@ -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",