From cb02b3831c00efe01b445933f69bd82cdf4b993a Mon Sep 17 00:00:00 2001 From: Doh Date: Fri, 3 Jan 2025 20:50:54 -0500 Subject: [PATCH] Added jotai (#14) * Added jotai * cache settings by using jotai state * use hydrateAtom with SSR * remove useSettings * fix test --- CHANGELOG.md | 3 +++ app/actions/data.ts | 13 +++---------- app/layout.tsx | 15 +++++++++++++-- app/settings/page.tsx | 15 ++++++++++++--- components/ChangelogModal.tsx | 1 - components/CoinBalance.tsx | 5 +++-- components/CoinsManager.tsx | 7 ++++--- components/DailyOverview.tsx | 5 +++-- components/DynamicTime.tsx | 11 +++++++---- components/HabitCalendar.tsx | 15 +++++---------- components/HabitHeatmap.tsx | 5 +++-- components/HabitItem.tsx | 5 +++-- components/HabitOverview.tsx | 5 +++-- components/HabitStreak.tsx | 7 ++++--- components/jotai-hydrate.tsx | 16 ++++++++++++++++ components/jotai-providers.tsx | 11 +++++++++++ hooks/useHabits.tsx | 5 +++-- hooks/useSettings.ts | 23 ----------------------- lib/atoms.ts | 4 ++++ lib/utils.ts | 8 ++++---- package-lock.json | 21 +++++++++++++++++++++ package.json | 1 + 22 files changed, 126 insertions(+), 75 deletions(-) create mode 100644 components/jotai-hydrate.tsx create mode 100644 components/jotai-providers.tsx delete mode 100644 hooks/useSettings.ts create mode 100644 lib/atoms.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a738611..b6989b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,13 @@ - docker-compose.yaml - timezone settings +- use jotai for state management ### Fixed - completing habits now respect timezone settings +- coin and settings display now respect timezone settings +- performance improvements by caching settings ## Version 0.1.4 diff --git a/app/actions/data.ts b/app/actions/data.ts index 711c16c..3457fd8 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -11,7 +11,8 @@ import { WishlistData, Settings, DataType, - DATA_DEFAULTS + DATA_DEFAULTS, + getDefaultSettings } from '@/lib/types' import { d2t, getNow, getNowInMilliseconds } from '@/lib/utils'; @@ -121,15 +122,7 @@ export async function addCoins( } export async function loadSettings(): Promise { - const defaultSettings: Settings = { - ui: { - useNumberFormatting: true, - useGrouping: true, - }, - system: { - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone - } - } + const defaultSettings = getDefaultSettings() try { const data = await loadData('settings') diff --git a/app/layout.tsx b/app/layout.tsx index e30ca8a..cce2566 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,10 @@ import './globals.css' import { Inter } from 'next/font/google' import { DM_Sans } from 'next/font/google' 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) const inter = Inter({ subsets: ['latin'], @@ -21,15 +25,22 @@ export const metadata = { description: 'Track your habits and get rewarded', } -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode }) { + const initialSettings = await loadSettings() return ( - {children} + + + + {children} + + + diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 9f634eb..886fbb2 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -3,11 +3,20 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' -import { useSettings } from '@/hooks/useSettings' 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() { - const { settings, updateSettings } = useSettings() + const [settings, setSettings] = useAtom(settingsAtom) + + const updateSettings = async (newSettings: Settings) => { + await saveSettings(newSettings) + setSettings(newSettings) + } + if (!settings) return null @@ -89,7 +98,7 @@ export default function SettingsPage() { ))} - + diff --git a/components/ChangelogModal.tsx b/components/ChangelogModal.tsx index efddd68..2ef3523 100644 --- a/components/ChangelogModal.tsx +++ b/components/ChangelogModal.tsx @@ -17,7 +17,6 @@ export default function ChangelogModal({ isOpen, onClose }: ChangelogModalProps) if (isOpen) { const loadChangelog = async () => { const content = await getChangelog() - console.log(content) setChangelog(content) } loadChangelog() diff --git a/components/CoinBalance.tsx b/components/CoinBalance.tsx index 18576b9..cf3f514 100644 --- a/components/CoinBalance.tsx +++ b/components/CoinBalance.tsx @@ -1,10 +1,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Coins } from 'lucide-react' 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 }) { - const { settings } = useSettings() + const [settings] = useAtom(settingsAtom) return ( diff --git a/components/CoinsManager.tsx b/components/CoinsManager.tsx index 2a9d1e2..813ae64 100644 --- a/components/CoinsManager.tsx +++ b/components/CoinsManager.tsx @@ -1,7 +1,6 @@ 'use client' import { useState } from 'react' -import { useSettings } from '@/hooks/useSettings' import { t2d, d2s, getNow, isSameDate } from '@/lib/utils' import { Button } from '@/components/ui/button' 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 { useCoins } from '@/hooks/useCoins' import Link from 'next/link' +import { useAtom } from 'jotai' +import { settingsAtom } from '@/lib/atoms' export default function CoinsManager() { const { balance, transactions, addAmount, removeAmount } = useCoins() - const { settings } = useSettings() + const [settings] = useAtom(settingsAtom) const DEFAULT_AMOUNT = '0' const [amount, setAmount] = useState(DEFAULT_AMOUNT) @@ -212,7 +213,7 @@ export default function CoinsManager() {

- {d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }) })} + {d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}

