Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/useAppUpdateAvailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,7 +16,7 @@ export function useAppUpdateAvailable() {
}, [updateServiceWorker]);

useEffect(() => {
if (!needsUpdate || toastShownRef.current) {
if (!needsUpdate || toastShownRef.current || dontToast) {
return;
}

Expand All @@ -27,5 +27,7 @@ export function useAppUpdateAvailable() {
persistent: true,
action: { label: 'Reload', onClick: applyUpdate }
});
}, [needsUpdate, applyUpdate, showToast]);
}, [needsUpdate, applyUpdate, showToast, dontToast]);

return { needsUpdate, applyUpdate };
Comment thread
vanister marked this conversation as resolved.
}
20 changes: 20 additions & 0 deletions src/pages/settings/AboutSectionBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Icon } from '../../components/Icon';

export function AboutSectionBody() {
return (
<>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon name="zap" size="md" className="text-primary" />
</div>
<div>
<p className="text-base font-semibold text-body">EV Charge Tracker</p>
<p className="text-sm text-body-secondary">Version 1.0.0</p>
</div>
</div>
<p className="text-sm text-body-secondary">
Track your electric vehicle charging sessions, costs, and usage across all your locations.
</p>
</>
);
}
67 changes: 67 additions & 0 deletions src/pages/settings/LocationsSection.tsx
Original file line number Diff line number Diff line change
@@ -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<Location[]>([]);

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 ? (
<EmptyState
icon="map-pin"
title="No locations yet"
message="Add a location to track where you charge."
actionLabel="Add Location"
onAction={() => navigate('/settings/locations/add')}
/>
) : (
<div>
<ItemListButton
label="Add location"
onClick={() => navigate('/settings/locations/add')}
className="mb-3"
/>
<div className="space-y-3">
{locations.map((location) => (
<LocationItem key={location.id} location={location} onDelete={handleDelete} />
))}
</div>
</div>
);
}
156 changes: 17 additions & 139 deletions src/pages/settings/Settings.tsx
Comment thread
vanister marked this conversation as resolved.
Comment thread
vanister marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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<Location[]>([]);

useEffect(() => {
const loadLocations = async () => {
const result = await getLocationList();

if (result.success) {
setLocations(result.data);
}
};

loadLocations();
}, [getLocationList]);
const [state, setState] = useImmerState<SettingsState>(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 (
<div className="bg-background px-4 py-6">
<div className="max-w-2xl mx-auto space-y-8">
<section>
<SectionHeader title="Locations" />
<SettingsSection title="Locations">
<LocationsSectionBody />
</SettingsSection>

{locations.length === 0 ? (
<EmptyState
icon="map-pin"
title="No locations yet"
message="Add a location to track where you charge."
actionLabel="Add Location"
onAction={() => navigate('/settings/locations/add')}
/>
) : (
<div>
<ItemListButton
label="Add location"
onClick={() => navigate('/settings/locations/add')}
className="mb-3"
/>
<div className="space-y-3">
{locations.map((location) => (
<LocationItem key={location.id} location={location} onDelete={handleDelete} />
))}
</div>
</div>
)}
</section>
<SettingsSection title="Storage" cardClassName="space-y-3">
<StorageSectionBody />
</SettingsSection>

<section>
<SectionHeader title="Storage" />
<div className="p-4 bg-surface border border-default rounded-lg space-y-3">
{state.storageUsed !== null && state.storageQuota !== null ? (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-body-secondary">Used</span>
<span className="text-body font-medium">
{formatBytes(state.storageUsed)} of {formatBytes(state.storageQuota)}
</span>
</div>
{storagePercent !== null && (
<div className="h-2 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${storagePercent}%` }}
/>
</div>
)}
</>
) : (
<p className="text-sm text-body-secondary">Storage information unavailable</p>
)}
</div>
</section>
<SettingsSection title="Update">
<UpdateSectionBody />
</SettingsSection>

<section>
<SectionHeader title="About" />
<div className="p-4 bg-surface border border-default rounded-lg">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon name="zap" size="md" className="text-primary" />
</div>
<div>
<p className="text-base font-semibold text-body">EV Charge Tracker</p>
<p className="text-sm text-body-secondary">Version 1.0.0</p>
</div>
</div>
<p className="text-sm text-body-secondary">
Track your electric vehicle charging sessions, costs, and usage across all your locations.
</p>
</div>
</section>
<SettingsSection title="About">
<AboutSectionBody />
</SettingsSection>
</div>
</div>
);
Expand Down
20 changes: 20 additions & 0 deletions src/pages/settings/SettingsSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<SectionHeader title={title} />
<div className={clsx('p-4 bg-surface border border-default rounded-lg', cardClassName)}>
{children}
</div>
</section>
);
}
57 changes: 57 additions & 0 deletions src/pages/settings/StorageSectionBody.tsx
Original file line number Diff line number Diff line change
@@ -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<StorageState>(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 <p className="text-sm text-body-secondary">Storage information unavailable</p>;
}

const storagePercent =
state.storageQuota > 0
? Math.min(100, (state.storageUsed / state.storageQuota) * 100)
: null;

return (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-body-secondary">Used</span>
<span className="text-body font-medium">
{formatBytes(state.storageUsed)} of {formatBytes(state.storageQuota)}
</span>
</div>
{storagePercent != null && (
<div className="h-2 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${storagePercent}%` }}
/>
</div>
)}
</>
);
}
Loading