diff --git a/package-lock.json b/package-lock.json index dd167a9..a7aac99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@cloudflare/vite-plugin": "^1.25.6", "@eslint/js": "^9.39.1", "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.10.1", + "@types/node": "^24.11.0", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -3951,9 +3951,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", - "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "version": "24.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", + "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 541983f..74ea20c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@cloudflare/vite-plugin": "^1.25.6", "@eslint/js": "^9.39.1", "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.10.1", + "@types/node": "^24.11.0", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/src/hooks/useAppUpdateAvailable.ts b/src/hooks/useAppUpdateAvailable.ts index 2f0c814..f755c4e 100644 --- a/src/hooks/useAppUpdateAvailable.ts +++ b/src/hooks/useAppUpdateAvailable.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useRegisterSW } from 'virtual:pwa-register/react'; import { useToast } from './useToast'; -export function useAppUpdateAvailable() { +export function useAppUpdateAvailable(dontToast = false) { const { needRefresh: [needsUpdate], updateServiceWorker @@ -16,7 +16,7 @@ export function useAppUpdateAvailable() { }, [updateServiceWorker]); useEffect(() => { - if (!needsUpdate || toastShownRef.current) { + if (!needsUpdate || toastShownRef.current || dontToast) { return; } @@ -27,5 +27,7 @@ export function useAppUpdateAvailable() { persistent: true, action: { label: 'Reload', onClick: applyUpdate } }); - }, [needsUpdate, applyUpdate, showToast]); + }, [needsUpdate, applyUpdate, showToast, dontToast]); + + return { needsUpdate, applyUpdate }; } diff --git a/src/pages/settings/AboutSectionBody.tsx b/src/pages/settings/AboutSectionBody.tsx new file mode 100644 index 0000000..9a0be74 --- /dev/null +++ b/src/pages/settings/AboutSectionBody.tsx @@ -0,0 +1,20 @@ +import { Icon } from '../../components/Icon'; + +export function AboutSectionBody() { + return ( + <> +
+
+ +
+
+

EV Charge Tracker

+

Version 1.0.0

+
+
+

+ Track your electric vehicle charging sessions, costs, and usage across all your locations. +

