From b6bcb7564b42cb1cda839c348163cef9da07c422 Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Thu, 27 Aug 2026 11:02:54 +0200 Subject: [PATCH 1/4] feat(count-aggregator-api): add raw-values OpenAPI, client, and helpers --- .changeset/count-aggregator-api-raw-values.md | 5 + packages/count-aggregator-api/README.md | 1 + .../openapi/count-aggregator.openapi.json | 184 ++++++++++++++++++ .../count-aggregator-api/src/contract.test.ts | 38 ++-- .../src/fixtures/raw-values-map.json | 16 ++ .../src/generated/client.ts | 114 +++++++++++ .../count-aggregator-api/src/helpers.test.ts | 24 +++ packages/count-aggregator-api/src/helpers.ts | 28 +++ packages/count-aggregator-api/src/index.ts | 8 + .../count-aggregator-api/src/lib/urls.test.ts | 28 +++ packages/count-aggregator-api/src/lib/urls.ts | 33 ++++ packages/count-aggregator-api/src/types.ts | 3 + 12 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 .changeset/count-aggregator-api-raw-values.md create mode 100644 packages/count-aggregator-api/src/fixtures/raw-values-map.json diff --git a/.changeset/count-aggregator-api-raw-values.md b/.changeset/count-aggregator-api-raw-values.md new file mode 100644 index 00000000..103e420d --- /dev/null +++ b/.changeset/count-aggregator-api-raw-values.md @@ -0,0 +1,5 @@ +--- +"@mapsight/count-aggregator-api": minor +--- + +Add `GET /{type}/raw-values` to the OpenAPI contract, typed client, and URL helpers. diff --git a/packages/count-aggregator-api/README.md b/packages/count-aggregator-api/README.md index 5fd28d5a..211cbd46 100644 --- a/packages/count-aggregator-api/README.md +++ b/packages/count-aggregator-api/README.md @@ -70,6 +70,7 @@ Use these helpers for JSON API calls: | `listStationTypes` | `GET /station-types` | | `listStations` | `GET /:type/stations` | | `getValues` | `GET /:type/values/:from/:to/:resolution` | +| `getRawValues` | `GET /:type/raw-values` | | `getLastValues` | `GET /:type/last-values/:resolution` | | `getStationLastValues` | `GET /:type/:stationId/last-values/:resolution` | | `getStationSums` | `GET /:type/:stationId/sums` | diff --git a/packages/count-aggregator-api/openapi/count-aggregator.openapi.json b/packages/count-aggregator-api/openapi/count-aggregator.openapi.json index 4bef4a86..fad63c83 100644 --- a/packages/count-aggregator-api/openapi/count-aggregator.openapi.json +++ b/packages/count-aggregator-api/openapi/count-aggregator.openapi.json @@ -532,6 +532,141 @@ } } }, + "/{type}/raw-values": { + "get": { + "operationId": "count-aggregator.public.type.raw-values", + "tags": ["Typed stations"], + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "description": "Station type identifier. See `GET /station-types` for currently available types.", + "schema": { + "type": "string", + "enum": [ + "airQualityCO", + "airQualityIndex", + "airQualityNO2", + "airQualityNO2Index", + "airQualityO3", + "airQualityO3Index", + "airQualityPM10", + "airQualityPM10Index", + "airQualityPM25", + "airQualitySO2", + "airQualityStation", + "bicycleSensorTotal", + "peopleCount", + "waterLevelStation", + "waterLevelSurface", + "waterLevelUnderground", + "waterTemp", + "weatherAirPressure", + "weatherHumidity", + "weatherLightingDistance", + "weatherLightnings", + "weatherRain", + "weatherStation", + "weatherSun", + "weatherTemp", + "weatherVaporPressure", + "weatherWindDirection", + "weatherWindSpeed", + "weatherWindSpeedMax" + ] + }, + "example": "peopleCount" + }, + { + "name": "stationIds", + "in": "query", + "required": true, + "description": "Comma-separated MSP station IDs.", + "schema": {}, + "example": "75,15" + }, + { + "name": "from", + "in": "query", + "description": "Start of an optional query window in local time (`Y-m-d` or `Y-m-d H:i:s`, `T` separator accepted). Must be used together with `to`. When omitted, returns the latest records per station.", + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}([ T]\\d{2}:\\d{2}:\\d{2})?$" + }, + "example": "2026-06-01T00:00:00" + }, + { + "name": "to", + "in": "query", + "description": "End of an optional query window in local time (`Y-m-d` or `Y-m-d H:i:s`, `T` separator accepted). Must be used together with `from`.", + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}([ T]\\d{2}:\\d{2}:\\d{2})?$" + }, + "example": "2026-06-01T12:00:00" + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of exact telemetry records per station. Defaults to 50. Maximum 500.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + "example": 50 + }, + { + "name": "order", + "in": "query", + "description": "Sort order of records by datetime. Defaults to `desc` (newest first).", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "example": "desc" + }, + { + "name": "format", + "in": "query", + "description": "Response serialization format. Omit this parameter or use `json` for JSON (default). Use `csv` to download multi-station raw telemetry as semicolon-separated CSV.", + "schema": { + "type": "string", + "enum": ["json", "csv"] + }, + "example": "json" + } + ], + "responses": { + "200": { + "description": "Exact stored telemetry records keyed by MSP station id. No aggregation, no quality flag until a column exists.", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "150": { + "id": 150, + "stationId": "138969", + "values": [ + { + "datetime": "2026-06-01 12:34:56", + "value": 67.25 + } + ] + } + }, + "additionalProperties": { + "$ref": "#/components/schemas/RawValuesResponse" + } + } + } + } + } + } + } + }, "/{type}/values": { "get": { "operationId": "count-aggregator.public.type.values.query", @@ -1958,6 +2093,55 @@ "title": "DataValuePoint", "required": ["datetime"] }, + "RawValuePoint": { + "type": "object", + "example": { + "datetime": "2026-06-01 12:34:56", + "value": 67.25 + }, + "properties": { + "datetime": { + "type": "string", + "description": "Local station datetime in `Y-m-d H:i:s` format (no timezone offset).", + "pattern": "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$" + }, + "value": { + "type": "number", + "description": "Exact stored numeric measurement. No aggregation." + } + }, + "title": "RawValuePoint", + "required": ["datetime", "value"] + }, + "RawValuesResponse": { + "type": "object", + "example": { + "id": 150, + "stationId": "138969", + "values": [ + { + "datetime": "2026-06-01 12:34:56", + "value": 67.25 + } + ] + }, + "properties": { + "id": { + "type": "integer" + }, + "stationId": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RawValuePoint" + } + } + }, + "required": ["id", "stationId", "values"], + "title": "RawValuesResponse" + }, "DetectedDataProblem": { "type": "object", "example": { diff --git a/packages/count-aggregator-api/src/contract.test.ts b/packages/count-aggregator-api/src/contract.test.ts index 1989c3eb..e6919495 100644 --- a/packages/count-aggregator-api/src/contract.test.ts +++ b/packages/count-aggregator-api/src/contract.test.ts @@ -1,21 +1,9 @@ -import {readFileSync} from "node:fs"; -import path from "node:path"; -import {fileURLToPath} from "node:url"; - import {describe, expect, it} from "vitest"; +import rawOpenApiDocument from "../openapi/count-aggregator.openapi.json"; import {schemas} from "./generated/client.js"; -const packageRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); -const openApiDocument = JSON.parse( - readFileSync( - path.join(packageRoot, "openapi/count-aggregator.openapi.json"), - "utf8", - ), -) as { +const openApiDocument = rawOpenApiDocument as { paths: Record; servers?: unknown; components: { @@ -85,6 +73,28 @@ describe("OpenAPI contract shape", () => { expect(parameterNames).not.toContain("originIds"); }); + it("documents raw-values route", () => { + const operation = ( + openApiDocument.paths["/{type}/raw-values"] as + {get?: {parameters?: Array<{name: string}>}} | undefined + )?.get; + + const parameterNames = + operation?.parameters?.map((parameter) => parameter.name) ?? []; + expect(parameterNames).toEqual( + expect.arrayContaining([ + "stationIds", + "from", + "to", + "limit", + "order", + "format", + ]), + ); + expect(parameterNames).not.toContain("resolution"); + expect(parameterNames).not.toContain("metrics"); + }); + it("documents datetime query values route", () => { const operation = ( openApiDocument.paths["/{type}/values"] as diff --git a/packages/count-aggregator-api/src/fixtures/raw-values-map.json b/packages/count-aggregator-api/src/fixtures/raw-values-map.json new file mode 100644 index 00000000..c7b29248 --- /dev/null +++ b/packages/count-aggregator-api/src/fixtures/raw-values-map.json @@ -0,0 +1,16 @@ +{ + "150": { + "id": 150, + "stationId": "138969", + "values": [ + { + "datetime": "2026-06-01 12:34:56", + "value": 67.25 + }, + { + "datetime": "2026-06-01 12:29:56", + "value": 64.5 + } + ] + } +} diff --git a/packages/count-aggregator-api/src/generated/client.ts b/packages/count-aggregator-api/src/generated/client.ts index 7a26634c..77ef9201 100644 --- a/packages/count-aggregator-api/src/generated/client.ts +++ b/packages/count-aggregator-api/src/generated/client.ts @@ -1,6 +1,23 @@ import { z } from "zod"; import { createLenientStationTypeListResponseSchema } from "../lib/lenient-station-type-list.js"; +type RawValuesResponse = { + id: number; + stationId: string; + values: Array; +}; +type RawValuePoint = { + /** + * Local station datetime in `Y-m-d H:i:s` format (no timezone offset). + * + * @pattern ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$ + */ + datetime: string; + /** + * Exact stored numeric measurement. No aggregation. + */ + value: number; +}; type StationGeoJsonFeature = { type: string; geometry: StationGeoJsonPointGeometry; @@ -914,6 +931,21 @@ const TimeSeriesResponse: z.ZodType = z }) .strict() .passthrough(); +const RawValuePoint: z.ZodType = z + .object({ + datetime: z.string().regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/), + value: z.number(), + }) + .strict() + .passthrough(); +const RawValuesResponse: z.ZodType = z + .object({ + id: z.number().int(), + stationId: z.string(), + values: z.array(RawValuePoint), + }) + .strict() + .passthrough(); const StationOverviewResponse: z.ZodType = z .object({ stationId: z.number().int(), @@ -950,6 +982,8 @@ export const schemas = { StationListResponse, DataValuePoint, TimeSeriesResponse, + RawValuePoint, + RawValuesResponse, StationOverviewResponse, DetectedDataProblem, GeoJsonFeatureCollection, @@ -1636,6 +1670,86 @@ export const endpoints = [ ], response: DetectedDataProblem, }, + { + method: "get", + path: "/:type/raw-values", + alias: "count-aggregator.public.type.raw-values", + requestFormat: "json", + parameters: [ + { + name: "type", + type: "Path", + schema: z.enum([ + "airQualityCO", + "airQualityIndex", + "airQualityNO2", + "airQualityNO2Index", + "airQualityO3", + "airQualityO3Index", + "airQualityPM10", + "airQualityPM10Index", + "airQualityPM25", + "airQualitySO2", + "airQualityStation", + "bicycleSensorTotal", + "peopleCount", + "waterLevelStation", + "waterLevelSurface", + "waterLevelUnderground", + "waterTemp", + "weatherAirPressure", + "weatherHumidity", + "weatherLightingDistance", + "weatherLightnings", + "weatherRain", + "weatherStation", + "weatherSun", + "weatherTemp", + "weatherVaporPressure", + "weatherWindDirection", + "weatherWindSpeed", + "weatherWindSpeedMax", + ]), + }, + { + name: "stationIds", + type: "Query", + schema: z.unknown(), + }, + { + name: "from", + type: "Query", + schema: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?$/) + .optional(), + }, + { + name: "to", + type: "Query", + schema: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?$/) + .optional(), + }, + { + name: "limit", + type: "Query", + schema: z.number().int().gte(1).lte(500).optional(), + }, + { + name: "order", + type: "Query", + schema: z.enum(["asc", "desc"]).optional(), + }, + { + name: "format", + type: "Query", + schema: z.enum(["json", "csv"]).optional(), + }, + ], + response: z.record(z.string(), RawValuesResponse), + }, { method: "get", path: "/:type/stations", diff --git a/packages/count-aggregator-api/src/helpers.test.ts b/packages/count-aggregator-api/src/helpers.test.ts index 871ccd91..07bc77e1 100644 --- a/packages/count-aggregator-api/src/helpers.test.ts +++ b/packages/count-aggregator-api/src/helpers.test.ts @@ -2,11 +2,13 @@ import {afterEach, describe, expect, it, vi} from "vitest"; import {createCountAggregatorClient} from "./client.js"; import lastValuesMapFixture from "./fixtures/last-values-map.json"; +import rawValuesMapFixture from "./fixtures/raw-values-map.json"; import stationListFixture from "./fixtures/station-list.json"; import stationSumsFixture from "./fixtures/station-sums.json"; import valuesMapFixture from "./fixtures/values-map.json"; import { getLastValues, + getRawValues, getStationLastValues, getStationSums, getValues, @@ -184,6 +186,28 @@ describe("typed endpoint helpers", () => { }); }); + it("gets raw values with optional range filters", async () => { + const fetchFn = createMockFetch((url) => { + expect(url).toBe( + `${baseUrl}/waterLevelSurface/raw-values?stationIds=140&from=2026-06-01+00%3A00%3A00&to=2026-06-01+12%3A00%3A00&limit=50&order=desc`, + ); + return rawValuesMapFixture; + }); + + const client = createCountAggregatorClient(baseUrl, {fetch: fetchFn}); + const result = await getRawValues(client, { + type: "waterLevelSurface", + stationIds: [140], + from: "2026-06-01 00:00:00", + to: "2026-06-01 12:00:00", + limit: 50, + order: "desc", + }); + + expect(result["150"]?.id).toBe(150); + expect(result["150"]?.values[0]?.value).toBe(67.25); + }); + it("gets last values", async () => { const fetchFn = createMockFetch((url) => { expect(url).toBe( diff --git a/packages/count-aggregator-api/src/helpers.ts b/packages/count-aggregator-api/src/helpers.ts index 9e725129..5ebd9448 100644 --- a/packages/count-aggregator-api/src/helpers.ts +++ b/packages/count-aggregator-api/src/helpers.ts @@ -1,6 +1,7 @@ import type {CountAggregatorClient} from "./client.js"; import type { BucketMetric, + RawValuesMapResponse, Resolution, StationListResponse, StationOverviewResponse, @@ -52,6 +53,15 @@ export interface StationLastValuesRequest { metrics?: readonly BucketMetric[]; } +export interface RawValuesRequest { + type: StationType; + stationIds: readonly number[]; + from?: string; + to?: string; + limit?: number; + order?: "asc" | "desc"; +} + function joinStationIds(stationIds: readonly number[]): string { return stationIds.join(","); } @@ -119,6 +129,24 @@ export function getValuesQuery( }); } +export function getRawValues( + client: CountAggregatorClient, + request: RawValuesRequest, +): Promise { + return client["count-aggregator.public.type.raw-values"]({ + params: { + type: request.type, + }, + queries: { + stationIds: joinStationIds(request.stationIds), + from: request.from, + to: request.to, + limit: request.limit, + order: request.order, + }, + }); +} + export function getLastValues( client: CountAggregatorClient, request: LastValuesRequest, diff --git a/packages/count-aggregator-api/src/index.ts b/packages/count-aggregator-api/src/index.ts index 740221eb..5fda109d 100644 --- a/packages/count-aggregator-api/src/index.ts +++ b/packages/count-aggregator-api/src/index.ts @@ -10,6 +10,7 @@ export { } from "./lib/fetch-client.js"; export { getLastValues, + getRawValues, getStationLastValues, getStationSums, getValues, @@ -18,6 +19,7 @@ export { listStationTypes, type LastValuesRequest, type ListStationsOptions, + type RawValuesRequest, type StationLastValuesRequest, type ValuesQueryRequest, type ValuesRequest, @@ -41,6 +43,8 @@ export { buildMultipleLastValuesUrl, buildMultipleValuesQueryUrl, buildMultipleValuesUrl, + buildRawValuesCsvExportUrl, + buildRawValuesUrl, buildSingleStationLastValuesUrl, buildSingleStationValuesUrl, buildStationSumsUrl, @@ -50,6 +54,7 @@ export { type MultipleLastValuesRequest, type MultipleValuesQueryRequest, type MultipleValuesRequest, + type MultipleRawValuesRequest, type SingleStationLastValuesRequest, type SingleStationValuesRequest, } from "./lib/urls.js"; @@ -62,6 +67,9 @@ export type { LastValuesAnchor, LocalDateTime, ProblemsListResponse, + RawValuePoint, + RawValuesMapResponse, + RawValuesResponse, Resolution, ResponseFormat, StationListResponse, diff --git a/packages/count-aggregator-api/src/lib/urls.test.ts b/packages/count-aggregator-api/src/lib/urls.test.ts index 2c4829e1..bf62a759 100644 --- a/packages/count-aggregator-api/src/lib/urls.test.ts +++ b/packages/count-aggregator-api/src/lib/urls.test.ts @@ -6,6 +6,8 @@ import { buildMultipleLastValuesUrl, buildMultipleValuesQueryUrl, buildMultipleValuesUrl, + buildRawValuesCsvExportUrl, + buildRawValuesUrl, buildSingleStationLastValuesUrl, buildSingleStationValuesUrl, buildStationSumsUrl, @@ -145,6 +147,32 @@ describe("count aggregator URL builders", () => { ); }); + it("builds a raw-values URL with station ids only", () => { + const url = buildRawValuesUrl(baseUrl, { + type: "bicycleSensorTotal", + stationIds: [150, 151], + }); + + expect(url).toBe( + "https://example.test/msp/public/count-aggregator/bicycleSensorTotal/raw-values?stationIds=150%2C151", + ); + }); + + it("builds a range-filtered raw-values CSV URL", () => { + const url = buildRawValuesCsvExportUrl(baseUrl, { + type: "waterLevelSurface", + stationIds: [140], + from: "2026-06-01 00:00:00", + to: "2026-06-01 12:00:00", + limit: 50, + order: "desc", + }); + + expect(url).toBe( + "https://example.test/msp/public/count-aggregator/waterLevelSurface/raw-values?stationIds=140&from=2026-06-01+00%3A00%3A00&to=2026-06-01+12%3A00%3A00&limit=50&order=desc&format=csv", + ); + }); + it("supports relative base URLs for browser apps", () => { const relativeBase = "/msp/public/count-aggregator"; diff --git a/packages/count-aggregator-api/src/lib/urls.ts b/packages/count-aggregator-api/src/lib/urls.ts index 1c19b9a5..5afc7106 100644 --- a/packages/count-aggregator-api/src/lib/urls.ts +++ b/packages/count-aggregator-api/src/lib/urls.ts @@ -55,6 +55,16 @@ export interface SingleStationLastValuesRequest { metrics?: readonly BucketMetric[]; } +export interface MultipleRawValuesRequest { + type: StationType; + stationIds: readonly number[]; + from?: string; + to?: string; + limit?: number; + order?: "asc" | "desc"; + format?: ResponseFormat; +} + function trimTrailingSlash(baseUrl: string): string { return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; } @@ -161,6 +171,29 @@ export function buildStationSumsUrl( return `${trimTrailingSlash(baseUrl)}/${type}/${stationId}/sums`; } +export function buildRawValuesUrl( + baseUrl: string, + request: MultipleRawValuesRequest, +): string { + const path = `${trimTrailingSlash(baseUrl)}/${request.type}/raw-values`; + + return appendQueryString(path, { + stationIds: request.stationIds.join(","), + from: request.from, + to: request.to, + limit: request.limit, + order: request.order, + format: request.format, + }); +} + +export function buildRawValuesCsvExportUrl( + baseUrl: string, + request: Omit, +): string { + return buildRawValuesUrl(baseUrl, {...request, format: "csv"}); +} + export function buildCsvExportUrl( baseUrl: string, request: Omit, diff --git a/packages/count-aggregator-api/src/types.ts b/packages/count-aggregator-api/src/types.ts index b9bb4543..eda1d3ca 100644 --- a/packages/count-aggregator-api/src/types.ts +++ b/packages/count-aggregator-api/src/types.ts @@ -14,6 +14,9 @@ export type DataValuePoint = z.infer; export type StationMetric = z.infer; export type TimeSeriesResponse = z.infer; export type TimeSeriesMapResponse = Record; +export type RawValuePoint = z.infer; +export type RawValuesResponse = z.infer; +export type RawValuesMapResponse = Record; export type StationSummary = z.infer; export type StationListResponse = z.infer; export type StationOverviewResponse = z.infer< From 0ad74aa5c6f86dd0dddad292bdbbe8448e1f7247 Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Thu, 27 Aug 2026 11:03:08 +0200 Subject: [PATCH 2/4] feat(count-aggregator-ui): add Rohwerte wizard mode --- .changeset/count-aggregator-ui-raw-values.md | 5 ++ packages/count-aggregator-ui/README.md | 4 +- .../src/api/hooks.test.tsx | 47 +++++++++++++ packages/count-aggregator-ui/src/api/hooks.ts | 67 ++++++++++++++++++- .../src/api/mappers.test.ts | 28 +++++++- .../count-aggregator-ui/src/api/mappers.ts | 19 ++++++ .../apps/count-aggregator-wizard.tsx | 67 +++++++++++++------ .../components/wizard/resolution-select.tsx | 16 ++--- .../components/wizard/result-step.test.tsx | 25 ++++++- .../src/components/wizard/result-step.tsx | 28 ++++++-- .../src/config/station-types.ts | 1 + packages/count-aggregator-ui/src/headless.ts | 2 + packages/count-aggregator-ui/src/index.ts | 1 + packages/count-aggregator-ui/src/lib/dates.ts | 12 ++++ packages/count-aggregator-ui/src/lib/i18n.ts | 12 ++++ .../count-aggregator-ui/src/types/index.ts | 5 +- 16 files changed, 299 insertions(+), 40 deletions(-) create mode 100644 .changeset/count-aggregator-ui-raw-values.md diff --git a/.changeset/count-aggregator-ui-raw-values.md b/.changeset/count-aggregator-ui-raw-values.md new file mode 100644 index 00000000..0de2fa22 --- /dev/null +++ b/.changeset/count-aggregator-ui-raw-values.md @@ -0,0 +1,5 @@ +--- +"@mapsight/count-aggregator-ui": minor +--- + +Add wizard Rohwerte mode (`features.rawValues`) that fetches unaggregated raw values and CSV. diff --git a/packages/count-aggregator-ui/README.md b/packages/count-aggregator-ui/README.md index c5bbbbc1..47c0693a 100644 --- a/packages/count-aggregator-ui/README.md +++ b/packages/count-aggregator-ui/README.md @@ -72,6 +72,7 @@ config.apps.bicycleSensorTotal = { uiVariant: "stepped", features: { resolutionSelect: true, + rawValues: true, chartTypeSelect: true, export: true, presets: false, @@ -122,7 +123,7 @@ Key config fields on each app (`CountAggregatorAppConfig`): - `stationType` — API path segment (e.g. `bicycleSensorTotal`) - `uiVariant` — `"stepped"` (selection → result) or `"single-page"` - `defaultResolution`, `resolutions` — `hourly` … `yearly` -- `features` — toggles for resolution select, chart type, CSV export, presets, events +- `features` — toggles for resolution select, Rohwerte, chart type, CSV export, presets, events ## Exports @@ -220,6 +221,7 @@ Feature flags should be configured via `features` on each app: ```ts features: { resolutionSelect: true, + rawValues: true, chartTypeSelect: true, export: true, presets: false, diff --git a/packages/count-aggregator-ui/src/api/hooks.test.tsx b/packages/count-aggregator-ui/src/api/hooks.test.tsx index e0ffc12b..50791995 100644 --- a/packages/count-aggregator-ui/src/api/hooks.test.tsx +++ b/packages/count-aggregator-ui/src/api/hooks.test.tsx @@ -4,6 +4,7 @@ import type { ValuesRequest as ApiValuesRequest, CountAggregatorClient, LastValuesRequest, + RawValuesRequest, StationListResponse, StationTypeListResponse, TimeSeriesMapResponse, @@ -17,6 +18,7 @@ import type {CountAggregatorConfig} from "../types/index.js"; import { useAggregatedValues, useLastValues, + useRawValues, useStationTypeCounts, useStations, } from "./hooks.js"; @@ -29,6 +31,7 @@ const mocks = vi.hoisted(() => { listStationTypes: vi.fn(), listStations: vi.fn(), getLastValues: vi.fn(), + getRawValues: vi.fn(), getValues: vi.fn(), }; }); @@ -43,6 +46,7 @@ vi.mock("@mapsight/count-aggregator-api", async (importActual) => { listStationTypes: mocks.listStationTypes, listStations: mocks.listStations, getLastValues: mocks.getLastValues, + getRawValues: mocks.getRawValues, getValues: mocks.getValues, }; }); @@ -164,6 +168,13 @@ describe("count-aggregator hooks", () => { mocks.listStations.mockResolvedValue(stationListResponse); mocks.listStationTypes.mockResolvedValue(stationTypeListResponse); mocks.getLastValues.mockResolvedValue(valuesResponse); + mocks.getRawValues.mockResolvedValue({ + "150": { + id: 150, + stationId: "138969", + values: [{datetime: "2026-06-01 12:34:56", value: 67.25}], + }, + }); mocks.getValues.mockResolvedValue(valuesResponse); }); @@ -281,4 +292,40 @@ describe("count-aggregator hooks", () => { metrics: ["sum"], } satisfies ApiValuesRequest); }); + + it("useRawValues loads exact telemetry without a resolution", async () => { + const {queryClient, wrapper} = createWrapper(); + const request = { + stationIds: [150], + from: "2026-06-01", + to: "2026-06-02", + limit: 50, + order: "desc" as const, + }; + + const {result} = renderHook(() => useRawValues(appId, request), { + wrapper, + }); + + await waitFor(() => + expect( + result.current?.stationsById.get(150)?.values[0]?.value, + ).toBe(67.25), + ); + + expect(mocks.getRawValues).toHaveBeenCalledWith(mocks.mockClient, { + type: "bicycleSensorTotal", + ...request, + } satisfies RawValuesRequest); + expect( + queryClient.getQueryData([ + "count-aggregator", + appId, + "raw-values", + apiBaseUrl, + "bicycleSensorTotal", + {...request, metric: "sum"}, + ]), + ).toBe(result.current); + }); }); diff --git a/packages/count-aggregator-ui/src/api/hooks.ts b/packages/count-aggregator-ui/src/api/hooks.ts index 2fca5c84..d7504d82 100644 --- a/packages/count-aggregator-ui/src/api/hooks.ts +++ b/packages/count-aggregator-ui/src/api/hooks.ts @@ -3,6 +3,7 @@ import {useMemo} from "react"; import { createCountAggregatorClient, getLastValues, + getRawValues, getValues, listStationTypes, listStations, @@ -23,7 +24,7 @@ import type { TrafficEventsData, ValuesRequest, } from "../types"; -import {mapStationList, mapTimeSeriesMap} from "./mappers.js"; +import {mapRawValuesMap, mapStationList, mapTimeSeriesMap} from "./mappers.js"; const STALE_TIME_MS = 5 * 60 * 1000; @@ -228,6 +229,70 @@ export function useAggregatedValues( return data; } +export function useRawValues( + appId: string, + request: { + stationIds: readonly number[]; + from?: string; + to?: string; + limit?: number; + order?: "asc" | "desc"; + }, + options?: {enabled?: boolean}, +): AggregatedValuesData | undefined { + const appConfig = useAppConfig(appId); + const metric = appConfig.defaultMetric ?? "sum"; + + const req = useMemo(() => { + if (request.stationIds.length === 0) { + return null; + } + + return { + stationIds: request.stationIds, + from: request.from, + to: request.to, + limit: request.limit, + order: request.order, + metric, + }; + }, [ + request.stationIds, + request.from, + request.to, + request.limit, + request.order, + metric, + ]); + + const {data} = useQuery({ + queryKey: [ + "count-aggregator", + appId, + "raw-values", + appConfig.apiBaseUrl, + appConfig.stationType, + req, + ], + queryFn: async () => { + const client = createCountAggregatorClient(appConfig.apiBaseUrl); + const response = await getRawValues(client, { + type: appConfig.stationType, + stationIds: req!.stationIds, + from: req!.from, + to: req!.to, + limit: req!.limit, + order: req!.order, + }); + return mapRawValuesMap(response, req!.metric); + }, + staleTime: STALE_TIME_MS, + enabled: (options?.enabled ?? true) && req !== null, + }); + + return data; +} + export function useTrafficEvents( appId: string, startDate: Date | null, diff --git a/packages/count-aggregator-ui/src/api/mappers.test.ts b/packages/count-aggregator-ui/src/api/mappers.test.ts index 91b9dfc2..6b645ecd 100644 --- a/packages/count-aggregator-ui/src/api/mappers.test.ts +++ b/packages/count-aggregator-ui/src/api/mappers.test.ts @@ -1,10 +1,11 @@ import type { + RawValuesMapResponse, StationListResponse, TimeSeriesMapResponse, } from "@mapsight/count-aggregator-api"; import {describe, expect, it} from "vitest"; -import {mapStationList, mapTimeSeriesMap} from "./mappers.js"; +import {mapRawValuesMap, mapStationList, mapTimeSeriesMap} from "./mappers.js"; describe("mapStationList", () => { it("maps API station summaries by MSP id", () => { @@ -98,6 +99,31 @@ describe("mapTimeSeriesMap", () => { }); }); +describe("mapRawValuesMap", () => { + it("maps exact telemetry points onto the default metric series", () => { + const response: RawValuesMapResponse = { + "150": { + id: 150, + stationId: "138969", + values: [ + {datetime: "2026-06-01 12:34:56", value: 67.25}, + {datetime: "2026-06-01 12:29:56", value: 64.5}, + ], + }, + }; + + const result = mapRawValuesMap(response, "sum"); + const station = result.stationsById.get(150); + + expect(station?.stationId).toBe(150); + expect(station?.values).toEqual([ + {date: new Date(Date.UTC(2026, 5, 1, 12, 34, 56)), value: 67.25}, + {date: new Date(Date.UTC(2026, 5, 1, 12, 29, 56)), value: 64.5}, + ]); + expect(station?.valuesByMetric.sum).toEqual(station?.values); + }); +}); + describe("resolveBucketValue", () => { it("prefers explicit metric fields over value alias", async () => { const {resolveBucketValue} = await import("../lib/bucket-metrics.js"); diff --git a/packages/count-aggregator-ui/src/api/mappers.ts b/packages/count-aggregator-ui/src/api/mappers.ts index 78a3fb66..309a8c3a 100644 --- a/packages/count-aggregator-ui/src/api/mappers.ts +++ b/packages/count-aggregator-ui/src/api/mappers.ts @@ -1,5 +1,6 @@ import type { BucketMetric, + RawValuesMapResponse, StationListResponse, TimeSeriesMapResponse, } from "@mapsight/count-aggregator-api"; @@ -62,3 +63,21 @@ export function mapTimeSeriesMap( return {stationsById}; } + +export function mapRawValuesMap( + map: RawValuesMapResponse, + metric: BucketMetric, +): AggregatedValuesData { + const stationsById = new Map(); + + for (const [key, series] of Object.entries(map)) { + const values = mapDataValuePointsToChartPoints(series.values, metric); + stationsById.set(Number(key), { + stationId: Number(key), + values, + valuesByMetric: values.length > 0 ? {[metric]: values} : {}, + }); + } + + return {stationsById}; +} diff --git a/packages/count-aggregator-ui/src/components/apps/count-aggregator-wizard.tsx b/packages/count-aggregator-ui/src/components/apps/count-aggregator-wizard.tsx index 83586de5..d5d89619 100644 --- a/packages/count-aggregator-ui/src/components/apps/count-aggregator-wizard.tsx +++ b/packages/count-aggregator-ui/src/components/apps/count-aggregator-wizard.tsx @@ -1,19 +1,27 @@ import {type FormEvent, type ReactElement, useCallback, useState} from "react"; -import {useAggregatedValues, useStations} from "../../api/hooks.js"; +import { + useAggregatedValues, + useRawValues, + useStations, +} from "../../api/hooks.js"; import {applyPresetDateRanges} from "../../config/platform.js"; import { useAppConfig, useCountAggregatorI18n, } from "../../context/count-aggregator-provider.js"; -import {getFirstDayOfMonth, getLastDayOfMonth} from "../../lib/dates.js"; +import { + dateToYmd, + getFirstDayOfMonth, + getLastDayOfMonth, +} from "../../lib/dates.js"; import {isFeatureEnabled} from "../../lib/features.js"; import type { BucketMetric, ChartType, - DataResolution, PresetData, Station, + ValuesMode, } from "../../types"; import {Events} from "../wizard/events.js"; import {MetricSelect} from "../wizard/metric-select.js"; @@ -61,8 +69,8 @@ function SelectionPanel({ endDate: Date; onStartDateChange: (date: Date) => void; onEndDateChange: (date: Date) => void; - resolution: DataResolution; - onResolutionChange: (resolution: DataResolution) => void; + resolution: ValuesMode; + onResolutionChange: (resolution: ValuesMode) => void; onPresetChange: (preset: PresetData | null) => void; showPresets: boolean; showResolutionSelect: boolean; @@ -71,8 +79,8 @@ function SelectionPanel({ availableMetrics: readonly BucketMetric[]; selectedMetrics: readonly BucketMetric[]; onSelectedMetricsChange: (metrics: readonly BucketMetric[]) => void; - resolutions: readonly DataResolution[]; - resolutionLabels?: Partial>; + resolutions: readonly ValuesMode[]; + resolutionLabels?: Partial>; }): ReactElement { const {t} = useCountAggregatorI18n(); const handleSelectAll = useCallback(() => { @@ -181,16 +189,15 @@ export function CountAggregatorWizard({ const isStepped = appConfig.uiVariant === "stepped"; const showPresets = isFeatureEnabled(appConfig, "presets"); const showEvents = isFeatureEnabled(appConfig, "events"); - const showResolutionSelect = isFeatureEnabled( - appConfig, - "resolutionSelect", - ); + const showRawValues = isFeatureEnabled(appConfig, "rawValues"); + const showResolutionSelect = + isFeatureEnabled(appConfig, "resolutionSelect") || showRawValues; const showExport = isFeatureEnabled(appConfig, "export"); const showChartTypeSelect = isFeatureEnabled(appConfig, "chartTypeSelect"); - - const showMetricSelect = isFeatureEnabled(appConfig, "metricSelect"); - - const resolutions = appConfig.resolutions ?? (["daily"] as const); + const aggregatedResolutions = appConfig.resolutions ?? (["daily"] as const); + const resolutions: readonly ValuesMode[] = showRawValues + ? [...aggregatedResolutions, "raw"] + : aggregatedResolutions; const availableMetrics = appConfig.availableMetrics ?? [ appConfig.defaultMetric ?? "sum", ]; @@ -204,7 +211,7 @@ export function CountAggregatorWizard({ >(initialSelectedStationIds); const [startDate, setStartDate] = useState(getFirstDayOfMonth); const [endDate, setEndDate] = useState(getLastDayOfMonth); - const [resolution, setResolution] = useState( + const [resolution, setResolution] = useState( appConfig.defaultResolution ?? "daily", ); const [chartType, setChartType] = useState( @@ -213,19 +220,34 @@ export function CountAggregatorWizard({ const [selectedMetrics, setSelectedMetrics] = useState(defaultChartMetrics); + const isRaw = resolution === "raw"; + const showMetricSelect = + isFeatureEnabled(appConfig, "metricSelect") && !isRaw; const stationsById = useStations(appId); const valuesEnabled = !isStepped || step === 1; - const data = useAggregatedValues( + const aggregatedData = useAggregatedValues( appId, { stationIds: selectedStationIds, startDate, endDate, - resolution, + resolution: isRaw + ? (appConfig.defaultResolution ?? "daily") + : resolution, metrics: selectedMetrics, }, - {enabled: valuesEnabled}, + {enabled: valuesEnabled && !isRaw}, + ); + const rawData = useRawValues( + appId, + { + stationIds: selectedStationIds, + from: dateToYmd(startDate), + to: dateToYmd(endDate), + }, + {enabled: valuesEnabled && isRaw}, ); + const data = isRaw ? rawData : aggregatedData; const handlePresetChange = useCallback((preset: PresetData | null) => { if (preset === null) { @@ -267,8 +289,11 @@ export function CountAggregatorWizard({ endDate={endDate} stationsById={stationsById} chartType={chartType} - resolution={resolution} - selectedMetrics={selectedMetrics} + resolution={isRaw ? "5min" : resolution} + valuesMode={resolution} + selectedMetrics={ + isRaw ? [appConfig.defaultMetric ?? "sum"] : selectedMetrics + } showExport={showExport} showChartTypeSelect={showChartTypeSelect} onChartTypeChange={setChartType} diff --git a/packages/count-aggregator-ui/src/components/wizard/resolution-select.tsx b/packages/count-aggregator-ui/src/components/wizard/resolution-select.tsx index 72502c17..18b0aaa3 100644 --- a/packages/count-aggregator-ui/src/components/wizard/resolution-select.tsx +++ b/packages/count-aggregator-ui/src/components/wizard/resolution-select.tsx @@ -1,8 +1,8 @@ import {type ChangeEvent, type ReactElement, useCallback} from "react"; import {useCountAggregatorI18n} from "../../context/count-aggregator-provider.js"; -import {getResolutionLabels} from "../../lib/i18n.js"; -import type {DataResolution, Resolution} from "../../types"; +import {getValuesModeLabels} from "../../lib/i18n.js"; +import type {ValuesMode} from "../../types"; import {Section} from "./section.js"; export function ResolutionSelect({ @@ -11,20 +11,20 @@ export function ResolutionSelect({ resolutionLabels, onChange, }: { - resolution: DataResolution; - resolutions: readonly Resolution[]; - resolutionLabels?: Partial>; - onChange: (resolution: DataResolution) => void; + resolution: ValuesMode; + resolutions: readonly ValuesMode[]; + resolutionLabels?: Partial>; + onChange: (resolution: ValuesMode) => void; }): ReactElement { const {t} = useCountAggregatorI18n(); const handleChange = useCallback( (event: ChangeEvent) => { - onChange(event.target.value as DataResolution); + onChange(event.target.value as ValuesMode); }, [onChange], ); - const labels = {...getResolutionLabels(t), ...resolutionLabels}; + const labels = {...getValuesModeLabels(t), ...resolutionLabels}; return (
diff --git a/packages/count-aggregator-ui/src/components/wizard/result-step.test.tsx b/packages/count-aggregator-ui/src/components/wizard/result-step.test.tsx index 8f585cb2..c63808ac 100644 --- a/packages/count-aggregator-ui/src/components/wizard/result-step.test.tsx +++ b/packages/count-aggregator-ui/src/components/wizard/result-step.test.tsx @@ -1,5 +1,5 @@ -import {render, screen} from "@testing-library/react"; -import {describe, expect, it, vi} from "vitest"; +import {cleanup, render, screen} from "@testing-library/react"; +import {afterEach, describe, expect, it, vi} from "vitest"; import {CountAggregatorProvider} from "../../context/count-aggregator-provider.js"; import type { @@ -94,6 +94,10 @@ function renderResultStep( } describe("ResultStep", () => { + afterEach(() => { + cleanup(); + }); + it("shows validation messages and disables CSV when the selection is invalid", () => { renderResultStep({ selectedStationIds: [], @@ -113,6 +117,23 @@ describe("ResultStep", () => { expect(screen.queryByRole("link", {name: /csv/i})).toBeNull(); }); + it("builds a raw-values CSV href in Rohwerte mode", () => { + renderResultStep({ + valuesMode: "raw", + showExport: true, + onEditSelection: vi.fn(), + }); + + const link = screen.getByRole("link", { + name: "Als CSV-Datei herunterladen", + }); + + expect(link.getAttribute("href")).toBe( + "/mock/msp/public/count-aggregator/bicycleSensorTotal/raw-values?stationIds=150&from=2026-06-01&to=2026-06-02&format=csv", + ); + expect(screen.getByText(/Rohwerte/)).toBeTruthy(); + }); + it("builds a CSV href when the selection is valid", () => { renderResultStep(); diff --git a/packages/count-aggregator-ui/src/components/wizard/result-step.tsx b/packages/count-aggregator-ui/src/components/wizard/result-step.tsx index 80df9970..b3b02812 100644 --- a/packages/count-aggregator-ui/src/components/wizard/result-step.tsx +++ b/packages/count-aggregator-ui/src/components/wizard/result-step.tsx @@ -1,6 +1,9 @@ import {Fragment, type ReactElement, useMemo} from "react"; -import {buildCsvExportUrl} from "@mapsight/count-aggregator-api"; +import { + buildCsvExportUrl, + buildRawValuesCsvExportUrl, +} from "@mapsight/count-aggregator-api"; import type {BucketMetric} from "@mapsight/count-aggregator-api"; import { @@ -10,13 +13,14 @@ import { import {normalizeSelectedMetrics} from "../../lib/bucket-metrics.js"; import {getColorForStationIndex} from "../../lib/colors.js"; import {dateToYmd} from "../../lib/dates.js"; -import {getResolutionLabels} from "../../lib/i18n.js"; +import {getValuesModeLabels} from "../../lib/i18n.js"; import {isDefined} from "../../lib/utils.js"; import { type AggregatedValuesData, type ChartType, type DataResolution, type Station, + type ValuesMode, } from "../../types"; import { TimeSeriesChart, @@ -37,6 +41,7 @@ export function ResultStep({ stationsById, chartType = "line", resolution = "daily", + valuesMode, showExport = false, showChartTypeSelect = false, onChartTypeChange, @@ -51,6 +56,7 @@ export function ResultStep({ stationsById: Map | undefined; chartType?: ChartType; resolution?: DataResolution; + valuesMode?: ValuesMode; selectedMetrics?: readonly BucketMetric[]; showExport?: boolean; showChartTypeSelect?: boolean; @@ -107,15 +113,26 @@ export function ResultStep({ [appConfig.defaultMetric, data, metrics, selectedStationIds], ); + const mode = valuesMode ?? resolution; + const isRaw = mode === "raw"; + const chartResolution = isRaw ? "5min" : resolution; const resolutionLabel = - appConfig.resolutionLabels?.[resolution] ?? - getResolutionLabels(t)[resolution]; + appConfig.resolutionLabels?.[mode] ?? getValuesModeLabels(t)[mode]; const csvDownloadHref = useMemo(() => { if (!isValid) { return ""; } + if (isRaw) { + return buildRawValuesCsvExportUrl(appConfig.apiBaseUrl, { + type: appConfig.stationType, + from: dateToYmd(startDate), + to: dateToYmd(endDate), + stationIds: [...selectedStationIds], + }); + } + return buildCsvExportUrl(appConfig.apiBaseUrl, { type: appConfig.stationType, from: dateToYmd(startDate), @@ -128,6 +145,7 @@ export function ResultStep({ appConfig.apiBaseUrl, appConfig.stationType, endDate, + isRaw, isValid, metrics, resolution, @@ -173,7 +191,7 @@ export function ResultStep({ selectedStationIds={selectedStationIds} selectedMetrics={metrics} chartSeries={chartSeries} - resolution={resolution} + resolution={chartResolution} startDate={startDate} endDate={endDate} stationsById={stationsById} diff --git a/packages/count-aggregator-ui/src/config/station-types.ts b/packages/count-aggregator-ui/src/config/station-types.ts index f935c4fb..d7d50dea 100644 --- a/packages/count-aggregator-ui/src/config/station-types.ts +++ b/packages/count-aggregator-ui/src/config/station-types.ts @@ -64,6 +64,7 @@ function createAppConfigForStationType( resolutions: supportedResolutions, features: { resolutionSelect: true, + rawValues: true, chartTypeSelect: isBicycleSensorTotal, metricSelect: uniqueMetrics.length > 1, export: true, diff --git a/packages/count-aggregator-ui/src/headless.ts b/packages/count-aggregator-ui/src/headless.ts index 85f92ba9..6612474a 100644 --- a/packages/count-aggregator-ui/src/headless.ts +++ b/packages/count-aggregator-ui/src/headless.ts @@ -4,6 +4,7 @@ export { useStations, useStationsQuery, useLastValues, + useRawValues, useAggregatedValues, useTrafficEvents, usePresets, @@ -24,6 +25,7 @@ export {dateToYmd, ymdToDate} from "./lib/dates.js"; export { getCountAggregatorDictionary, getResolutionLabels, + getValuesModeLabels, resolveCountAggregatorLocale, type CountAggregatorLocale, type CountAggregatorTranslationKey, diff --git a/packages/count-aggregator-ui/src/index.ts b/packages/count-aggregator-ui/src/index.ts index b3ada686..c0d3d379 100644 --- a/packages/count-aggregator-ui/src/index.ts +++ b/packages/count-aggregator-ui/src/index.ts @@ -41,6 +41,7 @@ export { useStations, useStationsQuery, useLastValues, + useRawValues, useAggregatedValues, useTrafficEvents, usePresets, diff --git a/packages/count-aggregator-ui/src/lib/dates.ts b/packages/count-aggregator-ui/src/lib/dates.ts index dd21742c..2ee2b1af 100644 --- a/packages/count-aggregator-ui/src/lib/dates.ts +++ b/packages/count-aggregator-ui/src/lib/dates.ts @@ -66,6 +66,16 @@ export function getTooltipDateFormat( resolution: DataResolution, ): Intl.DateTimeFormatOptions { switch (resolution) { + case "5min": + case "15min": + return { + weekday: "long", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }; case "hourly": return { weekday: "long", @@ -108,6 +118,8 @@ export function formatChartAxisDate( }); case "yearly": return date.getFullYear().toString(); + case "5min": + case "15min": case "hourly": return date.toLocaleTimeString(undefined, { hour: "2-digit", diff --git a/packages/count-aggregator-ui/src/lib/i18n.ts b/packages/count-aggregator-ui/src/lib/i18n.ts index dbee7e84..034325fe 100644 --- a/packages/count-aggregator-ui/src/lib/i18n.ts +++ b/packages/count-aggregator-ui/src/lib/i18n.ts @@ -49,6 +49,7 @@ export type CountAggregatorTranslationKey = | "resolution.daily" | "resolution.hourly" | "resolution.monthly" + | "resolution.raw" | "resolution.section" | "resolution.weekly" | "resolution.yearly" @@ -130,6 +131,7 @@ const de: CountAggregatorTranslations = { "resolution.daily": "Tag", "resolution.hourly": "Stunde", "resolution.monthly": "Monat", + "resolution.raw": "Rohwerte", "resolution.section": "Auflösung", "resolution.weekly": "Woche", "resolution.yearly": "Jahr", @@ -209,6 +211,7 @@ const en: CountAggregatorTranslations = { "resolution.daily": "Day", "resolution.hourly": "Hour", "resolution.monthly": "Month", + "resolution.raw": "Raw values", "resolution.section": "Resolution", "resolution.weekly": "Week", "resolution.yearly": "Year", @@ -272,6 +275,15 @@ export function getResolutionLabels( }; } +export function getValuesModeLabels( + translate: (key: CountAggregatorTranslationKey) => string, +): Record { + return { + ...getResolutionLabels(translate), + raw: translate("resolution.raw"), + }; +} + export function getMetricLabels( translate: (key: CountAggregatorTranslationKey) => string, ): Record { diff --git a/packages/count-aggregator-ui/src/types/index.ts b/packages/count-aggregator-ui/src/types/index.ts index b3722547..e8002a13 100644 --- a/packages/count-aggregator-ui/src/types/index.ts +++ b/packages/count-aggregator-ui/src/types/index.ts @@ -11,6 +11,8 @@ import type { export type DataResolution = Resolution; +export type ValuesMode = Resolution | "raw"; + export type ChartType = "line" | "column" | "area"; export interface Station { @@ -110,6 +112,7 @@ export type UiVariant = "single-page" | "stepped"; export interface CountAggregatorFeatures { resolutionSelect?: boolean; + rawValues?: boolean; chartTypeSelect?: boolean; metricSelect?: boolean; export?: boolean; @@ -131,7 +134,7 @@ export interface CountAggregatorAppConfig { defaultResolution?: Resolution; defaultChartType?: ChartType; resolutions?: readonly Resolution[]; - resolutionLabels?: Partial>; + resolutionLabels?: Partial>; features?: CountAggregatorFeatures; endpoints?: CountAggregatorPlatformEndpoints; } From eca40ba13247ca405733f86a7e74a570357568c5 Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Thu, 27 Aug 2026 11:03:43 +0200 Subject: [PATCH 3/4] feat(showcase): mock count-aggregator raw-values --- .../count-aggregator-mock/mock-api.ts | 38 +++++++++++++++++++ .../count-aggregator-demo-page.tsx | 1 + 2 files changed, 39 insertions(+) diff --git a/apps/showcase/count-aggregator-mock/mock-api.ts b/apps/showcase/count-aggregator-mock/mock-api.ts index f44c9741..b650f464 100644 --- a/apps/showcase/count-aggregator-mock/mock-api.ts +++ b/apps/showcase/count-aggregator-mock/mock-api.ts @@ -168,6 +168,44 @@ export function resolveCountAggregatorMockRequest( return jsonResponse(stationList); } + const rawValuesMatch = subPath.match(/^\/([^/]+)\/raw-values$/); + if (rawValuesMatch !== null) { + const stationIds = parseStationIds(url.searchParams); + if (stationIds.length === 0) { + return jsonResponse({message: "stationIds is required"}, 400); + } + + const fromYmd = (url.searchParams.get("from") ?? "2026-06-01").slice( + 0, + 10, + ); + const toYmd = (url.searchParams.get("to") ?? fromYmd).slice(0, 10); + const map = buildValuesMap(stationIds, fromYmd, toYmd, "daily"); + const rawMap: Record = {}; + + for (const [stationId, series] of Object.entries(map)) { + const entry = series as { + id: number; + stationId: string; + values: {datetime: string; value: number}[]; + }; + rawMap[stationId] = { + id: entry.id, + stationId: entry.stationId, + values: entry.values, + }; + } + + if (format === "csv") { + return textResponse( + buildCsv(stationIds, fromYmd, toYmd, "daily"), + "text/csv; charset=utf-8", + ); + } + + return jsonResponse(rawMap); + } + const valuesMatch = subPath.match( /^\/([^/]+)\/values\/(\d{4}-\d{2}-\d{2})\/(\d{4}-\d{2}-\d{2})\/([^/]+)$/, ); diff --git a/apps/showcase/src/count-aggregator/count-aggregator-demo-page.tsx b/apps/showcase/src/count-aggregator/count-aggregator-demo-page.tsx index 37be98af..597a6f31 100644 --- a/apps/showcase/src/count-aggregator/count-aggregator-demo-page.tsx +++ b/apps/showcase/src/count-aggregator/count-aggregator-demo-page.tsx @@ -42,6 +42,7 @@ function CountAggregatorDemoBootstrap(): ReactElement { uiVariant: "stepped", features: { resolutionSelect: true, + rawValues: true, chartTypeSelect: true, export: true, presets: false, From 5ddea0bae19bd1c19da6d18081a7e65ebbd5a8fe Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Thu, 27 Aug 2026 22:25:06 +0200 Subject: [PATCH 4/4] fix(count-aggregator-ui): request chronological Rohwerte with the API max limit The raw-values contract defaults to 50 newest-first, so wizard chart and CSV exports were truncated and reversed unless the UI opted in. --- .../src/components/apps/count-aggregator-wizard.tsx | 2 ++ .../src/components/wizard/result-step.test.tsx | 2 +- .../src/components/wizard/result-step.tsx | 2 ++ packages/count-aggregator-ui/src/lib/raw-values.ts | 9 +++++++++ 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 packages/count-aggregator-ui/src/lib/raw-values.ts diff --git a/packages/count-aggregator-ui/src/components/apps/count-aggregator-wizard.tsx b/packages/count-aggregator-ui/src/components/apps/count-aggregator-wizard.tsx index d5d89619..efea9e05 100644 --- a/packages/count-aggregator-ui/src/components/apps/count-aggregator-wizard.tsx +++ b/packages/count-aggregator-ui/src/components/apps/count-aggregator-wizard.tsx @@ -16,6 +16,7 @@ import { getLastDayOfMonth, } from "../../lib/dates.js"; import {isFeatureEnabled} from "../../lib/features.js"; +import {RAW_VALUES_RANGE_QUERY} from "../../lib/raw-values.js"; import type { BucketMetric, ChartType, @@ -244,6 +245,7 @@ export function CountAggregatorWizard({ stationIds: selectedStationIds, from: dateToYmd(startDate), to: dateToYmd(endDate), + ...RAW_VALUES_RANGE_QUERY, }, {enabled: valuesEnabled && isRaw}, ); diff --git a/packages/count-aggregator-ui/src/components/wizard/result-step.test.tsx b/packages/count-aggregator-ui/src/components/wizard/result-step.test.tsx index c63808ac..4f8bb70d 100644 --- a/packages/count-aggregator-ui/src/components/wizard/result-step.test.tsx +++ b/packages/count-aggregator-ui/src/components/wizard/result-step.test.tsx @@ -129,7 +129,7 @@ describe("ResultStep", () => { }); expect(link.getAttribute("href")).toBe( - "/mock/msp/public/count-aggregator/bicycleSensorTotal/raw-values?stationIds=150&from=2026-06-01&to=2026-06-02&format=csv", + "/mock/msp/public/count-aggregator/bicycleSensorTotal/raw-values?stationIds=150&from=2026-06-01&to=2026-06-02&limit=500&order=asc&format=csv", ); expect(screen.getByText(/Rohwerte/)).toBeTruthy(); }); diff --git a/packages/count-aggregator-ui/src/components/wizard/result-step.tsx b/packages/count-aggregator-ui/src/components/wizard/result-step.tsx index b3b02812..6a7610c0 100644 --- a/packages/count-aggregator-ui/src/components/wizard/result-step.tsx +++ b/packages/count-aggregator-ui/src/components/wizard/result-step.tsx @@ -14,6 +14,7 @@ import {normalizeSelectedMetrics} from "../../lib/bucket-metrics.js"; import {getColorForStationIndex} from "../../lib/colors.js"; import {dateToYmd} from "../../lib/dates.js"; import {getValuesModeLabels} from "../../lib/i18n.js"; +import {RAW_VALUES_RANGE_QUERY} from "../../lib/raw-values.js"; import {isDefined} from "../../lib/utils.js"; import { type AggregatedValuesData, @@ -130,6 +131,7 @@ export function ResultStep({ from: dateToYmd(startDate), to: dateToYmd(endDate), stationIds: [...selectedStationIds], + ...RAW_VALUES_RANGE_QUERY, }); } diff --git a/packages/count-aggregator-ui/src/lib/raw-values.ts b/packages/count-aggregator-ui/src/lib/raw-values.ts new file mode 100644 index 00000000..0bdf078c --- /dev/null +++ b/packages/count-aggregator-ui/src/lib/raw-values.ts @@ -0,0 +1,9 @@ +/** + * Wizard date-range queries. The raw-values contract defaults to `limit=50` + * and `order=desc`; omitting those truncates the selected window and draws + * the chart newest-first. + */ +export const RAW_VALUES_RANGE_QUERY = { + limit: 500, + order: "asc", +} as const;