From d9bc95c1fe3a32fd0eab3dbd6df5d51b841fa158 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 31 Jul 2026 14:20:54 +0200 Subject: [PATCH 1/4] feat(synkronus): Add charts and API stats route --- synkronus-portal/eslint.config.js | 2 +- synkronus-portal/package.json | 3 +- synkronus-portal/pnpm-lock.yaml | 308 ++++++++++++++++++ .../generated/.openapi-generator/FILES | 5 + .../src/api/synkronus/generated/api.ts | 173 ++++++++++ .../docs/ObservationFormTypeCount.md | 22 ++ .../docs/ObservationStatsResponse.md | 26 ++ .../generated/docs/ObservationTimeline.md | 26 ++ .../docs/ObservationTimelineBucket.md | 24 ++ .../api/synkronus/generated/docs/StatsApi.md | 62 ++++ synkronus-portal/src/components/HomePanel.tsx | 126 +++++++ .../charts/ObservationFormTypeChart.tsx | 104 ++++++ .../charts/ObservationTimelineChart.tsx | 99 ++++++ .../components/charts/OverviewChartPanel.tsx | 27 ++ synkronus-portal/src/contexts/AuthContext.tsx | 54 +-- .../src/lib/observationStatsCharts.ts | 71 ++++ synkronus-portal/src/pages/Dashboard.css | 201 ++++++++++++ synkronus-portal/src/pages/Dashboard.tsx | 102 +++++- synkronus-portal/src/services/api.ts | 14 + synkronus/cmd/synkronus/main.go | 6 + synkronus/internal/api/api.go | 5 + .../internal/api/api_integration_test.go | 1 + synkronus/internal/api/api_test.go | 1 + .../internal/handlers/appbundle_push_test.go | 6 +- .../handlers/attachment_manifest_test.go | 1 + .../attachment_sync_integration_test.go | 1 + synkronus/internal/handlers/handlers.go | 4 + synkronus/internal/handlers/mocks/stats.go | 36 ++ synkronus/internal/handlers/stats.go | 28 ++ synkronus/internal/handlers/sync_e2e_test.go | 1 + synkronus/internal/handlers/test_helpers.go | 2 + synkronus/internal/handlers/user_test.go | 1 + synkronus/openapi/synkronus.yaml | 98 ++++++ ...000_observations_created_at_live_index.sql | 7 + synkronus/pkg/stats/database.go | 91 ++++++ synkronus/pkg/stats/service.go | 45 +++ synkronus/pkg/stats/timeline.go | 90 +++++ synkronus/pkg/stats/timeline_test.go | 96 ++++++ synkronus/pkg/stats/types.go | 38 +++ 39 files changed, 1962 insertions(+), 45 deletions(-) create mode 100644 synkronus-portal/src/api/synkronus/generated/docs/ObservationFormTypeCount.md create mode 100644 synkronus-portal/src/api/synkronus/generated/docs/ObservationStatsResponse.md create mode 100644 synkronus-portal/src/api/synkronus/generated/docs/ObservationTimeline.md create mode 100644 synkronus-portal/src/api/synkronus/generated/docs/ObservationTimelineBucket.md create mode 100644 synkronus-portal/src/api/synkronus/generated/docs/StatsApi.md create mode 100644 synkronus-portal/src/components/HomePanel.tsx create mode 100644 synkronus-portal/src/components/charts/ObservationFormTypeChart.tsx create mode 100644 synkronus-portal/src/components/charts/ObservationTimelineChart.tsx create mode 100644 synkronus-portal/src/components/charts/OverviewChartPanel.tsx create mode 100644 synkronus-portal/src/lib/observationStatsCharts.ts create mode 100644 synkronus/internal/handlers/mocks/stats.go create mode 100644 synkronus/internal/handlers/stats.go create mode 100644 synkronus/pkg/migrations/sql/20260731120000_observations_created_at_live_index.sql create mode 100644 synkronus/pkg/stats/database.go create mode 100644 synkronus/pkg/stats/service.go create mode 100644 synkronus/pkg/stats/timeline.go create mode 100644 synkronus/pkg/stats/timeline_test.go create mode 100644 synkronus/pkg/stats/types.go diff --git a/synkronus-portal/eslint.config.js b/synkronus-portal/eslint.config.js index 4cb6e2ce7..b1fada31c 100644 --- a/synkronus-portal/eslint.config.js +++ b/synkronus-portal/eslint.config.js @@ -9,7 +9,7 @@ import prettierPlugin from 'eslint-plugin-prettier'; import { defineConfig, globalIgnores } from 'eslint/config'; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'src/api/synkronus/generated/**']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/synkronus-portal/package.json b/synkronus-portal/package.json index e8591f8a9..289336de9 100644 --- a/synkronus-portal/package.json +++ b/synkronus-portal/package.json @@ -23,7 +23,8 @@ "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-icons": "^5.5.0" + "react-icons": "^5.5.0", + "recharts": "^3.10.1" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/synkronus-portal/pnpm-lock.yaml b/synkronus-portal/pnpm-lock.yaml index d6d78444d..d8e7f440b 100644 --- a/synkronus-portal/pnpm-lock.yaml +++ b/synkronus-portal/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: react-icons: specifier: ^5.5.0 version: 5.6.0(react@19.2.6) + recharts: + specifier: ^3.10.1 + version: 3.10.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1) devDependencies: '@eslint/js': specifier: ^9.39.2 @@ -564,6 +567,17 @@ packages: '@types/react': optional: true + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -708,6 +722,12 @@ packages: '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -730,6 +750,33 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -768,6 +815,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} @@ -1057,6 +1107,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1126,6 +1180,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -1163,6 +1261,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1276,6 +1377,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1397,6 +1501,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -1645,6 +1752,9 @@ packages: engines: {node: '>=16.x'} hasBin: true + immer@11.1.15: + resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1660,6 +1770,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -2280,6 +2394,18 @@ packages: '@types/react': optional: true + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -2292,6 +2418,22 @@ packages: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -2313,6 +2455,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2525,6 +2670,9 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -2635,10 +2783,18 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@7.3.3: resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3270,6 +3426,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.15 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 19.2.6 + react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.60.4': @@ -3349,6 +3517,10 @@ snapshots: '@sinclair/typebox@0.27.10': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -3381,6 +3553,30 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} @@ -3421,6 +3617,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@types/wrap-ansi@3.0.0': {} '@types/yargs-parser@21.0.3': {} @@ -3778,6 +3976,8 @@ snapshots: clone@1.0.4: optional: true + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3849,6 +4049,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-uri-to-buffer@6.0.2: {} data-view-buffer@1.0.2: @@ -3879,6 +4117,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} defaults@1.0.4: @@ -4061,6 +4301,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.50.0: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -4230,6 +4472,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + exponential-backoff@3.1.3: {} fast-deep-equal@3.1.3: {} @@ -4484,6 +4728,8 @@ snapshots: dependencies: queue: 6.0.2 + immer@11.1.15: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -4499,6 +4745,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -5259,12 +5507,47 @@ snapshots: - supports-color - utf-8-validate + react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.14.2: {} react-refresh@0.18.0: {} react@19.2.6: {} + recharts@3.10.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.50.0 + eventemitter3: 5.0.4 + immer: 11.1.15 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 18.3.1 + react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.6) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -5293,6 +5576,8 @@ snapshots: require-main-filename@2.0.0: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve@2.0.0-next.7: @@ -5583,6 +5868,8 @@ snapshots: throat@5.0.0: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -5697,8 +5984,29 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + utils-merge@1.0.1: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@7.3.3(@types/node@24.12.4)(terser@5.47.1)(yaml@2.9.0): dependencies: esbuild: 0.27.7 diff --git a/synkronus-portal/src/api/synkronus/generated/.openapi-generator/FILES b/synkronus-portal/src/api/synkronus/generated/.openapi-generator/FILES index 03184907e..a9761a534 100644 --- a/synkronus-portal/src/api/synkronus/generated/.openapi-generator/FILES +++ b/synkronus-portal/src/api/synkronus/generated/.openapi-generator/FILES @@ -35,7 +35,11 @@ docs/GetHealth503Response.md docs/HealthApi.md docs/LoginRequest.md docs/Observation.md +docs/ObservationFormTypeCount.md docs/ObservationGeolocation.md +docs/ObservationStatsResponse.md +docs/ObservationTimeline.md +docs/ObservationTimelineBucket.md docs/ProblemDetail.md docs/ProblemDetailErrorsInner.md docs/RefreshTokenRequest.md @@ -44,6 +48,7 @@ docs/RepositoryResetResponse.md docs/ResetUserPassword200Response.md docs/ResetUserPasswordRequest.md docs/ServerInfo.md +docs/StatsApi.md docs/SwitchAppBundleVersion200Response.md docs/SyncPullRequest.md docs/SyncPullRequestSince.md diff --git a/synkronus-portal/src/api/synkronus/generated/api.ts b/synkronus-portal/src/api/synkronus/generated/api.ts index f0e406027..8dde1b5a0 100644 --- a/synkronus-portal/src/api/synkronus/generated/api.ts +++ b/synkronus-portal/src/api/synkronus/generated/api.ts @@ -299,6 +299,13 @@ export interface Observation { */ 'tags'?: Array | null; } +export interface ObservationFormTypeCount { + /** + * Form type id; empty/whitespace stored values become \"(no form type)\" + */ + 'formType': string; + 'count': number; +} /** * Optional geolocation data for the observation */ @@ -328,6 +335,55 @@ export interface ObservationGeolocation { */ 'timestamp'?: string; } +export interface ObservationStatsResponse { + /** + * Total non-deleted observations + */ + 'totalCount': number; + 'byFormType': Array; + 'timeline': ObservationTimeline; + /** + * UTC timestamp when this aggregate was computed + */ + 'computedAt': string; +} +export interface ObservationTimeline { + /** + * Bucket granularity (week when span of dated observations is >= 365 days) + */ + 'bucketUnit': ObservationTimelineBucketUnitEnum; + /** + * First bucket start date (YYYY-MM-DD), empty when there are no observations + */ + 'rangeStart': string; + /** + * Last bucket start date (YYYY-MM-DD), empty when there are no observations + */ + 'rangeEnd': string; + /** + * Dense zero-filled buckets from rangeStart through rangeEnd + */ + 'buckets': Array; +} + +export const ObservationTimelineBucketUnitEnum = { + Day: 'day', + Week: 'week', +} as const; + +export type ObservationTimelineBucketUnitEnum = typeof ObservationTimelineBucketUnitEnum[keyof typeof ObservationTimelineBucketUnitEnum]; + +export interface ObservationTimelineBucket { + /** + * Bucket start date (YYYY-MM-DD, UTC) + */ + 'bucketStart': string; + /** + * Human-readable label (e.g. \"Jan 1\") + */ + 'label': string; + 'count': number; +} export interface ProblemDetail { 'type': string; 'title': string; @@ -3208,3 +3264,120 @@ export class HealthApi extends BaseAPI { +/** + * StatsApi - axios parameter creator + */ +export const StatsApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Returns non-deleted observation totals grouped by form type and a dense activity timeline bucketed by UTC calendar day (or week when the span is at least 365 days). Soft-deleted observations are excluded. Timeline dates use `created_at` interpreted in UTC. Intended for portal/desktop overview charts; not a general-purpose query API. + * @summary Observation aggregate stats for dashboard charts + * @param {string} xOdeVersion Client semantic version; the major segment must match the server. Optional leading v/V and semver pre-release/build suffixes are accepted (same rules as Synkronus). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getObservationStats: async (xOdeVersion: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'xOdeVersion' is not null or undefined + assertParamExists('getObservationStats', 'xOdeVersion', xOdeVersion) + const localVarPath = `/api/stats/observations`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + if (xOdeVersion != null) { + localVarHeaderParameter['x-ode-version'] = String(xOdeVersion); + } + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * StatsApi - functional programming interface + */ +export const StatsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = StatsApiAxiosParamCreator(configuration) + return { + /** + * Returns non-deleted observation totals grouped by form type and a dense activity timeline bucketed by UTC calendar day (or week when the span is at least 365 days). Soft-deleted observations are excluded. Timeline dates use `created_at` interpreted in UTC. Intended for portal/desktop overview charts; not a general-purpose query API. + * @summary Observation aggregate stats for dashboard charts + * @param {string} xOdeVersion Client semantic version; the major segment must match the server. Optional leading v/V and semver pre-release/build suffixes are accepted (same rules as Synkronus). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getObservationStats(xOdeVersion: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getObservationStats(xOdeVersion, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['StatsApi.getObservationStats']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * StatsApi - factory interface + */ +export const StatsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = StatsApiFp(configuration) + return { + /** + * Returns non-deleted observation totals grouped by form type and a dense activity timeline bucketed by UTC calendar day (or week when the span is at least 365 days). Soft-deleted observations are excluded. Timeline dates use `created_at` interpreted in UTC. Intended for portal/desktop overview charts; not a general-purpose query API. + * @summary Observation aggregate stats for dashboard charts + * @param {StatsApiGetObservationStatsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getObservationStats(requestParameters: StatsApiGetObservationStatsRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getObservationStats(requestParameters.xOdeVersion, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for getObservationStats operation in StatsApi. + */ +export interface StatsApiGetObservationStatsRequest { + /** + * Client semantic version; the major segment must match the server. Optional leading v/V and semver pre-release/build suffixes are accepted (same rules as Synkronus). + */ + readonly xOdeVersion: string +} + +/** + * StatsApi - object-oriented interface + */ +export class StatsApi extends BaseAPI { + /** + * Returns non-deleted observation totals grouped by form type and a dense activity timeline bucketed by UTC calendar day (or week when the span is at least 365 days). Soft-deleted observations are excluded. Timeline dates use `created_at` interpreted in UTC. Intended for portal/desktop overview charts; not a general-purpose query API. + * @summary Observation aggregate stats for dashboard charts + * @param {StatsApiGetObservationStatsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getObservationStats(requestParameters: StatsApiGetObservationStatsRequest, options?: RawAxiosRequestConfig) { + return StatsApiFp(this.configuration).getObservationStats(requestParameters.xOdeVersion, options).then((request) => request(this.axios, this.basePath)); + } +} + + + diff --git a/synkronus-portal/src/api/synkronus/generated/docs/ObservationFormTypeCount.md b/synkronus-portal/src/api/synkronus/generated/docs/ObservationFormTypeCount.md new file mode 100644 index 000000000..90eb22afa --- /dev/null +++ b/synkronus-portal/src/api/synkronus/generated/docs/ObservationFormTypeCount.md @@ -0,0 +1,22 @@ +# ObservationFormTypeCount + + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**formType** | **string** | Form type id; empty/whitespace stored values become \"(no form type)\" | [default to undefined] +**count** | **number** | | [default to undefined] + +## Example + +```typescript +import { ObservationFormTypeCount } from './api'; + +const instance: ObservationFormTypeCount = { + formType, + count, +}; +``` + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/synkronus-portal/src/api/synkronus/generated/docs/ObservationStatsResponse.md b/synkronus-portal/src/api/synkronus/generated/docs/ObservationStatsResponse.md new file mode 100644 index 000000000..1d3df849b --- /dev/null +++ b/synkronus-portal/src/api/synkronus/generated/docs/ObservationStatsResponse.md @@ -0,0 +1,26 @@ +# ObservationStatsResponse + + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**totalCount** | **number** | Total non-deleted observations | [default to undefined] +**byFormType** | [**Array<ObservationFormTypeCount>**](ObservationFormTypeCount.md) | | [default to undefined] +**timeline** | [**ObservationTimeline**](ObservationTimeline.md) | | [default to undefined] +**computedAt** | **string** | UTC timestamp when this aggregate was computed | [default to undefined] + +## Example + +```typescript +import { ObservationStatsResponse } from './api'; + +const instance: ObservationStatsResponse = { + totalCount, + byFormType, + timeline, + computedAt, +}; +``` + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/synkronus-portal/src/api/synkronus/generated/docs/ObservationTimeline.md b/synkronus-portal/src/api/synkronus/generated/docs/ObservationTimeline.md new file mode 100644 index 000000000..a6f87d58c --- /dev/null +++ b/synkronus-portal/src/api/synkronus/generated/docs/ObservationTimeline.md @@ -0,0 +1,26 @@ +# ObservationTimeline + + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**bucketUnit** | **string** | Bucket granularity (week when span of dated observations is >= 365 days) | [default to undefined] +**rangeStart** | **string** | First bucket start date (YYYY-MM-DD), empty when there are no observations | [default to undefined] +**rangeEnd** | **string** | Last bucket start date (YYYY-MM-DD), empty when there are no observations | [default to undefined] +**buckets** | [**Array<ObservationTimelineBucket>**](ObservationTimelineBucket.md) | Dense zero-filled buckets from rangeStart through rangeEnd | [default to undefined] + +## Example + +```typescript +import { ObservationTimeline } from './api'; + +const instance: ObservationTimeline = { + bucketUnit, + rangeStart, + rangeEnd, + buckets, +}; +``` + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/synkronus-portal/src/api/synkronus/generated/docs/ObservationTimelineBucket.md b/synkronus-portal/src/api/synkronus/generated/docs/ObservationTimelineBucket.md new file mode 100644 index 000000000..ad40928ed --- /dev/null +++ b/synkronus-portal/src/api/synkronus/generated/docs/ObservationTimelineBucket.md @@ -0,0 +1,24 @@ +# ObservationTimelineBucket + + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**bucketStart** | **string** | Bucket start date (YYYY-MM-DD, UTC) | [default to undefined] +**label** | **string** | Human-readable label (e.g. \"Jan 1\") | [default to undefined] +**count** | **number** | | [default to undefined] + +## Example + +```typescript +import { ObservationTimelineBucket } from './api'; + +const instance: ObservationTimelineBucket = { + bucketStart, + label, + count, +}; +``` + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/synkronus-portal/src/api/synkronus/generated/docs/StatsApi.md b/synkronus-portal/src/api/synkronus/generated/docs/StatsApi.md new file mode 100644 index 000000000..bedb39d2c --- /dev/null +++ b/synkronus-portal/src/api/synkronus/generated/docs/StatsApi.md @@ -0,0 +1,62 @@ +# StatsApi + +All URIs are relative to *http://localhost* + +|Method | HTTP request | Description| +|------------- | ------------- | -------------| +|[**getObservationStats**](#getobservationstats) | **GET** /api/stats/observations | Observation aggregate stats for dashboard charts| + +# **getObservationStats** +> ObservationStatsResponse getObservationStats() + +Returns non-deleted observation totals grouped by form type and a dense activity timeline bucketed by UTC calendar day (or week when the span is at least 365 days). Soft-deleted observations are excluded. Timeline dates use `created_at` interpreted in UTC. Intended for portal/desktop overview charts; not a general-purpose query API. + +### Example + +```typescript +import { + StatsApi, + Configuration +} from './api'; + +const configuration = new Configuration(); +const apiInstance = new StatsApi(configuration); + +let xOdeVersion: string; //Client semantic version; the major segment must match the server. Optional leading v/V and semver pre-release/build suffixes are accepted (same rules as Synkronus). (default to undefined) + +const { status, data } = await apiInstance.getObservationStats( + xOdeVersion +); +``` + +### Parameters + +|Name | Type | Description | Notes| +|------------- | ------------- | ------------- | -------------| +| **xOdeVersion** | [**string**] | Client semantic version; the major segment must match the server. Optional leading v/V and semver pre-release/build suffixes are accepted (same rules as Synkronus). | defaults to undefined| + + +### Return type + +**ObservationStatsResponse** + +### Authorization + +[bearerAuth](../README.md#bearerAuth) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +|**200** | Observation aggregate stats | - | +|**401** | Unauthorized | - | +|**403** | Forbidden | - | +|**500** | Internal server error | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/synkronus-portal/src/components/HomePanel.tsx b/synkronus-portal/src/components/HomePanel.tsx new file mode 100644 index 000000000..dc0f74578 --- /dev/null +++ b/synkronus-portal/src/components/HomePanel.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@ode/components/react-web'; +import { HiArrowPath } from 'react-icons/hi2'; +import type { ObservationStatsResponse } from '../api/synkronus/generated'; +import { api } from '../services/api'; +import { formatOverviewCount } from '../lib/observationStatsCharts'; +import { ObservationFormTypeChart } from './charts/ObservationFormTypeChart'; +import { ObservationTimelineChart } from './charts/ObservationTimelineChart'; + +export function HomePanel() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadStats = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await api.getObservationStats(); + setStats(result); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to load observation stats', + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + + void api + .getObservationStats() + .then(result => { + if (cancelled) return; + setStats(result); + setError(null); + }) + .catch(err => { + if (cancelled) return; + setError( + err instanceof Error + ? err.message + : 'Failed to load observation stats', + ); + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + const total = stats?.totalCount ?? 0; + + return ( +
+
+
+

