use jotai for all states (#19)

This commit is contained in:
Doh
2025-01-04 11:20:36 -05:00
committed by GitHub
parent 306242f2ec
commit ad05a46206
18 changed files with 212 additions and 243 deletions

View File

@@ -1,5 +1,11 @@
# Changelog # Changelog
## Version 0.1.8
### Changed
- use jotai for all state management
## Version 0.1.7 ## Version 0.1.7
### Fixed ### Fixed

View File

@@ -14,7 +14,7 @@ import {
DATA_DEFAULTS, DATA_DEFAULTS,
getDefaultSettings getDefaultSettings
} from '@/lib/types' } from '@/lib/types'
import { d2t, getNow, getNowInMilliseconds } from '@/lib/utils'; import { d2t, getNow } from '@/lib/utils';
function getDefaultData<T>(type: DataType): T { function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as T; return DATA_DEFAULTS[type]() as T;
@@ -65,8 +65,12 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
} }
// Wishlist specific functions // Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> {
return loadData<WishlistData>('wishlist')
}
export async function loadWishlistItems(): Promise<WishlistItemType[]> { export async function loadWishlistItems(): Promise<WishlistItemType[]> {
const data = await loadData<WishlistData>('wishlist') const data = await loadWishlistData()
return data.items return data.items
} }

View File

