From 55ceaac8a076d2d251cb5778b353091adb070cd0 Mon Sep 17 00:00:00 2001 From: JerryVincent Date: Wed, 25 Mar 2026 16:27:48 +0100 Subject: [PATCH] feat: add bulk delete all measurements for a device Previously, clearing a device's measurement history required deleting each sensor's data individually. Users can now delete all measurements across all sensors at once from the device settings sidebar, with password confirmation and feedback for both success and empty states. --- app/models/measurement.server.ts | 12 ++ ...ice.$deviceId.edit.delete-measurements.tsx | 151 ++++++++++++++++++ app/routes/device.$deviceId.edit.tsx | 6 + public/locales/de/delete-measurements.json | 7 + public/locales/en/delete-measurements.json | 7 + 5 files changed, 183 insertions(+) create mode 100644 app/routes/device.$deviceId.edit.delete-measurements.tsx create mode 100644 public/locales/de/delete-measurements.json create mode 100644 public/locales/en/delete-measurements.json diff --git a/app/models/measurement.server.ts b/app/models/measurement.server.ts index 26c2eb7cc..b65e2866b 100644 --- a/app/models/measurement.server.ts +++ b/app/models/measurement.server.ts @@ -9,6 +9,7 @@ import { measurements1hourView, measurements1monthView, measurements1yearView, + sensor, } from '~/schema' import { type MinimalDevice, @@ -287,3 +288,14 @@ export async function deleteMeasurementsForTime(date: Date) { .delete(measurement) .where(eq(measurement.time, date)) } + +export async function deleteMeasurementsForDevice(deviceId: string) { + const sensorIds = drizzleClient + .select({ id: sensor.id }) + .from(sensor) + .where(eq(sensor.deviceId, deviceId)) + + return await drizzleClient + .delete(measurement) + .where(inArray(measurement.sensorId, sensorIds)) +} diff --git a/app/routes/device.$deviceId.edit.delete-measurements.tsx b/app/routes/device.$deviceId.edit.delete-measurements.tsx new file mode 100644 index 000000000..681237ec1 --- /dev/null +++ b/app/routes/device.$deviceId.edit.delete-measurements.tsx @@ -0,0 +1,151 @@ +import * as React from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + Form, + data, + redirect, + useActionData, + useLoaderData, +} from 'react-router' +import invariant from 'tiny-invariant' +import { Button } from '~/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '~/components/ui/card' +import { Input } from '~/components/ui/input' +import { Label } from '~/components/ui/label' +import { getUserDevice } from '~/models/device.server' +import { deleteMeasurementsForDevice } from '~/models/measurement.server' +import { verifyLogin } from '~/models/user.server' +import { getUserEmail, getUserId } from '~/utils/session.server' + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await getUserId(request) + if (!userId) return redirect('/') + + const deviceId = params.deviceId + invariant(typeof deviceId === 'string', 'Device id not found.') + + const device = await getUserDevice({ id: deviceId, userId: userId }) + if (!device) return redirect('/profile/me') + + return data({ device }) +} + +export async function action({ request, params }: ActionFunctionArgs) { + const userId = await getUserId(request) + if (!userId) return redirect('/') + + const deviceId = params.deviceId + invariant(typeof deviceId === 'string', 'Device id not found.') + + const device = await getUserDevice({ id: deviceId, userId: userId }) + if (!device) return redirect('/profile/me') + + const formData = await request.formData() + const passwordConfirm = formData.get('passwordConfirm') + invariant(typeof passwordConfirm === 'string', 'password must be a string') + + const userEmail = await getUserEmail(request) + invariant(typeof userEmail === 'string', 'email not found') + + const user = await verifyLogin(userEmail, passwordConfirm) + if (!user) { + return data( + { + success: false, + noMeasurements: false, + errors: { passwordConfirm: 'Invalid password' } as { passwordConfirm: string } | null, + }, + { status: 400 }, + ) + } + + const result = await deleteMeasurementsForDevice(deviceId) + + return data({ + success: true, + noMeasurements: result.count === 0, + errors: null as { passwordConfirm: string } | null, + }) +} + +export default function DeleteMeasurementsPage() { + const { device } = useLoaderData() + const actionData = useActionData() + const passwordRef = React.useRef(null) + const [password, setPassword] = React.useState('') + + const { t } = useTranslation('delete-measurements') + + React.useEffect(() => { + if (actionData?.errors?.passwordConfirm) { + passwordRef.current?.focus() + } + if (actionData?.success) { + setPassword('') + } + }, [actionData]) + + return ( +
+ + + + {t('delete_measurements')} + + + }} + /> + + + + + {actionData?.success && !actionData.noMeasurements && ( +
+ {t('delete_success')} +
+ )} + + {actionData?.success && actionData.noMeasurements && ( +
+ {t('no_measurements')} +
+ )} + +
+ + setPassword(e.target.value)} + required + /> + {actionData?.errors?.passwordConfirm && ( +
+ {actionData.errors.passwordConfirm} +
+ )} +
+ + +
+
+
+ ) +} diff --git a/app/routes/device.$deviceId.edit.tsx b/app/routes/device.$deviceId.edit.tsx index 12c819626..ddaa5553c 100644 --- a/app/routes/device.$deviceId.edit.tsx +++ b/app/routes/device.$deviceId.edit.tsx @@ -10,6 +10,7 @@ import { Cpu, ArrowLeft, NotepadText, + Trash2, } from 'lucide-react' import { useState } from 'react' import { @@ -90,6 +91,11 @@ export default function EditBox() { href: `/device/${deviceId}/edit/transfer`, icon: ArrowRightLeft, }, + { + title: 'Delete Measurements', + href: `/device/${deviceId}/edit/delete-measurements`, + icon: Trash2, + }, ] return ( diff --git a/public/locales/de/delete-measurements.json b/public/locales/de/delete-measurements.json new file mode 100644 index 000000000..3e68ef0df --- /dev/null +++ b/public/locales/de/delete-measurements.json @@ -0,0 +1,7 @@ +{ + "delete_measurements": "Alle Messungen löschen", + "confirm_permanent_deletion": "Dadurch werden alle Messungen für alle Sensoren von {{device}} dauerhaft gelöscht. Das Gerät und seine Sensoren bleiben erhalten. Bitte bestätigen Sie mit Ihrem Passwort.", + "password": "Passwort", + "delete_success": "Alle Messungen wurden erfolgreich gelöscht.", + "no_measurements": "Dieses Gerät hat keine Messungen zum Löschen." +} diff --git a/public/locales/en/delete-measurements.json b/public/locales/en/delete-measurements.json new file mode 100644 index 000000000..7a398e47d --- /dev/null +++ b/public/locales/en/delete-measurements.json @@ -0,0 +1,7 @@ +{ + "delete_measurements": "Delete all measurements", + "confirm_permanent_deletion": "This will permanently delete all measurements for all sensors of {{device}}. The device and its sensors will remain. Please confirm with your password.", + "password": "Password", + "delete_success": "All measurements have been successfully deleted.", + "no_measurements": "This device has no measurements to delete." +}