Home

+

+ Observation activity across this Synkronus server +

+
+
+ +
+
+ + {error && ( +
+ {error} + +
+ )} + + {loading && !stats && ( +
+ Loading observation stats… +
+ )} + + {stats && !error && total === 0 && ( +
+ No observations yet. Once devices sync data, charts will appear here. +
+ )} + + {stats && ( + <> +

+ {formatOverviewCount(total)} observation + {total === 1 ? '' : 's'} + {stats.computedAt + ? ` · updated ${new Date(stats.computedAt).toLocaleString()}` + : ''} +

+
+ + +
+ + )} +
+ ); +} diff --git a/synkronus-portal/src/components/charts/ObservationFormTypeChart.tsx b/synkronus-portal/src/components/charts/ObservationFormTypeChart.tsx new file mode 100644 index 000000000..3ebeb0698 --- /dev/null +++ b/synkronus-portal/src/components/charts/ObservationFormTypeChart.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; +import type { ObservationFormTypeCount } from '../../api/synkronus/generated'; +import { + buildFormTypeChartSlices, + formatOverviewCount, +} from '../../lib/observationStatsCharts'; +import { OverviewChartPanel } from './OverviewChartPanel'; + +export interface ObservationFormTypeChartProps { + rows: ObservationFormTypeCount[]; + totalObservations: number; +} + +export function ObservationFormTypeChart({ + rows, + totalObservations, +}: ObservationFormTypeChartProps) { + const slices = useMemo(() => buildFormTypeChartSlices(rows), [rows]); + + if (totalObservations === 0 || slices.length === 0) { + return ( + +

+ No form types to display. +

+
+ ); + } + + return ( + +
+
+ + + + {slices.map(slice => ( + + ))} + + { + const count = + typeof value === 'number' ? value : Number(value ?? 0); + const pct = + totalObservations > 0 + ? ((count / totalObservations) * 100).toFixed(1) + : '0'; + const formType = + item && typeof item === 'object' && 'payload' in item + ? String( + (item.payload as { formType?: string }).formType ?? + '', + ) + : ''; + return [`${formatOverviewCount(count)} (${pct}%)`, formType]; + }} + /> + + +
+ + {formatOverviewCount(totalObservations)} + + total +
+
+
    + {slices.map(slice => ( +
  • + + + {slice.formType} + + + {formatOverviewCount(slice.count)} + +
  • + ))} +
+
+
+ ); +} diff --git a/synkronus-portal/src/components/charts/ObservationTimelineChart.tsx b/synkronus-portal/src/components/charts/ObservationTimelineChart.tsx new file mode 100644 index 000000000..4b4466c35 --- /dev/null +++ b/synkronus-portal/src/components/charts/ObservationTimelineChart.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { ObservationTimeline } from '../../api/synkronus/generated'; +import { formatOverviewCount } from '../../lib/observationStatsCharts'; +import { OverviewChartPanel } from './OverviewChartPanel'; + +export interface ObservationTimelineChartProps { + timeline: ObservationTimeline; + totalObservations: number; +} + +export function ObservationTimelineChart({ + timeline, + totalObservations, +}: ObservationTimelineChartProps) { + const data = useMemo( + () => + timeline.buckets.map(b => ({ + label: b.label, + count: b.count, + })), + [timeline.buckets], + ); + + const subtitle = useMemo(() => { + if (!timeline.rangeStart || !timeline.rangeEnd) { + return undefined; + } + return `${timeline.rangeStart} — ${timeline.rangeEnd} · ${timeline.bucketUnit === 'week' ? 'weekly' : 'daily'} buckets`; + }, [timeline]); + + if (totalObservations === 0 || data.length === 0) { + return ( + +

+ Sync observations to see a timeline. +

+
+ ); + } + + return ( + + + + + + + { + const count = + typeof value === 'number' ? value : Number(value ?? 0); + return [formatOverviewCount(count), 'Observations']; + }} + labelFormatter={label => `Period: ${label}`} + /> + + + + + ); +} diff --git a/synkronus-portal/src/components/charts/OverviewChartPanel.tsx b/synkronus-portal/src/components/charts/OverviewChartPanel.tsx new file mode 100644 index 000000000..2ef4a2ce0 --- /dev/null +++ b/synkronus-portal/src/components/charts/OverviewChartPanel.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; + +export interface OverviewChartPanelProps { + title: string; + subtitle?: string; + children: ReactNode; + className?: string; +} + +export function OverviewChartPanel({ + title, + subtitle, + children, + className, +}: OverviewChartPanelProps) { + return ( +
+
+

{title}

+ {subtitle ? ( +

{subtitle}

+ ) : null} +
+
{children}
+
+ ); +} diff --git a/synkronus-portal/src/contexts/AuthContext.tsx b/synkronus-portal/src/contexts/AuthContext.tsx index 2b9233f1d..f86e81a7a 100644 --- a/synkronus-portal/src/contexts/AuthContext.tsx +++ b/synkronus-portal/src/contexts/AuthContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useEffect } from 'react'; +import { createContext, useContext, useState } from 'react'; import type { ReactNode } from 'react'; import { api } from '../services/api'; import type { LoginRequest, User, AuthState } from '../types/auth'; @@ -11,37 +11,37 @@ interface AuthContextType extends AuthState { const AuthContext = createContext(undefined); -export function AuthProvider({ children }: { children: ReactNode }) { - const [authState, setAuthState] = useState({ +function readStoredAuthState(): AuthState { + const token = localStorage.getItem('token'); + const refreshToken = localStorage.getItem('refreshToken'); + const userStr = localStorage.getItem('user'); + + if (token && refreshToken && userStr) { + try { + const user = JSON.parse(userStr) as User; + return { + user, + token, + refreshToken, + isAuthenticated: true, + }; + } catch { + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + } + } + + return { user: null, token: null, refreshToken: null, isAuthenticated: false, - }); + }; +} - // Load auth state from localStorage on mount - useEffect(() => { - const token = localStorage.getItem('token'); - const refreshToken = localStorage.getItem('refreshToken'); - const userStr = localStorage.getItem('user'); - - if (token && refreshToken && userStr) { - try { - const user = JSON.parse(userStr); - setAuthState({ - user, - token, - refreshToken, - isAuthenticated: true, - }); - } catch { - // Invalid stored data, clear it - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); - } - } - }, []); +export function AuthProvider({ children }: { children: ReactNode }) { + const [authState, setAuthState] = useState(readStoredAuthState); const login = async (credentials: LoginRequest) => { const response = await api.login(credentials); diff --git a/synkronus-portal/src/lib/observationStatsCharts.ts b/synkronus-portal/src/lib/observationStatsCharts.ts new file mode 100644 index 000000000..4bcd9ac3b --- /dev/null +++ b/synkronus-portal/src/lib/observationStatsCharts.ts @@ -0,0 +1,71 @@ +import type { ObservationFormTypeCount } from '../api/synkronus/generated'; + +/** Series colors tuned for the portal light/dark themes. */ +export const OVERVIEW_CHART_COLORS = [ + '#6b8ae8', + '#5cb88a', + '#e0a86a', + '#c77dff', + '#56cfe1', + '#ff8f8f', + '#90a3cb', + '#ffb95f', +] as const; + +export const OVERVIEW_CHART_OTHER_COLOR = '#5a6478'; + +const MAX_PIE_SLICES = 8; + +export interface FormTypeChartSlice { + formType: string; + count: number; + color: string; +} + +export function colorForFormType(formType: string, index: number): string { + if (formType.startsWith('Other')) { + return OVERVIEW_CHART_OTHER_COLOR; + } + return OVERVIEW_CHART_COLORS[index % OVERVIEW_CHART_COLORS.length]; +} + +/** Collapse form-type rows to at most 8 pie slices (top 7 + Other). */ +export function buildFormTypeChartSlices( + rows: ObservationFormTypeCount[], +): FormTypeChartSlice[] { + if (rows.length === 0) { + return []; + } + + const sorted = [...rows].sort((a, b) => b.count - a.count); + + if (sorted.length <= MAX_PIE_SLICES) { + return sorted.map((row, i) => ({ + formType: row.formType, + count: row.count, + color: colorForFormType(row.formType, i), + })); + } + + const head = sorted.slice(0, MAX_PIE_SLICES - 1); + const tail = sorted.slice(MAX_PIE_SLICES - 1); + const otherCount = tail.reduce((sum, row) => sum + row.count, 0); + + const slices: FormTypeChartSlice[] = head.map((row, i) => ({ + formType: row.formType, + count: row.count, + color: colorForFormType(row.formType, i), + })); + + slices.push({ + formType: `Other (${tail.length} types)`, + count: otherCount, + color: OVERVIEW_CHART_OTHER_COLOR, + }); + + return slices; +} + +export function formatOverviewCount(n: number): string { + return n.toLocaleString(); +} diff --git a/synkronus-portal/src/pages/Dashboard.css b/synkronus-portal/src/pages/Dashboard.css index bb298718b..582e9812c 100644 --- a/synkronus-portal/src/pages/Dashboard.css +++ b/synkronus-portal/src/pages/Dashboard.css @@ -169,6 +169,22 @@ html::-webkit-scrollbar-thumb:hover { position: relative; } +button.logo-home-link { + background: none; + border: none; + padding: 0; + cursor: pointer; + font: inherit; + color: inherit; + text-align: left; +} + +button.logo-home-link:focus-visible { + outline: 2px solid var(--color-brand-primary-500); + outline-offset: 4px; + border-radius: 8px; +} + .logo-icon { width: var(--icon-size-2xl); height: var(--icon-size-2xl); @@ -4656,3 +4672,188 @@ html::-webkit-scrollbar-thumb:hover { [data-theme='light'] .info-card:nth-child(even) .info-icon svg { color: var(--color-brand-primary-500); } + +/* ---- Home overview charts + footer ---- */ +.dashboard-footer { + text-align: center; + padding: var(--spacing-4) var(--spacing-6) var(--spacing-6); + margin-top: auto; +} + +.dashboard-footer .version-text { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); + font-weight: var(--font-weight-regular); + opacity: var(--opacity-7); + letter-spacing: var(--letter-spacing-wide); + text-transform: uppercase; +} + +[data-theme='light'] .dashboard-footer .version-text { + color: var(--color-text-tertiary-light); +} + +.home-section { + --home-chart-tick: #90a3cb; + --home-chart-axis: #2d3449; + --home-chart-tooltip-bg: #131b2e; + --home-chart-tooltip-border: #2d3449; + --home-chart-tooltip-fg: #e8edf8; + --home-chart-donut-stroke: var(--color-background-card, #131b2e); +} + +[data-theme='light'] .home-section { + --home-chart-tick: #5a6a85; + --home-chart-axis: #c5cddb; + --home-chart-tooltip-bg: #ffffff; + --home-chart-tooltip-border: #d0d7e2; + --home-chart-tooltip-fg: #1a2333; + --home-chart-donut-stroke: #ffffff; +} + +.home-stats-loading, +.home-stats-empty { + padding: var(--spacing-8) 0; + text-align: center; +} + +.home-stats-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-3); + margin-bottom: var(--spacing-4); +} + +.home-stats-summary { + margin: 0 0 var(--spacing-4); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.home-overview-charts-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-4); + align-items: stretch; +} + +@media (max-width: 900px) { + .home-overview-charts-row { + grid-template-columns: 1fr; + } +} + +.home-overview-chart { + display: flex; + flex-direction: column; + min-height: 0; + margin: 0; + padding: var(--spacing-4); + border-radius: var(--border-radius-lg, 12px); + background: var(--color-background-card); + border: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.08)); +} + +[data-theme='light'] .home-overview-chart { + border-color: var(--color-border-default); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.home-overview-chart-header h3 { + margin: 0; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.home-overview-chart-subtitle { + margin: 0.35rem 0 0; + font-size: var(--font-size-sm); +} + +.home-overview-chart-body { + margin-top: var(--spacing-3); + min-height: 0; +} + +.home-overview-chart-empty { + margin: 0; + padding: var(--spacing-6) 0; + text-align: center; +} + +.home-overview-donut-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--spacing-3); + align-items: center; +} + +@media (max-width: 640px) { + .home-overview-donut-layout { + grid-template-columns: 1fr; + } +} + +.home-overview-donut-wrap { + position: relative; + min-height: 240px; +} + +.home-overview-donut-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.home-overview-donut-total { + font-size: 1.35rem; + font-weight: 700; + color: var(--color-text-primary); +} + +.home-overview-legend { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; + max-height: 240px; + overflow-y: auto; +} + +.home-overview-legend li { + display: grid; + grid-template-columns: 0.75rem 1fr auto; + gap: 0.5rem; + align-items: center; + font-size: var(--font-size-sm); +} + +.home-overview-legend-swatch { + width: 0.75rem; + height: 0.75rem; + border-radius: 999px; + flex-shrink: 0; +} + +.home-overview-legend-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-overview-legend-count { + font-variant-numeric: tabular-nums; +} + +.muted { + color: var(--color-text-tertiary); +} diff --git a/synkronus-portal/src/pages/Dashboard.tsx b/synkronus-portal/src/pages/Dashboard.tsx index 88613d404..a6ae1b590 100644 --- a/synkronus-portal/src/pages/Dashboard.tsx +++ b/synkronus-portal/src/pages/Dashboard.tsx @@ -16,6 +16,8 @@ import { HiCube, HiOutlineUsers, HiUsers, + HiOutlineHome, + HiHome, HiCheckCircle, HiExclamationTriangle, HiArrowUpTray, @@ -36,6 +38,7 @@ import { ColorBrandPrimary500 } from '@ode/tokens'; import portalLogo from '../assets/portal.png'; import dashboardBackgroundDark from '../assets/dashboard-background.png'; import dashboardBackgroundLight from '../assets/dashboard-background-light.png'; +import { HomePanel } from '../components/HomePanel'; import './Dashboard.css'; import type { UserListItem } from '../api/synkronus/generated'; @@ -49,7 +52,7 @@ function userMatchesSearch(u: UserListItem, query: string): boolean { ); } -type TabType = 'users' | 'app-bundles' | 'data-export'; +type TabType = 'home' | 'users' | 'app-bundles' | 'data-export'; interface AppBundleVersion { version: string; @@ -84,12 +87,13 @@ interface AppBundleVersionsResponse { export function Dashboard() { const { user, logout } = useAuth(); const { resolvedTheme } = useTheme(); - const [activeTab, setActiveTab] = useState('users'); + const [activeTab, setActiveTab] = useState('home'); const [appBundles, setAppBundles] = useState([]); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [serverVersion, setServerVersion] = useState(null); const [uploadProgress, setUploadProgress] = useState(0); const fileInputRef = useRef(null); /** Prevents overlapping listUsers calls without putting `loading` in loadUsers deps (which would retrigger the initial-load effect). */ @@ -362,15 +366,37 @@ export function Dashboard() { } }; - // Initial load: after login, the "users" tab is already active, but - // handleTabChange() won't run again. So explicitly fetch users when the - // logged-in user becomes an admin and the Users tab is active. + // Users load when switching to the Users tab (see handleTabChange). + // Home is the default tab, so no initial users fetch on mount. + useEffect(() => { - if (activeTab !== 'users') return; - if (user?.role !== 'admin') return; - if (users.length !== 0) return; - loadUsers(); - }, [activeTab, user?.role, users.length, loadUsers]); + const fetchVersion = async () => { + try { + const data = await api.getVersion(); + const version = data.server?.version || data.version; + if (version) { + setServerVersion(version); + return; + } + } catch { + // Continue to health fallback below. + } + + try { + const health = await api.getHealth(); + if (typeof health?.version === 'string') { + setServerVersion(health.version); + return; + } + } catch (err) { + console.debug('Failed to fetch server version:', err); + } + + setServerVersion('Unknown'); + }; + + void fetchVersion(); + }, []); const handleUploadClick = () => { if (loading) return; @@ -645,14 +671,18 @@ export function Dashboard() { }>
-
+
+
Welcome back: @@ -740,6 +770,44 @@ export function Dashboard() {