@@ -5,7 +5,7 @@ import { Toaster } from '@/components/ui/toaster'
import { JotaiProvider } from '@/components/jotai-providers' import { JotaiProvider } from '@/components/jotai-providers'
import { Suspense } from 'react' import { Suspense } from 'react'
import { JotaiHydrate } from '@/components/jotai-hydrate' import { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings } from './actions/data' import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data'
// Inter (clean, modern, excellent readability) // Inter (clean, modern, excellent readability)
const inter = Inter({ const inter = Inter({
subsets: ['latin'], subsets: ['latin'],
@@ -32,13 +32,26 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const initialSettings = await loadSettings() const [initialSettings, initialHabits, initialCoins, initialWishlist] = await Promise.all([
loadSettings(),
loadHabitsData(),
loadCoinsData(),
loadWishlistData()
])
return ( return (
<html lang="en"> <html lang="en">
<body className={activeFont.className}> <body className={activeFont.className}>
<JotaiProvider> <JotaiProvider>
<Suspense fallback="loading"> <Suspense fallback="loading">
<JotaiHydrate initialSettings={initialSettings}> <JotaiHydrate
initialValues={{
settings: initialSettings,
habits: initialHabits,
coins: initialCoins,
wishlist: initialWishlist
}}
>
{children} {children}
</JotaiHydrate> </JotaiHydrate>
</Suspense> </Suspense>

View File

@@ -8,31 +8,25 @@ import { History } from 'lucide-react'
import EmptyState from './EmptyState' import EmptyState from './EmptyState'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from '@/hooks/use-toast' import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import Link from 'next/link' import Link from 'next/link'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms' import { useCoins } from '@/hooks/useCoins'
export default function CoinsManager() { export default function CoinsManager() {
const { balance, transactions, addAmount, removeAmount } = useCoins() const { add, remove, balance, transactions } = useCoins()
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const DEFAULT_AMOUNT = '0' const DEFAULT_AMOUNT = '0'
const [amount, setAmount] = useState(DEFAULT_AMOUNT) const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const handleAddCoins = async () => { const handleAddRemoveCoins = async () => {
const data = await addAmount(Number(amount), "Manual addition") const numAmount = Number(amount)
if (data) { if (numAmount > 0) {
await add(numAmount, "Manual addition")
setAmount(DEFAULT_AMOUNT) setAmount(DEFAULT_AMOUNT)
toast({ title: "Success", description: `Added ${amount} coins` }) } else if (numAmount < 0) {
} await remove(Math.abs(numAmount), "Manual removal")
}
const handleRemoveCoins = async () => {
const data = await removeAmount(Math.abs(Number(amount)), "Manual removal")
if (data) {
setAmount(DEFAULT_AMOUNT) setAmount(DEFAULT_AMOUNT)
toast({ title: "Success", description: `Removed ${amount} coins` })
} }
} }
@@ -84,14 +78,7 @@ export default function CoinsManager() {
</div> </div>
<Button <Button
onClick={() => { onClick={handleAddRemoveCoins}
const numAmount = Number(amount);
if (numAmount > 0) {
handleAddCoins();
} else if (numAmount < 0) {
handleRemoveCoins();
}
}}
className="w-full h-14 transition-colors flex items-center justify-center font-medium" className="w-full h-14 transition-colors flex items-center justify-center font-medium"
variant="default" variant="default"
> >

View File

@@ -1,26 +1,21 @@
'use client' 'use client'
import { loadCoinsData } from '@/app/actions/data' import { useAtom } from 'jotai'
import { useHabits } from '@/hooks/useHabits' import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms'
import { useWishlist } from '@/hooks/useWishlist'
import { useEffect, useState } from 'react'
import CoinBalance from './CoinBalance' import CoinBalance from './CoinBalance'
import DailyOverview from './DailyOverview' import DailyOverview from './DailyOverview'
import HabitOverview from './HabitOverview'
import HabitStreak from './HabitStreak' import HabitStreak from './HabitStreak'
import { useHabits } from '@/hooks/useHabits'
export default function Dashboard() { export default function Dashboard() {
const { habits, completeHabit, undoComplete } = useHabits() const { completeHabit, undoComplete } = useHabits()
const [coinBalance, setCoinBalance] = useState(0) const [habitsData] = useAtom(habitsAtom)
const { wishlistItems } = useWishlist() const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
useEffect(() => { const [coins] = useAtom(coinsAtom)
const loadData = async () => { const coinBalance = coins.balance
const coinsData = await loadCoinsData() const [wishlist] = useAtom(wishlistAtom)
setCoinBalance(coinsData.balance) const wishlistItems = wishlist.items
}
loadData()
}, [])
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -33,18 +28,8 @@ export default function Dashboard() {
wishlistItems={wishlistItems} wishlistItems={wishlistItems}
habits={habits} habits={habits}
coinBalance={coinBalance} coinBalance={coinBalance}
onComplete={async (habit) => { onComplete={completeHabit}
const newBalance = await completeHabit(habit) onUndo={undoComplete}
if (newBalance !== null) {
setCoinBalance(newBalance)
}
}}
onUndo={async (habit) => {
const newBalance = await undoComplete(habit)
if (newBalance !== null) {
setCoinBalance(newBalance)
}
}}
/> />
{/* <HabitHeatmap habits={habits} /> */} {/* <HabitHeatmap habits={habits} /> */}

View File

@@ -1,31 +1,20 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { Calendar } from '@/components/ui/calendar' import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { loadHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
import { d2s, getNow } from '@/lib/utils' import { d2s, getNow } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms' import { habitsAtom, settingsAtom } from '@/lib/atoms'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import Linkify from './linkify' import Linkify from './linkify'
export default function HabitCalendar() { export default function HabitCalendar() {
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [selectedDate, setSelectedDate] = useState<DateTime>(getNow({ timezone: settings.system.timezone })) const [selectedDate, setSelectedDate] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
const [habits, setHabits] = useState<Habit[]>([]) const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
useEffect(() => {
fetchHabitsData()
}, [])
const fetchHabitsData = async () => {
const data = await loadHabitsData()
setHabits(data.habits)
}
const getHabitsForDate = (date: Date) => { const getHabitsForDate = (date: Date) => {
const dateString = date.toISOString().split('T')[0] const dateString = date.toISOString().split('T')[0]
@@ -46,7 +35,7 @@ export default function HabitCalendar() {
<Calendar <Calendar
mode="single" mode="single"
selected={selectedDate.toJSDate()} selected={selectedDate.toJSDate()}
// onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))} onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))}
className="rounded-md border" className="rounded-md border"
modifiers={{ modifiers={{
completed: (date) => getHabitsForDate(date).length > 0, completed: (date) => getHabitsForDate(date).length > 0,

View File

@@ -6,16 +6,16 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2 } from 'lucide-react' import { Coins, Edit, Trash2, Check, Undo2 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
interface HabitItemProps { interface HabitItemProps {
habit: Habit habit: Habit
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onComplete: () => void
onUndo: () => void
} }
export default function HabitItem({ habit, onEdit, onDelete, onComplete, onUndo }: HabitItemProps) { export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const today = getTodayInTimezone(settings.system.timezone) const today = getTodayInTimezone(settings.system.timezone)
const isCompletedToday = habit.completions?.includes(today) const isCompletedToday = habit.completions?.includes(today)
@@ -71,7 +71,7 @@ export default function HabitItem({ habit, onEdit, onDelete, onComplete, onUndo
<Button <Button
variant={isCompletedToday ? "secondary" : "default"} variant={isCompletedToday ? "secondary" : "default"}
size="sm" size="sm"
onClick={onComplete} onClick={async () => await completeHabit(habit)}
disabled={isCompletedToday} disabled={isCompletedToday}
> >
<Check className="h-4 w-4 mr-2" /> <Check className="h-4 w-4 mr-2" />
@@ -81,7 +81,7 @@ export default function HabitItem({ habit, onEdit, onDelete, onComplete, onUndo
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={onUndo} onClick={async () => await undoComplete(habit)}
> >
<Undo2 /> <Undo2 />
</Button> </Button>

View File

@@ -1,17 +1,22 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { Plus, ListTodo } from 'lucide-react' import { Plus, ListTodo } from 'lucide-react'
import { useAtom } from 'jotai'
import { habitsAtom, settingsAtom } from '@/lib/atoms'
import EmptyState from './EmptyState' import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import HabitItem from './HabitItem' import HabitItem from './HabitItem'
import AddEditHabitModal from './AddEditHabitModal' import AddEditHabitModal from './AddEditHabitModal'
import ConfirmDialog from './ConfirmDialog' import ConfirmDialog from './ConfirmDialog'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { useHabits } from '@/hooks/useHabits'
export default function HabitList() { export default function HabitList() {
const { habits, addHabit, editHabit, deleteHabit, completeHabit, undoComplete } = useHabits() const { saveHabit, deleteHabit } = useHabits()
const [habitsData, setHabitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [editingHabit, setEditingHabit] = useState<Habit | null>(null) const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({ const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
@@ -39,17 +44,15 @@ export default function HabitList() {
</div> </div>
) : ( ) : (
habits.map((habit) => ( habits.map((habit) => (
<HabitItem <HabitItem
key={habit.id} key={habit.id}
habit={habit} habit={habit}
onEdit={() => { onEdit={() => {
setEditingHabit(habit) setEditingHabit(habit)
setIsModalOpen(true) setIsModalOpen(true)
}} }}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })} onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
onComplete={() => completeHabit(habit)} />
onUndo={() => undoComplete(habit)}
/>
)) ))
)} )}
</div> </div>
@@ -60,11 +63,7 @@ export default function HabitList() {
setEditingHabit(null) setEditingHabit(null)
}} }}
onSave={async (habit) => { onSave={async (habit) => {
if (editingHabit) { await saveHabit({ ...habit, id: editingHabit?.id })
await editHabit({ ...habit, id: editingHabit.id })
} else {
await addHabit(habit)
}
setIsModalOpen(false) setIsModalOpen(false)
setEditingHabit(null) setEditingHabit(null)
}} }}
@@ -73,9 +72,9 @@ export default function HabitList() {
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmation.isOpen} isOpen={deleteConfirmation.isOpen}
onClose={() => setDeleteConfirmation({ isOpen: false, habitId: null })} onClose={() => setDeleteConfirmation({ isOpen: false, habitId: null })}
onConfirm={() => { onConfirm={async () => {
if (deleteConfirmation.habitId) { if (deleteConfirmation.habitId) {
deleteHabit(deleteConfirmation.habitId) await deleteHabit(deleteConfirmation.habitId)
} }
setDeleteConfirmation({ isOpen: false, habitId: null }) setDeleteConfirmation({ isOpen: false, habitId: null })
}} }}

View File

@@ -1,22 +1,12 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart } from 'lucide-react' import { BarChart } from 'lucide-react'
import { useEffect, useState } from 'react'
import { getTodayInTimezone } from '@/lib/utils' import { getTodayInTimezone } from '@/lib/utils'
import { loadHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms' import { habitsAtom, settingsAtom } from '@/lib/atoms'
export default function HabitOverview() { export default function HabitOverview() {
const [habits, setHabits] = useState<Habit[]>([]) const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
useEffect(() => {
const fetchHabits = async () => {
const data = await loadHabitsData()
setHabits(data.habits)
}
fetchHabits()
}, [])
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const today = getTodayInTimezone(settings.system.timezone) const today = getTodayInTimezone(settings.system.timezone)

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useWishlist } from '@/hooks/useWishlist'
import { Plus, Gift } from 'lucide-react' import { Plus, Gift } from 'lucide-react'
import EmptyState from './EmptyState' import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -8,16 +9,15 @@ import WishlistItem from './WishlistItem'
import AddEditWishlistItemModal from './AddEditWishlistItemModal' import AddEditWishlistItemModal from './AddEditWishlistItemModal'
import ConfirmDialog from './ConfirmDialog' import ConfirmDialog from './ConfirmDialog'
import { WishlistItemType } from '@/lib/types' import { WishlistItemType } from '@/lib/types'
import { useWishlist } from '@/hooks/useWishlist'
export default function WishlistManager() { export default function WishlistManager() {
const { const {
wishlistItems,
addWishlistItem, addWishlistItem,
editWishlistItem, editWishlistItem,
deleteWishlistItem, deleteWishlistItem,
redeemWishlistItem, redeemWishlistItem,
canRedeem canRedeem,
wishlistItems
} = useWishlist() } = useWishlist()
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null) const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null)

View File

@@ -1,16 +1,18 @@
'use client' 'use client'
import { settingsAtom } from "@/lib/atoms" import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom } from "@/lib/atoms"
import { useHydrateAtoms } from "jotai/utils" import { useHydrateAtoms } from "jotai/utils"
import { Settings } from "@/lib/types" import { JotaiHydrateInitialValues } from "@/lib/types"
export function JotaiHydrate({ export function JotaiHydrate({
children, children,
initialSettings initialValues
}: { }: { children: React.ReactNode, initialValues: JotaiHydrateInitialValues }) {
children: React.ReactNode useHydrateAtoms([
initialSettings: Settings [settingsAtom, initialValues.settings],
}) { [habitsAtom, initialValues.habits],
useHydrateAtoms([[settingsAtom, initialSettings]]) [coinsAtom, initialValues.coins],
[wishlistAtom, initialValues.wishlist]
])
return children return children
} }

View File

@@ -1,5 +1,5 @@
import Linkify0 from "linkify-react"; import Linkify0 from "linkify-react";
export default function Linkify({ children }: { children: React.ReactNode }) { export default function Linkify({ children }: { children: React.ReactNode }) {
return <Linkify0 options={{ className: "underline" }}>{children}</Linkify0>; return <Linkify0 options={{ className: "underline", target: "_blank" }}>{children}</Linkify0>;
} }

View File

@@ -1,28 +1,12 @@
import { useState, useEffect } from 'react' import { useAtom } from 'jotai'
import { loadCoinsData, addCoins, removeCoins } from '@/app/actions/data' import { coinsAtom } from '@/lib/atoms'
import { addCoins, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { CoinTransaction, TransactionType } from '@/lib/types'
export function useCoins() { export function useCoins() {
const [balance, setBalance] = useState(0) const [coins, setCoins] = useAtom(coinsAtom)
const [transactions, setTransactions] = useState<CoinTransaction[]>([])
useEffect(() => { const add = async (amount: number, description: string) => {
fetchCoins()
}, [])
const fetchCoins = async () => {
const data = await loadCoinsData()
setBalance(data.balance)
setTransactions(data.transactions || [])
}
const addAmount = async (
amount: number,
description: string,
type: TransactionType = 'MANUAL_ADJUSTMENT',
relatedItemId?: string
) => {
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
toast({ toast({
title: "Invalid amount", title: "Invalid amount",
@@ -31,19 +15,15 @@ export function useCoins() {
return null return null
} }
const data = await addCoins(amount, description, type, relatedItemId) const data = await addCoins(amount, description)
setBalance(data.balance) setCoins(data)
setTransactions(data.transactions) toast({ title: "Success", description: `Added ${amount} coins` })
return data return data
} }
const removeAmount = async ( const remove = async (amount: number, description: string) => {
amount: number, const numAmount = Math.abs(amount)
description: string, if (isNaN(numAmount) || numAmount <= 0) {
type: TransactionType = 'MANUAL_ADJUSTMENT',
relatedItemId?: string
) => {
if (isNaN(amount) || amount <= 0) {
toast({ toast({
title: "Invalid amount", title: "Invalid amount",
description: "Please enter a valid positive number" description: "Please enter a valid positive number"
@@ -51,17 +31,16 @@ export function useCoins() {
return null return null
} }
const data = await removeCoins(amount, description, type, relatedItemId) const data = await removeCoins(numAmount, description)
setBalance(data.balance) setCoins(data)
setTransactions(data.transactions) toast({ title: "Success", description: `Removed ${numAmount} coins` })
return data return data
} }
return { return {
balance, add,
transactions, remove,
addAmount, balance: coins.balance,
removeAmount, transactions: coins.transactions
fetchCoins
} }
} }

View File

@@ -1,69 +1,47 @@
import { useState, useEffect } from 'react' import { useAtom } from 'jotai'
import { loadHabitsData, saveHabitsData, addCoins, removeCoins } from '@/app/actions/data' import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
import { getNowInMilliseconds, getTodayInTimezone } from '@/lib/utils'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { Undo2 } from 'lucide-react' import { Undo2 } from 'lucide-react'
import { Habit } from '@/lib/types'
import { getNowInMilliseconds, getTodayInTimezone } from '@/lib/utils'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
export function useHabits() { export function useHabits() {
const [habits, setHabits] = useState<Habit[]>([]) const [habitsData, setHabitsData] = useAtom(habitsAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
useEffect(() => {
fetchHabits()
}, [])
const fetchHabits = async () => {
const data = await loadHabitsData()
setHabits(data.habits)
}
const addHabit = async (habit: Omit<Habit, 'id'>) => {
const newHabit = { ...habit, id: getNowInMilliseconds() }
const newHabits = [...habits, newHabit]
setHabits(newHabits)
await saveHabitsData({ habits: newHabits })
}
const editHabit = async (updatedHabit: Habit) => {
const newHabits = habits.map(habit =>
habit.id === updatedHabit.id ? updatedHabit : habit
)
setHabits(newHabits)
await saveHabitsData({ habits: newHabits })
}
const deleteHabit = async (id: string) => {
const newHabits = habits.filter(habit => habit.id !== id)
setHabits(newHabits)
await saveHabitsData({ habits: newHabits })
}
const completeHabit = async (habit: Habit) => { const completeHabit = async (habit: Habit) => {
const today = getTodayInTimezone(settings.system.timezone) const timezone = settings.system.timezone
const today = getTodayInTimezone(timezone)
if (!habit.completions.includes(today)) { if (!habit.completions.includes(today)) {
const updatedHabit = { const updatedHabit = {
...habit, ...habit,
completions: [...habit.completions, today] completions: [...habit.completions, today]
} }
const updatedHabits = habits.map(h => const updatedHabits = habitsData.habits.map(h =>
h.id === habit.id ? updatedHabit : h h.id === habit.id ? updatedHabit : h
) )
await saveHabitsData({ habits: updatedHabits }) await saveHabitsData({ habits: updatedHabits })
const coinsData = await addCoins(habit.coinReward, `Completed habit: ${habit.name}`, 'HABIT_COMPLETION', habit.id) setHabitsData({ habits: updatedHabits })
setHabits(updatedHabits) const coinsData = await addCoins(habit.coinReward, `Completed habit: ${habit.name}`, 'HABIT_COMPLETION', habit.id)
setCoins(coinsData)
toast({ toast({
title: "Habit completed!", title: "Habit completed!",
description: `You earned ${habit.coinReward} coins.`, description: `You earned ${habit.coinReward} coins.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(habit)}><Undo2 className="h-4 w-4" />Undo</ToastAction> action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(habit)}>
<Undo2 className="h-4 w-4" />Undo
</ToastAction>
}) })
return coinsData.balance return {
updatedHabits,
newBalance: coinsData.balance,
newTransactions: coinsData.transactions
}
} else { } else {
toast({ toast({
title: "Habit already completed", title: "Habit already completed",
@@ -75,34 +53,61 @@ export function useHabits() {
} }
const undoComplete = async (habit: Habit) => { const undoComplete = async (habit: Habit) => {
const today = getTodayInTimezone(settings.system.timezone) const timezone = settings.system.timezone
const today = getTodayInTimezone(timezone)
const updatedHabit = { const updatedHabit = {
...habit, ...habit,
completions: habit.completions.filter(date => date !== today) completions: habit.completions.filter(date => date !== today)
} }
const updatedHabits = habits.map(h => const updatedHabits = habitsData.habits.map(h =>
h.id === habit.id ? updatedHabit : h h.id === habit.id ? updatedHabit : h
) )
await saveHabitsData({ habits: updatedHabits }) await saveHabitsData({ habits: updatedHabits })
const coinsData = await removeCoins(habit.coinReward, `Undid habit completion: ${habit.name}`, 'HABIT_UNDO', habit.id) setHabitsData({ habits: updatedHabits })
setHabits(updatedHabits) const coinsData = await removeCoins(habit.coinReward, `Undid habit completion: ${habit.name}`, 'HABIT_UNDO', habit.id)
setCoins(coinsData)
toast({ toast({
title: "Completion undone", title: "Completion undone",
description: `${habit.coinReward} coins have been deducted.`, description: `${habit.coinReward} coins have been deducted.`,
action: <ToastAction altText="Redo" onClick={() => completeHabit(habit)}><Undo2 className="h-4 w-4" />Undo</ToastAction> action: <ToastAction altText="Redo" onClick={() => completeHabit(habit)}>
<Undo2 className="h-4 w-4" />Undo
</ToastAction>
}) })
return coinsData.balance return {
updatedHabits,
newBalance: coinsData.balance,
newTransactions: coinsData.transactions
}
}
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
const newHabit = {
...habit,
id: habit.id || getNowInMilliseconds().toString()
}
const updatedHabits = habit.id
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
: [...habitsData.habits, newHabit]
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
return updatedHabits
}
const deleteHabit = async (id: string) => {
const updatedHabits = habitsData.habits.filter(h => h.id !== id)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
return updatedHabits
} }
return { return {
habits,
addHabit,
editHabit,
deleteHabit,
completeHabit, completeHabit,
undoComplete undoComplete,
saveHabit,
deleteHabit
} }
} }

View File

@@ -1,52 +1,45 @@
import { useState, useEffect } from 'react' import { useAtom } from 'jotai'
import { loadWishlistItems, saveWishlistItems } from '@/app/actions/data' import { wishlistAtom, coinsAtom } from '@/lib/atoms'
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { WishlistItemType } from '@/lib/types' import { WishlistItemType } from '@/lib/types'
import { useCoins } from '@/hooks/useCoins'
import { celebrations } from '@/utils/celebrations' import { celebrations } from '@/utils/celebrations'
export function useWishlist() { export function useWishlist() {
const [wishlistItems, setWishlistItems] = useState<WishlistItemType[]>([]) const [wishlist, setWishlist] = useAtom(wishlistAtom)
const { balance, removeAmount } = useCoins() const [coins, setCoins] = useAtom(coinsAtom)
const balance = coins.balance
useEffect(() => {
fetchWishlistItems()
}, [])
const fetchWishlistItems = async () => {
const items = await loadWishlistItems()
setWishlistItems(items)
}
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => { const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
const newItem = { ...item, id: Date.now().toString() } const newItem = { ...item, id: Date.now().toString() }
const newItems = [...wishlistItems, newItem] const newItems = [...wishlist.items, newItem]
setWishlistItems(newItems) setWishlist({ items: newItems })
await saveWishlistItems(newItems) await saveWishlistItems(newItems)
} }
const editWishlistItem = async (updatedItem: WishlistItemType) => { const editWishlistItem = async (updatedItem: WishlistItemType) => {
const newItems = wishlistItems.map(item => const newItems = wishlist.items.map(item =>
item.id === updatedItem.id ? updatedItem : item item.id === updatedItem.id ? updatedItem : item
) )
setWishlistItems(newItems) setWishlist({ items: newItems })
await saveWishlistItems(newItems) await saveWishlistItems(newItems)
} }
const deleteWishlistItem = async (id: string) => { const deleteWishlistItem = async (id: string) => {
const newItems = wishlistItems.filter(item => item.id !== id) const newItems = wishlist.items.filter(item => item.id !== id)
setWishlistItems(newItems) setWishlist({ items: newItems })
await saveWishlistItems(newItems) await saveWishlistItems(newItems)
} }
const redeemWishlistItem = async (item: WishlistItemType) => { const redeemWishlistItem = async (item: WishlistItemType) => {
if (balance >= item.coinCost) { if (balance >= item.coinCost) {
await removeAmount( const data = await removeCoins(
item.coinCost, item.coinCost,
`Redeemed reward: ${item.name}`, `Redeemed reward: ${item.name}`,
'WISH_REDEMPTION', 'WISH_REDEMPTION',
item.id item.id
) )
setCoins(data)
// Randomly choose a celebration effect // Randomly choose a celebration effect
const celebrationEffects = [ const celebrationEffects = [
@@ -71,12 +64,14 @@ export function useWishlist() {
} }
} }
const canRedeem = (cost: number) => balance >= cost
return { return {
wishlistItems,
addWishlistItem, addWishlistItem,
editWishlistItem, editWishlistItem,
deleteWishlistItem, deleteWishlistItem,
redeemWishlistItem, redeemWishlistItem,
canRedeem: (cost: number) => balance >= cost canRedeem,
wishlistItems: wishlist.items
} }
} }

View File

@@ -1,4 +1,12 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { getDefaultSettings } from "./types"; import {
getDefaultSettings,
getDefaultHabitsData,
getDefaultCoinsData,
getDefaultWishlistData
} from "./types";
export const settingsAtom = atom(getDefaultSettings()) export const settingsAtom = atom(getDefaultSettings());
export const habitsAtom = atom(getDefaultHabitsData());
export const coinsAtom = atom(getDefaultCoinsData());
export const wishlistAtom = atom(getDefaultWishlistData());

View File

@@ -88,3 +88,10 @@ export interface Settings {
ui: UISettings; ui: UISettings;
system: SystemSettings; system: SystemSettings;
} }
export interface JotaiHydrateInitialValues {
settings: Settings;
coins: CoinsData;
habits: HabitsData;
wishlist: WishlistData;
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",