Added jotai (#14)

* Added jotai

* cache settings by using jotai state

* use hydrateAtom with SSR

* remove useSettings

* fix test
This commit is contained in:
Doh
2025-01-03 20:50:54 -05:00
committed by GitHub
parent e06e6260ef
commit cb02b3831c
22 changed files with 126 additions and 75 deletions

View File

@@ -6,10 +6,13 @@
- docker-compose.yaml - docker-compose.yaml
- timezone settings - timezone settings
- use jotai for state management
### Fixed ### Fixed
- completing habits now respect timezone settings - completing habits now respect timezone settings
- coin and settings display now respect timezone settings
- performance improvements by caching settings
## Version 0.1.4 ## Version 0.1.4

View File

@@ -11,7 +11,8 @@ import {
WishlistData, WishlistData,
Settings, Settings,
DataType, DataType,
DATA_DEFAULTS DATA_DEFAULTS,
getDefaultSettings
} from '@/lib/types' } from '@/lib/types'
import { d2t, getNow, getNowInMilliseconds } from '@/lib/utils'; import { d2t, getNow, getNowInMilliseconds } from '@/lib/utils';
@@ -121,15 +122,7 @@ export async function addCoins(
} }
export async function loadSettings(): Promise<Settings> { export async function loadSettings(): Promise<Settings> {
const defaultSettings: Settings = { const defaultSettings = getDefaultSettings()
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
}
}
try { try {
const data = await loadData<Settings>('settings') const data = await loadData<Settings>('settings')

View File

@@ -2,6 +2,10 @@ import './globals.css'
import { Inter } from 'next/font/google' import { Inter } from 'next/font/google'
import { DM_Sans } from 'next/font/google' import { DM_Sans } from 'next/font/google'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { JotaiProvider } from '@/components/jotai-providers'
import { Suspense } from 'react'
import { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings } from './actions/data'
// Inter (clean, modern, excellent readability) // Inter (clean, modern, excellent readability)
const inter = Inter({ const inter = Inter({
subsets: ['latin'], subsets: ['latin'],
@@ -21,15 +25,22 @@ export const metadata = {
description: 'Track your habits and get rewarded', description: 'Track your habits and get rewarded',
} }
export default function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const initialSettings = await loadSettings()
return ( return (
<html lang="en"> <html lang="en">
<body className={activeFont.className}> <body className={activeFont.className}>
{children} <JotaiProvider>
<Suspense fallback="loading">
<JotaiHydrate initialSettings={initialSettings}>
{children}
</JotaiHydrate>
</Suspense>
</JotaiProvider>
<Toaster /> <Toaster />
</body> </body>
</html> </html>

View File

@@ -3,11 +3,20 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useSettings } from '@/hooks/useSettings'
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR' import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { Settings } from '@/lib/types'
import { saveSettings } from '../actions/data'
export default function SettingsPage() { export default function SettingsPage() {
const { settings, updateSettings } = useSettings() const [settings, setSettings] = useAtom(settingsAtom)
const updateSettings = async (newSettings: Settings) => {
await saveSettings(newSettings)
setSettings(newSettings)
}
if (!settings) return null if (!settings) return null
@@ -89,7 +98,7 @@ export default function SettingsPage() {
</option> </option>
))} ))}
</select> </select>
<DynamicTimeNoSSR timezone={settings.system.timezone} /> <DynamicTimeNoSSR />
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -17,7 +17,6 @@ export default function ChangelogModal({ isOpen, onClose }: ChangelogModalProps)
if (isOpen) { if (isOpen) {
const loadChangelog = async () => { const loadChangelog = async () => {
const content = await getChangelog() const content = await getChangelog()
console.log(content)
setChangelog(content) setChangelog(content)
} }
loadChangelog() loadChangelog()

View File

@@ -1,10 +1,11 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Coins } from 'lucide-react' import { Coins } from 'lucide-react'
import { formatNumber } from '@/lib/utils/formatNumber' import { formatNumber } from '@/lib/utils/formatNumber'
import { useSettings } from '@/hooks/useSettings' import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
export default function CoinBalance({ coinBalance }: { coinBalance: number }) { export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
const { settings } = useSettings() const [settings] = useAtom(settingsAtom)
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useSettings } from '@/hooks/useSettings'
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils' import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { formatNumber } from '@/lib/utils/formatNumber' import { formatNumber } from '@/lib/utils/formatNumber'
@@ -12,10 +11,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { useCoins } from '@/hooks/useCoins' import { useCoins } from '@/hooks/useCoins'
import Link from 'next/link' import Link from 'next/link'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
export default function CoinsManager() { export default function CoinsManager() {
const { balance, transactions, addAmount, removeAmount } = useCoins() const { balance, transactions, addAmount, removeAmount } = useCoins()
const { settings } = useSettings() const [settings] = useAtom(settingsAtom)
const DEFAULT_AMOUNT = '0' const DEFAULT_AMOUNT = '0'
const [amount, setAmount] = useState(DEFAULT_AMOUNT) const [amount, setAmount] = useState(DEFAULT_AMOUNT)
@@ -212,7 +213,7 @@ export default function CoinsManager() {
</span> </span>
</div> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }) })} {d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}
</p> </p>
</div> </div>
<span <span

View File

@@ -1,7 +1,8 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react' import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
import { useSettings } from '@/hooks/useSettings' import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone } from '@/lib/utils' import { getTodayInTimezone } from '@/lib/utils'
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'
@@ -24,7 +25,7 @@ export default function DailyOverview({
onComplete, onComplete,
onUndo onUndo
}: UpcomingItemsProps) { }: UpcomingItemsProps) {
const { settings } = useSettings() const [settings] = useAtom(settingsAtom)
const today = getTodayInTimezone(settings.system.timezone) const today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = habits.filter(habit => const todayCompletions = habits.filter(habit =>
habit.completions.includes(today) habit.completions.includes(today)

View File

@@ -2,14 +2,17 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { d2s } from '@/lib/utils' import { d2s, getNow } from '@/lib/utils'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
interface DynamicTimeProps { interface DynamicTimeProps {
timezone: string timezone: string
} }
export function DynamicTime({ timezone }: DynamicTimeProps) { export function DynamicTime() {
const [time, setTime] = useState(DateTime.now().setZone(timezone)) const [settings] = useAtom(settingsAtom)
const [time, setTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
@@ -21,7 +24,7 @@ export function DynamicTime({ timezone }: DynamicTimeProps) {
return ( return (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{d2s({ dateTime: time })} {d2s({ dateTime: time, timezone: settings.system.timezone })}
</div> </div>
) )
} }

View File

@@ -8,11 +8,12 @@ import { Badge } from '@/components/ui/badge'
import { loadHabitsData } from '@/app/actions/data' import { loadHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { d2s, getNow } from '@/lib/utils' import { d2s, getNow } from '@/lib/utils'
import { useSettings } from '@/hooks/useSettings' import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
export default function HabitCalendar() { export default function HabitCalendar() {
const { settings } = useSettings() 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 [habits, setHabits] = useState<Habit[]>([])
@@ -20,12 +21,6 @@ export default function HabitCalendar() {
fetchHabitsData() fetchHabitsData()
}, []) }, [])
// Update selectedDate when timezone changes
useEffect(() => {
const now = getNow({ timezone: settings.system.timezone })
setSelectedDate(now)
}, [settings])
const fetchHabitsData = async () => { const fetchHabitsData = async () => {
const data = await loadHabitsData() const data = await loadHabitsData()
setHabits(data.habits) setHabits(data.habits)
@@ -50,7 +45,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,
@@ -65,7 +60,7 @@ export default function HabitCalendar() {
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{selectedDate ? ( {selectedDate ? (
<>Habits for {d2s({ dateTime: selectedDate })}</> <>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone })}</>
) : ( ) : (
'Select a date' 'Select a date'
)} )}

View File

@@ -3,7 +3,8 @@
import HeatMap from '@uiw/react-heat-map' import HeatMap from '@uiw/react-heat-map'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { getNow } from '@/lib/utils' import { getNow } from '@/lib/utils'
import { useSettings } from '@/hooks/useSettings' import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
interface HabitHeatmapProps { interface HabitHeatmapProps {
habits: Habit[] habits: Habit[]
@@ -26,7 +27,7 @@ export default function HabitHeatmap({ habits }: HabitHeatmapProps) {
count count
})) }))
const { settings } = useSettings() const [settings] = useAtom(settingsAtom)
// Get start date (30 days ago) // Get start date (30 days ago)
const now = getNow({ timezone: settings.system.timezone }) const now = getNow({ timezone: settings.system.timezone })

View File

@@ -1,5 +1,6 @@
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { useSettings } from '@/hooks/useSettings' import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone } from '@/lib/utils' import { getTodayInTimezone } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -15,7 +16,7 @@ interface HabitItemProps {
} }
export default function HabitItem({ habit, onEdit, onDelete, onComplete, onUndo }: HabitItemProps) { export default function HabitItem({ habit, onEdit, onDelete, onComplete, onUndo }: HabitItemProps) {
const { settings } = useSettings() 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)
const [isHighlighted, setIsHighlighted] = useState(false) const [isHighlighted, setIsHighlighted] = useState(false)

View File

@@ -1,10 +1,11 @@
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 { useEffect, useState } from 'react'
import { useSettings } from '@/hooks/useSettings'
import { getTodayInTimezone } from '@/lib/utils' import { getTodayInTimezone } from '@/lib/utils'
import { loadHabitsData } from '@/app/actions/data' import { loadHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
export default function HabitOverview() { export default function HabitOverview() {
const [habits, setHabits] = useState<Habit[]>([]) const [habits, setHabits] = useState<Habit[]>([])
@@ -17,7 +18,7 @@ export default function HabitOverview() {
fetchHabits() fetchHabits()
}, []) }, [])
const { settings } = useSettings() const [settings] = useAtom(settingsAtom)
const today = getTodayInTimezone(settings.system.timezone) const today = getTodayInTimezone(settings.system.timezone)
const completedToday = habits.filter(habit => const completedToday = habits.filter(habit =>

View File

@@ -2,20 +2,21 @@
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useSettings } from '@/hooks/useSettings'
import { d2s, getNow } from '@/lib/utils' import { d2s, getNow } from '@/lib/utils'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
interface HabitStreakProps { interface HabitStreakProps {
habits: Habit[] habits: Habit[]
} }
export default function HabitStreak({ habits }: HabitStreakProps) { export default function HabitStreak({ habits }: HabitStreakProps) {
const { settings } = useSettings() const [settings] = useAtom(settingsAtom)
// Get the last 7 days of data // Get the last 7 days of data
const dates = Array.from({ length: 7 }, (_, i) => { const dates = Array.from({ length: 7 }, (_, i) => {
const d = getNow({ timezone: settings.system.timezone }); const d = getNow({ timezone: settings.system.timezone });
return d2s({ dateTime: d.minus({ days: i }), format: 'yyyy-MM-dd' }); return d2s({ dateTime: d.minus({ days: i }), format: 'yyyy-MM-dd', timezone: settings.system.timezone });
}).reverse() }).reverse()
const completions = dates.map(date => { const completions = dates.map(date => {

View File

@@ -0,0 +1,16 @@
'use client'
import { settingsAtom } from "@/lib/atoms"
import { useHydrateAtoms } from "jotai/utils"
import { Settings } from "@/lib/types"
export function JotaiHydrate({
children,
initialSettings
}: {
children: React.ReactNode
initialSettings: Settings
}) {
useHydrateAtoms([[settingsAtom, initialSettings]])
return children
}

View File

@@ -0,0 +1,11 @@
'use client'
import { Provider } from 'jotai'
export const JotaiProvider = ({ children }: { children: React.ReactNode }) => {
return (
<Provider>
{children}
</Provider>
)
}

View File

@@ -1,15 +1,16 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSettings } from '@/hooks/useSettings'
import { loadHabitsData, saveHabitsData, addCoins, removeCoins } from '@/app/actions/data' import { loadHabitsData, saveHabitsData, addCoins, removeCoins } from '@/app/actions/data'
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 { Habit } from '@/lib/types'
import { getNowInMilliseconds, getTodayInTimezone } from '@/lib/utils' 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 [habits, setHabits] = useState<Habit[]>([])
const { settings } = useSettings() const [settings] = useAtom(settingsAtom)
useEffect(() => { useEffect(() => {
fetchHabits() fetchHabits()

View File

@@ -1,23 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { getDefaultSettings, Settings } from '@/lib/types'
import { loadSettings, saveSettings } from '@/app/actions/data'
export function useSettings() {
const [settings, setSettings] = useState<Settings>(getDefaultSettings()) // TODO: do we need to initialize the settings here?
useEffect(() => {
loadSettings().then(setSettings)
}, [])
const updateSettings = async (newSettings: Settings) => {
await saveSettings(newSettings)
setSettings(newSettings)
}
return {
settings,
updateSettings,
}
}

4
lib/atoms.ts Normal file
View File

@@ -0,0 +1,4 @@
import { atom } from "jotai";
import { getDefaultSettings } from "./types";
export const settingsAtom = atom(getDefaultSettings())

View File

@@ -9,7 +9,7 @@ export function cn(...inputs: ClassValue[]) {
// get today's date string for timezone // get today's date string for timezone
export function getTodayInTimezone(timezone: string): string { export function getTodayInTimezone(timezone: string): string {
const now = getNow({ timezone }); const now = getNow({ timezone });
return d2s({ dateTime: now, format: 'yyyy-MM-dd' }); return d2s({ dateTime: now, format: 'yyyy-MM-dd', timezone });
} }
// get datetime object of now // get datetime object of now
@@ -34,11 +34,11 @@ export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezo
} }
// convert datetime object to string, mostly for display // convert datetime object to string, mostly for display
export function d2s({ dateTime, format }: { dateTime: DateTime, format?: string }) { export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format?: string, timezone: string }) {
if (format) { if (format) {
return dateTime.toFormat(format); return dateTime.setZone(timezone).toFormat(format);
} }
return dateTime.toLocaleString(DateTime.DATETIME_MED); return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
} }
// convert datetime object to date string, mostly for display // convert datetime object to date string, mostly for display

21
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"jotai": "^2.8.0",
"js-confetti": "^0.12.0", "js-confetti": "^0.12.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
@@ -5068,6 +5069,26 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/jotai": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.8.0.tgz",
"integrity": "sha512-yZNMC36FdLOksOr8qga0yLf14miCJlEThlp5DeFJNnqzm2+ZG7wLcJzoOyij5K6U6Xlc5ljQqPDlJRgqW0Y18g==",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=17.0.0",
"react": ">=17.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/js-confetti": { "node_modules/js-confetti": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/js-confetti/-/js-confetti-0.12.0.tgz", "resolved": "https://registry.npmjs.org/js-confetti/-/js-confetti-0.12.0.tgz",

View File

@@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"jotai": "^2.8.0",
"js-confetti": "^0.12.0", "js-confetti": "^0.12.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",