diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6d4bf5c7..8eefcd81 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ // List of extensions which should be recommended for users of this workspace. "recommendations": [ "esbenp.prettier-vscode", - "bradlc.vscode-tailwindcss" + "bradlc.vscode-tailwindcss", + "lokalise.i18n-ally" ] -} \ No newline at end of file +} diff --git a/app/components/mydevices/dt/columns.tsx b/app/components/mydevices/dt/columns.tsx index 20c09175..d19b48e9 100644 --- a/app/components/mydevices/dt/columns.tsx +++ b/app/components/mydevices/dt/columns.tsx @@ -126,7 +126,7 @@ export function getColumns( {t('edit')} - + {t('data_upload')} diff --git a/app/i18next-options.ts b/app/i18next-options.ts index c31dfd37..ace7c1af 100644 --- a/app/i18next-options.ts +++ b/app/i18next-options.ts @@ -1,13 +1,13 @@ -export const supportedLanguages = ["en", "de"] as const; +export const supportedLanguages = ['en', 'de'] as const export default { - // This is the list of languages your application supports - supportedLngs: supportedLanguages, - // This is the language you want to use in case - // if the user language is not in the supportedLngs - fallbackLng: "en", - // The default namespace of i18next is "translation", but you can customize it here - defaultNS: "common", - // Disabling suspense is recommended - react: { useSuspense: false }, -}; + // This is the list of languages your application supports + supportedLngs: supportedLanguages, + // This is the language you want to use in case + // if the user language is not in the supportedLngs + fallbackLng: 'en', + // The default namespace of i18next is "translation", but you can customize it here + defaultNS: 'common', + // Disabling suspense is recommended + react: { useSuspense: false }, +} diff --git a/app/lib/statistics-service.server.ts b/app/lib/statistics-service.server.ts index 0db1c4e6..6a88225e 100644 --- a/app/lib/statistics-service.server.ts +++ b/app/lib/statistics-service.server.ts @@ -1,45 +1,45 @@ -import { and, count, gt, lt, sql } from "drizzle-orm"; -import { drizzleClient } from "~/db.server"; -import { measurement } from "~/schema"; +import { and, count, gt, lt, sql } from 'drizzle-orm' +import { drizzleClient } from '~/db.server' +import { measurement } from '~/schema' /** * * @param humanReadable */ export const getStatistics = async (humanReadable: boolean = false) => { - const rowCount = async (tableName: string) => { - const [count] = await drizzleClient.execute( - sql`SELECT * FROM approximate_row_count(${tableName});`, - ); - return Number(count.approximate_row_count); - }; - const rowCountTimeBucket = async ( - table: any, // Ideally, this should be the actual table type, but TypeScript can't infer it generically - timeColumn: any, - intervalMillis: number, - ) => { - const result = await drizzleClient - .select({ count: count() }) - .from(table) - .where( - and( - gt(table[timeColumn], new Date(Date.now() - intervalMillis)), - lt(table[timeColumn], new Date()), - ), - ); - const [rowCount] = result; - return Number(rowCount.count); - }; + const rowCount = async (tableName: string) => { + const [count] = await drizzleClient.execute( + sql`SELECT * FROM approximate_row_count(${tableName});`, + ) + return Number(count.approximate_row_count) + } + const rowCountTimeBucket = async ( + table: any, // Ideally, this should be the actual table type, but TypeScript can't infer it generically + timeColumn: any, + intervalMillis: number, + ) => { + const result = await drizzleClient + .select({ count: count() }) + .from(table) + .where( + and( + gt(table[timeColumn], new Date(Date.now() - intervalMillis)), + lt(table[timeColumn], new Date()), + ), + ) + const [rowCount] = result + return Number(rowCount.count) + } - const results = await Promise.all([ - rowCount("device"), - rowCount("sensor"), - rowCountTimeBucket(measurement, "time", 60000), - ]); + const results = await Promise.all([ + rowCount('device'), + rowCount('sensor'), + rowCountTimeBucket(measurement, 'time', 60000), + ]) - if (humanReadable) { - const format = new Intl.NumberFormat(undefined, { notation: "compact" }); - return results.map((r) => format.format(r)); - } - return results; -}; + if (humanReadable) { + const format = new Intl.NumberFormat(undefined, { notation: 'compact' }) + return results.map((r) => format.format(r)) + } + return results +} diff --git a/app/routes/device.$deviceId.dataupload.tsx b/app/routes/device.$deviceId.dataupload.tsx index 6b8c5ed3..33159021 100644 --- a/app/routes/device.$deviceId.dataupload.tsx +++ b/app/routes/device.$deviceId.dataupload.tsx @@ -1,6 +1,15 @@ import { ArrowLeft, Upload } from 'lucide-react' -import { useState } from 'react' -import { redirect, Form, Link, type LoaderFunctionArgs } from 'react-router' +import { useRef, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { + redirect, + Form, + Link, + type LoaderFunctionArgs, + type ActionFunctionArgs, + useNavigation, + useParams, +} from 'react-router' import ErrorMessage from '~/components/error-message' import { NavBar } from '~/components/nav-bar' import { Button } from '~/components/ui/button' @@ -14,9 +23,11 @@ import { SelectValue, } from '~/components/ui/select' import { Textarea } from '~/components/ui/textarea' +import { postNewMeasurements } from '~/lib/measurement-service.server' +import { findAccessToken } from '~/models/device.server' +import { StandardResponse } from '~/utils/response-utils' import { getUserId } from '~/utils/session.server' -//***************************************************** export async function loader({ request }: LoaderFunctionArgs) { //* if user is not logged in, redirect to home const userId = await getUserId(request) @@ -25,18 +36,75 @@ export async function loader({ request }: LoaderFunctionArgs) { return {} } -//***************************************************** -export async function action() { - return {} +export async function action({ + request, + params, +}: ActionFunctionArgs): Promise { + const method = request.method + if (method !== 'POST') { + return StandardResponse.methodNotAllowed( + 'Endpoint only supports POST requests', + ) + } + + const deviceId = params['deviceId'] + if (deviceId === undefined) + return StandardResponse.badRequest('deviceId must be set but is undefined') + + const formData = await request.formData() + const contentType = formData.get('contentType') + if (contentType === null || typeof contentType !== 'string') + return StandardResponse.badRequest( + 'contentType is either not set or has a wrong type', + ) + + const measurementData = formData.get('measurement-data') + if (measurementData === null || typeof measurementData !== 'string') + return StandardResponse.badRequest( + 'measurement data is either not set or has a wrong type', + ) + const deviceApiKey = await findAccessToken(deviceId) + + try { + await postNewMeasurements(deviceId, measurementData, { + contentType, + luftdaten: false, + hackair: false, + authorization: deviceApiKey?.token ?? '', + }) + + return StandardResponse.ok({}) + } catch (err: any) { + // Handle different error types + if (err.name === 'UnauthorizedError') + return StandardResponse.unauthorized(err.message) + + if (err.name === 'ModelError' && err.type === 'UnprocessableEntityError') + return StandardResponse.unprocessableContent(err.message) + + if (err.name === 'UnsupportedMediaTypeError') + return StandardResponse.unsupportedMediaType(err.message) + + return StandardResponse.internalServerError( + err.message || 'An unexpected error occurred', + ) + } } -//********************************** -export default function DataUpload() { +export default function DataUpload({ actionData }: any) { + // actionData needs to be any type until we migrate to Route.ActionArgs + // Max number of characters to show for data + // thats input to the text area + const DATA_CUTOFF_CHARS = 3_000 + const { t } = useTranslation(['csv-upload', 'common']) + const params = useParams() + const nav = useNavigation() + const textareaRef = useRef(null) const [measurementData, setMeasurementData] = useState('') - const [dataFormat, setDataFormat] = useState('CSV') + const [dataFormat, setDataFormat] = useState('text/csv') return ( -
+
@@ -44,8 +112,10 @@ export default function DataUpload() { @@ -53,22 +123,38 @@ export default function DataUpload() {
-

Manual Data Upload

+

+ {t('dataUploadHeading')} +

+ + {actionData && Object.keys(actionData).length === 0 && ( +
+ {t('successMessage')} +
+ )} + {actionData && Object.keys(actionData).includes('error') && ( +
+ {t('errorMessage', { message: actionData.error })} +
+ )} +

- Here you can upload measurements for this senseBox. This can - be of use for senseBoxes that log their measurements to an - SD card when no means of direct communication to - openSenseMap are available. Either select a file, or copy - the data into the text field. Accepted data formats are - described{' '} - - here - - . + + Here you can upload measurements for this senseBox. This + can be of use for senseBoxes that log their measurements + to an SD card when no means of direct communication to + openSenseMap are available. Either select a file, or copy + the data into the text field. Accepted data formats are + described{' '} + + here + + . +

@@ -76,12 +162,16 @@ export default function DataUpload() {