habit.completions.includes(today) diff --git a/components/DynamicTime.tsx b/components/DynamicTime.tsx index 47be6b7..401a45d 100644 --- a/components/DynamicTime.tsx +++ b/components/DynamicTime.tsx @@ -2,14 +2,17 @@ import { useEffect, useState } from 'react' 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 { timezone: string } -export function DynamicTime({ timezone }: DynamicTimeProps) { - const [time, setTime] = useState(DateTime.now().setZone(timezone)) +export function DynamicTime() { + const [settings] = useAtom(settingsAtom) + const [time, setTime] = useState(getNow({ timezone: settings.system.timezone })) useEffect(() => { const timer = setInterval(() => { @@ -21,7 +24,7 @@ export function DynamicTime({ timezone }: DynamicTimeProps) { return (
- {d2s({ dateTime: time })} + {d2s({ dateTime: time, timezone: settings.system.timezone })}
) } diff --git a/components/HabitCalendar.tsx b/components/HabitCalendar.tsx index 1dfbc88..4072367 100644 --- a/components/HabitCalendar.tsx +++ b/components/HabitCalendar.tsx @@ -8,11 +8,12 @@ import { Badge } from '@/components/ui/badge' import { loadHabitsData } from '@/app/actions/data' import { Habit } from '@/lib/types' import { d2s, getNow } from '@/lib/utils' -import { useSettings } from '@/hooks/useSettings' +import { useAtom } from 'jotai' +import { settingsAtom } from '@/lib/atoms' import { DateTime } from 'luxon' export default function HabitCalendar() { - const { settings } = useSettings() + const [settings] = useAtom(settingsAtom) const [selectedDate, setSelectedDate] = useState(getNow({ timezone: settings.system.timezone })) const [habits, setHabits] = useState([]) @@ -20,12 +21,6 @@ export default function HabitCalendar() { fetchHabitsData() }, []) - // Update selectedDate when timezone changes - useEffect(() => { - const now = getNow({ timezone: settings.system.timezone }) - setSelectedDate(now) - }, [settings]) - const fetchHabitsData = async () => { const data = await loadHabitsData() setHabits(data.habits) @@ -50,7 +45,7 @@ export default function HabitCalendar() { e && setSelectedDate(DateTime.fromJSDate(e))} + // onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))} className="rounded-md border" modifiers={{ completed: (date) => getHabitsForDate(date).length > 0, @@ -65,7 +60,7 @@ export default function HabitCalendar() { {selectedDate ? ( - <>Habits for {d2s({ dateTime: selectedDate })} + <>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone })} ) : ( 'Select a date' )} diff --git a/components/HabitHeatmap.tsx b/components/HabitHeatmap.tsx index f1ad504..b7d05a3 100644 --- a/components/HabitHeatmap.tsx +++ b/components/HabitHeatmap.tsx @@ -3,7 +3,8 @@ import HeatMap from '@uiw/react-heat-map' import { Habit } from '@/lib/types' import { getNow } from '@/lib/utils' -import { useSettings } from '@/hooks/useSettings' +import { useAtom } from 'jotai' +import { settingsAtom } from '@/lib/atoms' interface HabitHeatmapProps { habits: Habit[] @@ -26,7 +27,7 @@ export default function HabitHeatmap({ habits }: HabitHeatmapProps) { count })) - const { settings } = useSettings() + const [settings] = useAtom(settingsAtom) // Get start date (30 days ago) const now = getNow({ timezone: settings.system.timezone }) diff --git a/components/HabitItem.tsx b/components/HabitItem.tsx index 2c0d242..622aa22 100644 --- a/components/HabitItem.tsx +++ b/components/HabitItem.tsx @@ -1,5 +1,6 @@ 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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -15,7 +16,7 @@ interface 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 isCompletedToday = habit.completions?.includes(today) const [isHighlighted, setIsHighlighted] = useState(false) diff --git a/components/HabitOverview.tsx b/components/HabitOverview.tsx index 96170d8..08d9396 100644 --- a/components/HabitOverview.tsx +++ b/components/HabitOverview.tsx @@ -1,10 +1,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { BarChart } from 'lucide-react' import { useEffect, useState } from 'react' -import { useSettings } from '@/hooks/useSettings' import { getTodayInTimezone } from '@/lib/utils' import { loadHabitsData } from '@/app/actions/data' import { Habit } from '@/lib/types' +import { useAtom } from 'jotai' +import { settingsAtom } from '@/lib/atoms' export default function HabitOverview() { const [habits, setHabits] = useState([]) @@ -17,7 +18,7 @@ export default function HabitOverview() { fetchHabits() }, []) - const { settings } = useSettings() + const [settings] = useAtom(settingsAtom) const today = getTodayInTimezone(settings.system.timezone) const completedToday = habits.filter(habit => diff --git a/components/HabitStreak.tsx b/components/HabitStreak.tsx index d138145..7ba1082 100644 --- a/components/HabitStreak.tsx +++ b/components/HabitStreak.tsx @@ -2,20 +2,21 @@ import { Habit } from '@/lib/types' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { useSettings } from '@/hooks/useSettings' import { d2s, getNow } from '@/lib/utils' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { useAtom } from 'jotai' +import { settingsAtom } from '@/lib/atoms' interface HabitStreakProps { habits: Habit[] } export default function HabitStreak({ habits }: HabitStreakProps) { - const { settings } = useSettings() + const [settings] = useAtom(settingsAtom) // Get the last 7 days of data const dates = Array.from({ length: 7 }, (_, i) => { 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() const completions = dates.map(date => { diff --git a/components/jotai-hydrate.tsx b/components/jotai-hydrate.tsx new file mode 100644 index 0000000..d9e96cb --- /dev/null +++ b/components/jotai-hydrate.tsx @@ -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 +} diff --git a/components/jotai-providers.tsx b/components/jotai-providers.tsx new file mode 100644 index 0000000..f054b32 --- /dev/null +++ b/components/jotai-providers.tsx @@ -0,0 +1,11 @@ +'use client' + +import { Provider } from 'jotai' + +export const JotaiProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} diff --git a/hooks/useHabits.tsx b/hooks/useHabits.tsx index 89e42b1..565f6e7 100644 --- a/hooks/useHabits.tsx +++ b/hooks/useHabits.tsx @@ -1,15 +1,16 @@ import { useState, useEffect } from 'react' -import { useSettings } from '@/hooks/useSettings' import { loadHabitsData, saveHabitsData, addCoins, removeCoins } from '@/app/actions/data' import { toast } from '@/hooks/use-toast' import { ToastAction } from '@/components/ui/toast' 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() { const [habits, setHabits] = useState([]) - const { settings } = useSettings() + const [settings] = useAtom(settingsAtom) useEffect(() => { fetchHabits() diff --git a/hooks/useSettings.ts b/hooks/useSettings.ts deleted file mode 100644 index 8c7d420..0000000 --- a/hooks/useSettings.ts +++ /dev/null @@ -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(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, - } -} diff --git a/lib/atoms.ts b/lib/atoms.ts new file mode 100644 index 0000000..ad01145 --- /dev/null +++ b/lib/atoms.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; +import { getDefaultSettings } from "./types"; + +export const settingsAtom = atom(getDefaultSettings()) diff --git a/lib/utils.ts b/lib/utils.ts index 726bf6c..f55faca 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -9,7 +9,7 @@ export function cn(...inputs: ClassValue[]) { // get today's date string for timezone export function getTodayInTimezone(timezone: string): string { 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 @@ -34,11 +34,11 @@ export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezo } // 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) { - 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 diff --git a/package-lock.json b/package-lock.json index fddc551..dd72af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "jotai": "^2.8.0", "js-confetti": "^0.12.0", "lucide-react": "^0.469.0", "luxon": "^3.5.0", @@ -5068,6 +5069,26 @@ "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": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/js-confetti/-/js-confetti-0.12.0.tgz", diff --git a/package.json b/package.json index 43150da..b21fe5e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "jotai": "^2.8.0", "js-confetti": "^0.12.0", "lucide-react": "^0.469.0", "luxon": "^3.5.0",