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 (
+
+ );
+}
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
+
+
+
+ );
+}