+ + ); +} diff --git a/src/pages/settings/LocationsSection.tsx b/src/pages/settings/LocationsSection.tsx new file mode 100644 index 0000000..5f0c364 --- /dev/null +++ b/src/pages/settings/LocationsSection.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { Location } from '../../data/data-types'; +import { useLocations } from '../../hooks/useLocations'; +import { useToast } from '../../hooks/useToast'; +import { ItemListButton } from '../../components/ItemListButton'; +import { EmptyState } from '../../components/EmptyState'; +import { LocationItem } from './LocationItem'; + +export function LocationsSectionBody() { + const navigate = useNavigate(); + const { getLocationList, deleteLocation } = useLocations(); + const { showToast } = useToast(); + const [locations, setLocations] = useState([]); + + useEffect(() => { + const loadLocations = async () => { + const result = await getLocationList(); + + if (result.success) { + setLocations(result.data); + } + }; + + loadLocations(); + }, [getLocationList]); + + const handleDelete = async (id: string) => { + const confirmed = confirm('Are you sure you want to delete this location?'); + + if (!confirmed) { + return; + } + + const result = await deleteLocation(id); + + if (!result.success) { + showToast({ message: `Failed to delete location: ${result.error}`, variant: 'error', persistent: true }); + return; + } + + setLocations((prev) => prev.filter((l) => l.id !== id)); + }; + + return locations.length === 0 ? ( + navigate('/settings/locations/add')} + /> + ) : ( +
+ navigate('/settings/locations/add')} + className="mb-3" + /> +
+ {locations.map((location) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index 3167d2a..58bbc8b 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -1,153 +1,31 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import type { Location } from '../../data/data-types'; -import { useLocations } from '../../hooks/useLocations'; import { usePageTitle } from '../../hooks/usePageTitle'; -import { useImmerState } from '../../hooks/useImmerState'; -import { ItemListButton } from '../../components/ItemListButton'; -import { Icon } from '../../components/Icon'; -import { SectionHeader } from '../../components/SectionHeader'; -import { EmptyState } from '../../components/EmptyState'; -import { LocationItem } from './LocationItem'; -import { formatBytes } from '../../utilities/formatUtils'; - -type SettingsState = { - storageUsed: number | null; - storageQuota: number | null; -}; - -const DEFAULT_STATE: SettingsState = { - storageUsed: null, - storageQuota: null -}; +import { SettingsSection } from './SettingsSection'; +import { LocationsSectionBody } from './LocationsSection'; +import { StorageSectionBody } from './StorageSectionBody'; +import { UpdateSectionBody } from './UpdateSectionBody'; +import { AboutSectionBody } from './AboutSectionBody'; export function Settings() { usePageTitle('Settings'); - const navigate = useNavigate(); - const { getLocationList, deleteLocation } = useLocations(); - const [locations, setLocations] = useState([]); - - useEffect(() => { - const loadLocations = async () => { - const result = await getLocationList(); - - if (result.success) { - setLocations(result.data); - } - }; - - loadLocations(); - }, [getLocationList]); - const [state, setState] = useImmerState(DEFAULT_STATE); - - useEffect(() => { - const loadStorageEstimate = async () => { - const estimate = await navigator.storage?.estimate?.(); - setState((draft) => { - draft.storageUsed = estimate?.usage ?? null; - draft.storageQuota = estimate?.quota ?? null; - }); - }; - - loadStorageEstimate(); - }, [setState]); - - const handleDelete = async (id: string) => { - const confirmed = window.confirm('Are you sure you want to delete this location?'); - - if (!confirmed) { - return; - } - - const result = await deleteLocation(id); - - if (!result.success) { - alert(`Failed to delete location: ${result.error}`); - return; - } - - setLocations((prev) => prev.filter((l) => l.id !== id)); - }; - - const storagePercent = - state.storageUsed !== null && state.storageQuota !== null && state.storageQuota > 0 - ? Math.min(100, (state.storageUsed / state.storageQuota) * 100) - : null; - return (
-
- + + + - {locations.length === 0 ? ( - navigate('/settings/locations/add')} - /> - ) : ( -
- navigate('/settings/locations/add')} - className="mb-3" - /> -
- {locations.map((location) => ( - - ))} -
-
- )} -
+ + + -
- -
- {state.storageUsed !== null && state.storageQuota !== null ? ( - <> -
- Used - - {formatBytes(state.storageUsed)} of {formatBytes(state.storageQuota)} - -
- {storagePercent !== null && ( -
-
-
- )} - - ) : ( -

Storage information unavailable

- )} -
-
+ + + -
- -
-
-
- -
-
-

EV Charge Tracker

-

Version 1.0.0

-
-
-

- Track your electric vehicle charging sessions, costs, and usage across all your locations. -

-
-
+ + +
); diff --git a/src/pages/settings/SettingsSection.tsx b/src/pages/settings/SettingsSection.tsx new file mode 100644 index 0000000..c050821 --- /dev/null +++ b/src/pages/settings/SettingsSection.tsx @@ -0,0 +1,20 @@ +import { clsx } from 'clsx'; +import type { ReactNode } from 'react'; +import { SectionHeader } from '../../components/SectionHeader'; + +type SettingsSectionProps = { + title: string; + children: ReactNode; + cardClassName?: string; +}; + +export function SettingsSection({ title, children, cardClassName }: SettingsSectionProps) { + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/src/pages/settings/StorageSectionBody.tsx b/src/pages/settings/StorageSectionBody.tsx new file mode 100644 index 0000000..b6f7844 --- /dev/null +++ b/src/pages/settings/StorageSectionBody.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { useImmerState } from '../../hooks/useImmerState'; +import { formatBytes } from '../../utilities/formatUtils'; + +type StorageState = { + storageUsed: number | null; + storageQuota: number | null; +}; + +const DEFAULT_STATE: StorageState = { + storageUsed: null, + storageQuota: null +}; + +export function StorageSectionBody() { + const [state, setState] = useImmerState(DEFAULT_STATE); + + useEffect(() => { + const loadStorageEstimate = async () => { + const estimate = await navigator.storage?.estimate?.(); + setState((draft) => { + draft.storageUsed = estimate?.usage ?? null; + draft.storageQuota = estimate?.quota ?? null; + }); + }; + + loadStorageEstimate(); + }, [setState]); + + if (state.storageUsed == null || state.storageQuota == null) { + return

Storage information unavailable

; + } + + const storagePercent = + state.storageQuota > 0 + ? Math.min(100, (state.storageUsed / state.storageQuota) * 100) + : null; + + return ( + <> +
+ Used + + {formatBytes(state.storageUsed)} of {formatBytes(state.storageQuota)} + +
+ {storagePercent != null && ( +
+
+
+ )} + + ); +} diff --git a/src/pages/settings/UpdateSectionBody.tsx b/src/pages/settings/UpdateSectionBody.tsx new file mode 100644 index 0000000..e515b1a --- /dev/null +++ b/src/pages/settings/UpdateSectionBody.tsx @@ -0,0 +1,33 @@ +import { useAppUpdateAvailable } from '../../hooks/useAppUpdateAvailable'; +import { Button } from '../../components/Button'; +import { Icon } from '../../components/Icon'; + +export function UpdateSectionBody() { + // dontToast=true prevents the toast from showing while the user is already on this page + const { needsUpdate, applyUpdate } = useAppUpdateAvailable(true); + + if (!needsUpdate) { + return ( +
+
+ +
+

App is up to date

+
+ ); + } + + return ( +
+
+
+ +
+

A new version is available

+
+ +
+ ); +}