From 137ff6289ebbdf30321c76e626d054db50f785d3 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Thu, 12 Dec 2024 17:17:18 +0000 Subject: [PATCH 01/11] Separate router Move router out of index --- compose.yaml | 6 +- frontend/src/App.tsx | 60 ++--------- .../src/components/{home.tsx => intro.tsx} | 2 +- frontend/src/index.tsx | 100 +----------------- frontend/src/router.tsx | 92 ++++++++++++++++ frontend/src/simulation.tsx | 54 ++++++++++ 6 files changed, 161 insertions(+), 153 deletions(-) rename frontend/src/components/{home.tsx => intro.tsx} (96%) create mode 100644 frontend/src/router.tsx create mode 100644 frontend/src/simulation.tsx diff --git a/compose.yaml b/compose.yaml index 8c836503..c905adc7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -119,7 +119,7 @@ services: POSTGRES_URL: jdbc:postgresql://postgres:5432/test ## Recompiles on source code changes. - ## Do ensure that the two Gradle containers have independent Home directories and Project cache directories. + ## Do ensure that the two Gradle containers have independent Intro directories and Project cache directories. ztor-build-once: extends: service: gradle-base @@ -128,7 +128,7 @@ services: command: ztor:buildFatJar --no-daemon ## Recompiles on source code changes. - ## Do ensure that the two Gradle containers have independent Home directories and Project cache directories. + ## Do ensure that the two Gradle containers have independent Intro directories and Project cache directories. ztor-build: extends: service: gradle-base @@ -137,7 +137,7 @@ services: command: ztor:classes ## Runs Ktor web server and reloads classes if the compiled .class file changes. - ## Do ensure that the two Gradle containers have independent Home directories and Project cache directories. + ## Do ensure that the two Gradle containers have independent Intro directories and Project cache directories. ztor-run: extends: service: ztor-gradle-with-db diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a40e3a1..dbb5d3b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,54 +1,10 @@ -import 'leaflet/dist/leaflet.css' -import React, {createElement as h, useState} from 'react' -import './App.css' -import {AggregatedAreaData} from './components/aggregated-area-data' -import {AnyLogic} from './components/any-logic' -import {BuurtPicker} from './components/buurt-picker' -import {MainMap} from './components/main-map' -import {PandDataDisplay} from './components/pand-display' -import {useApp} from './services/appState' -import {assertDefined} from './services/util' -import {Buurt} from './services/wijkenbuurten/buurten' -import {ZeroLayout} from "./components/zero-layout" - -function App() { - const appHook = useApp() - const {setGeometry, getPandData, bag2dPanden} = appHook - - const [currentPandId, setCurrentPandId] = useState('') - - const [buurt, setBuurt] = useState() +import React, {FunctionComponent} from 'react' +import {Outlet} from "react-router-dom"; +export const App: FunctionComponent = () => { return ( - - {/* Three-column layout*/} -
-
- {h(AggregatedAreaData, {appHook: appHook})} - { - setBuurt(buurt) - setGeometry(buurt.geometry) - }}/> -
-
- - -
-
- {currentPandId && getPandData(currentPandId) && - } -
-
-
- ) -} - -export default App + <> + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/home.tsx b/frontend/src/components/intro.tsx similarity index 96% rename from frontend/src/components/home.tsx rename to frontend/src/components/intro.tsx index e3248db8..281d4f6a 100644 --- a/frontend/src/components/home.tsx +++ b/frontend/src/components/intro.tsx @@ -2,7 +2,7 @@ import {FunctionComponent} from "react" import {ZeroLayout} from "./zero-layout" import {Link} from "react-router-dom" -export const Home: FunctionComponent = () => ( +export const Intro: FunctionComponent = () => (

Welkom bij Zenmo Zero

diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index b57d66b5..4a42ae42 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,106 +1,12 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import { - createBrowserRouter, - RouterProvider, -} from "react-router-dom"; import './index.css' -import {DE_WIEKEN, getProjectConfiguration, HESSENPOORT, ProjectName} from "./components/company-survey-v2/project" -import {Survey, SurveyFromProject} from "./components/company-survey-v2/survey" -import {ThankYou} from './components/thank-you' import reportWebVitals from './reportWebVitals' -import App from "./App"; -import {LoginWidget} from "./user/login"; -import {BedrijvenFormV1} from "./components/bedrijven-form-v1"; -import {Admin} from "./admin/admin"; -import { - fetchSurveyById, - SurveyById, - SurveyByIdLoaderData, - SurveyByIdRouteData, -} from "./components/company-survey-v2/survey-by-id" -import {Home} from "./components/home" -import {ExcelImport} from "./excel-import/excel-import" -import {NewSurveyByProjectName} from "./components/company-survey-v2/new-survey-by-project-name" -import {fetchBuurtcodesByProject} from "./panden-select/fetch-buurtcodes" -import {assertDefined} from "./services/util" - -const router = createBrowserRouter([ - { - path: "/", - element: , - }, - { - path: "/proof-of-concept", - element: , - }, - { - path: "/bedrijven-v1", - element: - }, - { - path: "/new-survey/:projectName", - element: , - loader: async ({params: {projectName}, request}) => { - return getProjectConfiguration(assertDefined(projectName) as ProjectName) - } - }, - { - path: "/bedrijven-hessenpoort", - element: , - loader: async () => getProjectConfiguration(HESSENPOORT.name) - }, - { - path: "/bedrijven-de-wieken", - element: , - loader: async () => getProjectConfiguration(DE_WIEKEN.name) - }, - { - path: "/bedrijven-uitvraag/:surveyId", - element: , - loader: async ({params: {surveyId}, request}): Promise => { - if (!surveyId) { - throw new Error("Survey ID is required") - } - const url = new URL(request.url); - const deeplink = url.searchParams.get("deeplink"); - const secret = url.searchParams.get("secret"); - - const survey = await fetchSurveyById({ - surveyId, - deeplink, - secret, - }) - - const project = await getProjectConfiguration(survey.zenmoProject) - - return { - survey, - project, - } - } - }, - { - path: "/bedankt", - element: , - }, - { - path: "/admin", - element: , - }, - { - path: "/admin/import-excel", - element: - }, - { - path: "/login", - element: - } -]); +import { RouterProvider } from "react-router-dom"; +import { router } from "./router"; const root = ReactDOM.createRoot( - //@ts-ignore - document.getElementById('react-root'), + document.getElementById('react-root') as HTMLElement, ) root.render( diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 00000000..fa8da1c8 --- /dev/null +++ b/frontend/src/router.tsx @@ -0,0 +1,92 @@ +import { createBrowserRouter } from "react-router-dom"; + +import {DE_WIEKEN, getProjectConfiguration, HESSENPOORT, ProjectName} from "./components/company-survey-v2/project" +import {SurveyFromProject} from "./components/company-survey-v2/survey" +import {ThankYou} from './components/thank-you' +import {LoginWidget} from "./user/login"; +import {BedrijvenFormV1} from "./components/bedrijven-form-v1"; +import {Admin} from "./admin/admin"; +import {fetchSurveyById, SurveyById, SurveyByIdLoaderData} from "./components/company-survey-v2/survey-by-id" +import {Intro} from "./components/intro" +import {ExcelImport} from "./excel-import/excel-import" +import {NewSurveyByProjectName} from "./components/company-survey-v2/new-survey-by-project-name" +import {assertDefined} from "./services/util" +import Simulation from "./simulation"; +import {App} from "./App"; + +export const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + {path: "", element: }, + {path: "/simulation", element: }, + ], + }, + { + path: "/bedrijven-v1", + element: + }, + { + path: "/proof-of-concept", + element: , + }, + { + path: "/new-survey/:projectName", + element: , + loader: async ({params: {projectName}, request}) => { + return getProjectConfiguration(assertDefined(projectName) as ProjectName) + } + }, + { + path: "/bedrijven-hessenpoort", + element: , + loader: async () => getProjectConfiguration(HESSENPOORT.name) + }, + { + path: "/bedrijven-de-wieken", + element: , + loader: async () => getProjectConfiguration(DE_WIEKEN.name) + }, + { + path: "/bedrijven-uitvraag/:surveyId", + element: , + loader: async ({params: {surveyId}, request}): Promise => { + if (!surveyId) { + throw new Error("Survey ID is required") + } + const url = new URL(request.url); + const deeplink = url.searchParams.get("deeplink"); + const secret = url.searchParams.get("secret"); + + const survey = await fetchSurveyById({ + surveyId, + deeplink, + secret, + }) + + const project = await getProjectConfiguration(survey.zenmoProject) + + return { + survey, + project, + } + } + }, + { + path: "/bedankt", + element: , + }, + { + path: "/admin", + element: , + }, + { + path: "/admin/import-excel", + element: + }, + { + path: "/login", + element: + } +]); diff --git a/frontend/src/simulation.tsx b/frontend/src/simulation.tsx new file mode 100644 index 00000000..7bf3e18d --- /dev/null +++ b/frontend/src/simulation.tsx @@ -0,0 +1,54 @@ +import 'leaflet/dist/leaflet.css' +import React, {createElement as h, useState} from 'react' +import './App.css' +import {AggregatedAreaData} from './components/aggregated-area-data' +import {AnyLogic} from './components/any-logic' +import {BuurtPicker} from './components/buurt-picker' +import {MainMap} from './components/main-map' +import {PandDataDisplay} from './components/pand-display' +import {useApp} from './services/appState' +import {assertDefined} from './services/util' +import {Buurt} from './services/wijkenbuurten/buurten' +import {ZeroLayout} from "./components/zero-layout" + +function Simulation() { + const appHook = useApp() + const {setGeometry, getPandData, bag2dPanden} = appHook + + const [currentPandId, setCurrentPandId] = useState('') + + const [buurt, setBuurt] = useState() + + return ( + + {/* Three-column layout*/} +
+
+ {h(AggregatedAreaData, {appHook: appHook})} + { + setBuurt(buurt) + setGeometry(buurt.geometry) + }}/> +
+
+ + +
+
+ {currentPandId && getPandData(currentPandId) && + } +
+
+
+ ) +} + +export default Simulation From ef0e528ac9fd5c135d189abe97d696ae74a49670 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Fri, 13 Dec 2024 16:52:57 +0000 Subject: [PATCH 02/11] Add new layout - header and side-bar --- frontend/src/App.tsx | 6 +- frontend/src/admin/{admin.tsx => surveys.tsx} | 2 +- frontend/src/components/zero-header.tsx | 82 +++++++++++++++---- frontend/src/components/zero-layout.tsx | 3 +- frontend/src/router.tsx | 5 +- 5 files changed, 77 insertions(+), 21 deletions(-) rename frontend/src/admin/{admin.tsx => surveys.tsx} (98%) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbb5d3b0..d02333e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,12 @@ import React, {FunctionComponent} from 'react' +import {ZeroHeader} from "./components/zero-header"; import {Outlet} from "react-router-dom"; export const App: FunctionComponent = () => { return ( <> - + + ); -}; \ No newline at end of file +}; diff --git a/frontend/src/admin/admin.tsx b/frontend/src/admin/surveys.tsx similarity index 98% rename from frontend/src/admin/admin.tsx rename to frontend/src/admin/surveys.tsx index 06f65d4c..e693a1bb 100644 --- a/frontend/src/admin/admin.tsx +++ b/frontend/src/admin/surveys.tsx @@ -16,7 +16,7 @@ import {ZeroLayout} from "../components/zero-layout" import {AdminButtonRow} from "./admin-button-row" import {SurveyIncludeInSimulationCheckbox} from "./survey-include-in-simulation-checkbox" -export const Admin: FunctionComponent = () => { +export const Surveys: FunctionComponent = () => { const {loading, surveys, changeSurvey, removeSurvey} = useSurveys() const multipleProjects = surveys.map(survey => survey.zenmoProject) diff --git a/frontend/src/components/zero-header.tsx b/frontend/src/components/zero-header.tsx index 4d92f27f..4b3773f0 100644 --- a/frontend/src/components/zero-header.tsx +++ b/frontend/src/components/zero-header.tsx @@ -1,16 +1,68 @@ -import React from "react"; +import React, {FunctionComponent, PropsWithChildren, useState} from "react" +import {Button} from "primereact/button"; +import {Sidebar} from "primereact/sidebar"; +import {css} from "@emotion/react"; +import {To, useNavigate} from "react-router-dom"; -export const ZeroHeader = () => ( -

- -   - Zenmo Zero -

-) \ No newline at end of file +const sidebarStyle = css({ + width: '250px', + backgroundColor: '#f5f5f5', + borderRight: '1px solid #ddd', +}); + +const buttonStyle = css({ + display: 'block', + marginBottom: '45px', + width: '100%', + + textAlign: 'left', + padding: '0.5em 1em', + border: 'none', + borderBottom: '1px solid #ddd', + color: '#333', + background: '#f5f5f5', + transition: 'background-color 0.2s ease-in-out', + fontWeight: 'normal', + cursor: 'pointer', + '&:hover': { + backgroundColor: '#ebebeb', + color: '#007ad9', + }, +}); + +export const ZeroHeader: FunctionComponent = () => { + const [visible, setVisible] = useState(false); + const navigate = useNavigate(); + + const loadContent = (navidateTo: To) => { + setVisible(false); + navigate(navidateTo) + } + return ( +
+
+
+ setVisible(false)} css={sidebarStyle}> +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/zero-layout.tsx b/frontend/src/components/zero-layout.tsx index 2a136c3e..00391cae 100644 --- a/frontend/src/components/zero-layout.tsx +++ b/frontend/src/components/zero-layout.tsx @@ -29,4 +29,5 @@ export const ZeroLayout: FunctionComponent} {children}
-) \ No newline at end of file +) + diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index fa8da1c8..83c7b99c 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -5,7 +5,7 @@ import {SurveyFromProject} from "./components/company-survey-v2/survey" import {ThankYou} from './components/thank-you' import {LoginWidget} from "./user/login"; import {BedrijvenFormV1} from "./components/bedrijven-form-v1"; -import {Admin} from "./admin/admin"; +import {Surveys} from "./admin/surveys"; import {fetchSurveyById, SurveyById, SurveyByIdLoaderData} from "./components/company-survey-v2/survey-by-id" import {Intro} from "./components/intro" import {ExcelImport} from "./excel-import/excel-import" @@ -20,6 +20,7 @@ export const router = createBrowserRouter([ element: , children: [ {path: "", element: }, + {path: "/surveys", element: }, {path: "/simulation", element: }, ], }, @@ -79,7 +80,7 @@ export const router = createBrowserRouter([ }, { path: "/admin", - element: , + element: , }, { path: "/admin/import-excel", From fb4fd67234f387e6cebd1ddcabf1f2872e0c139c Mon Sep 17 00:00:00 2001 From: Erik van Velzen Date: Wed, 18 Dec 2024 14:40:26 +0100 Subject: [PATCH 03/11] Reduce pagination threshold for BAG panden (#192) It wasn't working for Hoeksche Waard. --- frontend/src/services/bag2d.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/services/bag2d.ts b/frontend/src/services/bag2d.ts index 108fa3bd..d2645393 100644 --- a/frontend/src/services/bag2d.ts +++ b/frontend/src/services/bag2d.ts @@ -62,17 +62,18 @@ export async function fetchBag2dPanden(boundingBox: LatLngBounds, startIndex = 0 const json = await response.json() as ResponseBody - // Recursively fetch the next page. - // // There seems no consistent indication of whether there are more results. - // We assume this is the last page if there are less than 900 results. + // We assume this is the last page if there are less results than below. + const paginationThreshold = 800 + + // Recursively fetch the next page. // // There is sometimes overlap between the last items of the previous page // and the first items of the next page. // We remove these duplicates by using a Map. return json.features.reduce((map, feature) => { return map.set(BigInt(feature.properties.identificatie), feature) - }, json.features.length > 900 ? await fetchBag2dPanden(boundingBox, startIndex + json.features.length) : new Map()) + }, json.features.length > paginationThreshold ? await fetchBag2dPanden(boundingBox, startIndex + json.features.length) : new Map()) } // definitie van de Nederlandse coƶrdinatenstelsel From db6758b6e1c9c6d308c4c37b01703b4ab54d1856 Mon Sep 17 00:00:00 2001 From: Erik van Velzen Date: Wed, 18 Dec 2024 14:40:53 +0100 Subject: [PATCH 04/11] Fix usePromise caching (#194) --- frontend/src/hooks/use-promise.ts | 2 +- frontend/src/panden-select/panden-select-loader.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/use-promise.ts b/frontend/src/hooks/use-promise.ts index 5092d791..e3015f71 100644 --- a/frontend/src/hooks/use-promise.ts +++ b/frontend/src/hooks/use-promise.ts @@ -17,7 +17,7 @@ type UsePromiseReturn = [ type Writeable = { -readonly [P in keyof T]: T[P] }; export function usePromise(promiseFn: () => Promise, deps?: readonly any[]): UsePromiseReturn { - const [result, error, state] = useLibPromise(promiseFn(), deps as Writeable) + const [result, error, state] = useLibPromise(promiseFn, deps as Writeable) // @ts-ignore return [ diff --git a/frontend/src/panden-select/panden-select-loader.tsx b/frontend/src/panden-select/panden-select-loader.tsx index b4499846..9152a07f 100644 --- a/frontend/src/panden-select/panden-select-loader.tsx +++ b/frontend/src/panden-select/panden-select-loader.tsx @@ -29,8 +29,8 @@ export const PandenSelectLoader: FunctionComponent<{ return

Geen project gevonden

} const [result, error, pending] = usePromise( - async () => fetchBuurtenAndPanden(buurtcodes), - buurtcodes, + () => fetchBuurtenAndPanden(buurtcodes), + [buurtcodes.join()], ) if (error) { From 5740b1cd66f11b85ddfdea3d9876cc72139e214c Mon Sep 17 00:00:00 2001 From: Erik van Velzen Date: Wed, 18 Dec 2024 14:56:33 +0100 Subject: [PATCH 05/11] Panden selection: dynamic zoom (#193) Adjust map zoom to area size. --- frontend/src/panden-select/panden-select.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/panden-select/panden-select.tsx b/frontend/src/panden-select/panden-select.tsx index c5db8e5f..15c6341b 100644 --- a/frontend/src/panden-select/panden-select.tsx +++ b/frontend/src/panden-select/panden-select.tsx @@ -3,7 +3,7 @@ import {BuurtFeatureCollection, getBuurtCenter} from "../services/wijkenbuurten/ import {Bag2DPand} from "../services/bag2d" import {GeoJSON, MapContainer, TileLayer} from "react-leaflet" import {featureCollection} from "@turf/helpers" -import {geoJsonPositionToLeaflet} from "../services/geometry" +import {geoJsonPositionToLeaflet, geometryToBoundingBox} from "../services/geometry" import center from "@turf/center" import {LeafletMouseEventHandlerFn} from "leaflet" import {PandID} from "zero-zummon" @@ -52,7 +52,7 @@ export const PandenSelect: FunctionComponent<{ return ( ) -} \ No newline at end of file +} From 23e5b6119b918afd3d1a71b6ff8731d500eb0d3c Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Wed, 18 Dec 2024 23:54:06 +0000 Subject: [PATCH 06/11] Clean ups --- frontend/src/router.tsx | 2 +- frontend/src/simulation.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index fa8da1c8..2c591766 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -11,7 +11,7 @@ import {Intro} from "./components/intro" import {ExcelImport} from "./excel-import/excel-import" import {NewSurveyByProjectName} from "./components/company-survey-v2/new-survey-by-project-name" import {assertDefined} from "./services/util" -import Simulation from "./simulation"; +import {Simulation} from "./simulation"; import {App} from "./App"; export const router = createBrowserRouter([ diff --git a/frontend/src/simulation.tsx b/frontend/src/simulation.tsx index 7bf3e18d..2e028fa4 100644 --- a/frontend/src/simulation.tsx +++ b/frontend/src/simulation.tsx @@ -1,5 +1,5 @@ import 'leaflet/dist/leaflet.css' -import React, {createElement as h, useState} from 'react' +import React, {createElement as h, FunctionComponent, useState} from 'react' import './App.css' import {AggregatedAreaData} from './components/aggregated-area-data' import {AnyLogic} from './components/any-logic' @@ -11,7 +11,7 @@ import {assertDefined} from './services/util' import {Buurt} from './services/wijkenbuurten/buurten' import {ZeroLayout} from "./components/zero-layout" -function Simulation() { +export const Simulation: FunctionComponent<{}> = () => { const appHook = useApp() const {setGeometry, getPandData, bag2dPanden} = appHook @@ -51,4 +51,3 @@ function Simulation() { ) } -export default Simulation From 069a8a90b9eba861436b6be852e49afb3cf3e371 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Wed, 18 Dec 2024 23:56:42 +0000 Subject: [PATCH 07/11] Clean up --- compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.yaml b/compose.yaml index c905adc7..8c836503 100644 --- a/compose.yaml +++ b/compose.yaml @@ -119,7 +119,7 @@ services: POSTGRES_URL: jdbc:postgresql://postgres:5432/test ## Recompiles on source code changes. - ## Do ensure that the two Gradle containers have independent Intro directories and Project cache directories. + ## Do ensure that the two Gradle containers have independent Home directories and Project cache directories. ztor-build-once: extends: service: gradle-base @@ -128,7 +128,7 @@ services: command: ztor:buildFatJar --no-daemon ## Recompiles on source code changes. - ## Do ensure that the two Gradle containers have independent Intro directories and Project cache directories. + ## Do ensure that the two Gradle containers have independent Home directories and Project cache directories. ztor-build: extends: service: gradle-base @@ -137,7 +137,7 @@ services: command: ztor:classes ## Runs Ktor web server and reloads classes if the compiled .class file changes. - ## Do ensure that the two Gradle containers have independent Intro directories and Project cache directories. + ## Do ensure that the two Gradle containers have independent Home directories and Project cache directories. ztor-run: extends: service: ztor-gradle-with-db From e05d146823f5a2741c919d7df11b940cb02bebc9 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Thu, 19 Dec 2024 10:40:49 +0000 Subject: [PATCH 08/11] Fix import --- frontend/src/App.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 9053a53d..3287c07b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,6 +1,6 @@ import {render, screen} from '@testing-library/react' import React from 'react' -import App from './App' +import {App} from './App' test('renders learn react link', () => { render() From ecba67ce75611dddb12f041391a98ec6b7ba8bd3 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Thu, 19 Dec 2024 11:59:20 +0000 Subject: [PATCH 09/11] clean code --- frontend/src/components/zero-header.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/zero-header.tsx b/frontend/src/components/zero-header.tsx index 4b3773f0..7b5f1b93 100644 --- a/frontend/src/components/zero-header.tsx +++ b/frontend/src/components/zero-header.tsx @@ -5,14 +5,14 @@ import {css} from "@emotion/react"; import {To, useNavigate} from "react-router-dom"; const sidebarStyle = css({ - width: '250px', + width: '16rem', backgroundColor: '#f5f5f5', borderRight: '1px solid #ddd', }); const buttonStyle = css({ display: 'block', - marginBottom: '45px', + marginBottom: '3rem', width: '100%', textAlign: 'left', @@ -58,10 +58,20 @@ export const ZeroHeader: FunctionComponent = () => { Zenmo Zero + setVisible(false)} css={sidebarStyle}> - ) -} \ No newline at end of file +} diff --git a/frontend/src/components/company-survey-v2/electricity-consumption-radios.tsx b/frontend/src/components/company-survey-v2/electricity-consumption-radios.tsx index 3eb3102d..c3ff6983 100644 --- a/frontend/src/components/company-survey-v2/electricity-consumption-radios.tsx +++ b/frontend/src/components/company-survey-v2/electricity-consumption-radios.tsx @@ -4,6 +4,8 @@ import {ProjectConfiguration} from './project' export enum ConsumptionSpec { PLACEHOLDER_AUTHORIZATION = "PLACEHOLDER_AUTHORIZATION", PDF_AUTHORIZATION = "PDF_AUTHORIZATION", + TEXTAREA = "TEXTAREA", + // Option unused as we have not automated this proces UPLOAD_QUARTER_HOURLY_VALUES = "UPLOAD_QUARTER_HOURLY_VALUES", // Option removed as per feedback // ANNUAL_VALUES = "ANNUAL_VALUES", @@ -12,6 +14,7 @@ export enum ConsumptionSpec { const labels = { [ConsumptionSpec.PLACEHOLDER_AUTHORIZATION]: "Ik wil jullie machtigen voor het ophalen van de meetdata", [ConsumptionSpec.PDF_AUTHORIZATION]: "Ik wil jullie machtigen voor het ophalen van de meetdata", + [ConsumptionSpec.TEXTAREA]: "Kwartierwaarden plakken", [ConsumptionSpec.UPLOAD_QUARTER_HOURLY_VALUES]: "Kwartierwaarden uploaden", // [ConsumptionSpec.ANNUAL_VALUES]: "Jaarverbruik invullen", } @@ -32,9 +35,9 @@ export const ElectricityConsumptionRadios = ({onChange, consumptionSpec, project Ik wil jullie machtigen voor het ophalen van de meetdata } - - Kwartierwaarden uploaden + + Ik wil kwartierwaarden kopiƫren en plakken ) -} \ No newline at end of file +} diff --git a/frontend/src/components/company-survey-v2/electricity-data.tsx b/frontend/src/components/company-survey-v2/electricity-data.tsx index ef28f253..45f44e5f 100644 --- a/frontend/src/components/company-survey-v2/electricity-data.tsx +++ b/frontend/src/components/company-survey-v2/electricity-data.tsx @@ -1,9 +1,10 @@ -import {useState} from 'react' -import {UseFormReturn} from 'react-hook-form' -import {ConsumptionSpec, ElectricityConsumptionRadios} from './electricity-consumption-radios' -import {LabelRow} from './generic/label-row' -import {Purpose, Upload} from './generic/upload' -import {ProjectConfiguration} from './project' +import {useState} from "react" +import {UseFormReturn} from "react-hook-form" +import {ConsumptionSpec, ElectricityConsumptionRadios} from "./electricity-consumption-radios" +import {LabelRow} from "./generic/label-row" +import {Purpose, Upload} from "./generic/upload" +import {ProjectConfiguration} from "./project" +import {TimeSeriesElectricity} from "./time-series/time-series-electricity" export const ElectricityData = ({form, prefix, project}: { form: UseFormReturn, @@ -62,6 +63,9 @@ export const ElectricityData = ({form, prefix, project}: { purpose={Purpose.ELECTRICITY_AUTHORIZATION} /> )} + {consumptionSpec === ConsumptionSpec.TEXTAREA && ( + + )} {/*{consumptionSpec === ConsumptionSpec.ANNUAL_VALUES && (*/} {/* <>*/} {/* ) -} \ No newline at end of file +} diff --git a/frontend/src/components/company-survey-v2/generic/label-row.tsx b/frontend/src/components/company-survey-v2/generic/label-row.tsx index 740c02dd..53d171e3 100644 --- a/frontend/src/components/company-survey-v2/generic/label-row.tsx +++ b/frontend/src/components/company-survey-v2/generic/label-row.tsx @@ -1,33 +1,31 @@ import {css} from '@emotion/react' -import {PropsWithChildren} from 'react' +import {FunctionComponent, PropsWithChildren} from "react" import {flash} from '../flash' -export const LabelRow = ({label, children}: PropsWithChildren<{label: any}>) => { - return ( - +) diff --git a/frontend/src/components/company-survey-v2/time-series/time-series-electricity.tsx b/frontend/src/components/company-survey-v2/time-series/time-series-electricity.tsx new file mode 100644 index 00000000..7f47e060 --- /dev/null +++ b/frontend/src/components/company-survey-v2/time-series/time-series-electricity.tsx @@ -0,0 +1,41 @@ +import {targetYear} from "./time-series-util" +import {TimeSeriesTextareaAdapter} from "./time-series-textarea-adapter" +import {FunctionComponent} from "react" +import {UseFormReturn} from "react-hook-form" +import {TimeSeriesType} from "zero-zummon" + +export const TimeSeriesElectricity: FunctionComponent<{form: UseFormReturn, prefix: string}> = ({form, prefix}) => { + return ( + <> +

2. Kwartierwaarden electriciteit

+ +

Richtlijnen

+ +
    +
  • Geef waarden op van het gehele jaar {targetYear}.
  • +
  • De eerste waarde betreft het verbruik in het kwartier van 1 januari {targetYear} van 00:00 + tot 00:15 CET. +
  • +
  • De laatste waarde betreft het verbruik in het kwartier van 31 december {targetYear} 23:45 + tot 1 januari {targetYear + 1} om 00:00 CET. +
  • +
  • Een langere periode mag.
  • +
+

Kwartierwaarden levering

+ form.setValue(`${prefix}.quarterHourlyDelivery_kWh`, timeSeries)} /> +

Kwartierwaarden teruglevering

+ form.setValue(`${prefix}.quarterHourlyFeedIn_kWh`, timeSeries)} /> +

Kwartierwaarden brutoproductiemeter

+ form.setValue(`${prefix}.quarterHourlyProduction_kWh`, timeSeries)} /> + + ) +} diff --git a/frontend/src/components/company-survey-v2/time-series/time-series-textarea-adapter.tsx b/frontend/src/components/company-survey-v2/time-series/time-series-textarea-adapter.tsx new file mode 100644 index 00000000..f2d53952 --- /dev/null +++ b/frontend/src/components/company-survey-v2/time-series/time-series-textarea-adapter.tsx @@ -0,0 +1,27 @@ +import {FunctionComponent} from "react" +import {targetYear} from "./time-series-util" +import {TimeSeries, TimeSeriesType, timeSeriesFromJson, createEmptyTimeSeriesForYear} from "zero-zummon" +import {TimeSeriesTextarea} from "./time-series-textarea" + +/** + * Has a plain JS object as input and output. + * Converts that objects to a {@link TimeSeries} domain object for use in {@link TimeSeriesTextarea} + */ +export const TimeSeriesTextareaAdapter: FunctionComponent<{ + timeSeries?: any, + timeSeriesType?: TimeSeriesType, + setTimeSeries?: (obj: any) => void +}> = ({ + timeSeries, + timeSeriesType = TimeSeriesType.ELECTRICITY_DELIVERY, + setTimeSeries = console.log, +}) => { + const timeSeriesDomainObject = timeSeries ? timeSeriesFromJson(JSON.stringify(timeSeries)) : createEmptyTimeSeriesForYear(timeSeriesType, targetYear) + + return ( + { + setTimeSeries(JSON.parse(timeSeries.toJson())) + }} /> + ) +} diff --git a/frontend/src/components/company-survey-v2/time-series/time-series-textarea.tsx b/frontend/src/components/company-survey-v2/time-series/time-series-textarea.tsx new file mode 100644 index 00000000..cd50c3d5 --- /dev/null +++ b/frontend/src/components/company-survey-v2/time-series/time-series-textarea.tsx @@ -0,0 +1,80 @@ +import {LocalDateTime, ZonedDateTime, ZoneId} from "@js-joda/core" +import "@js-joda/timezone/dist/js-joda-timezone-2017-2027.esm.js" +import {FunctionComponent, useState} from "react" +import {TimeSeries} from "zero-zummon" +import {InputTextarea} from "primereact/inputtextarea" +import {LabelRow} from "../generic/label-row" +import {displayTimeZone, kotlinInstantToJsJodaInstant, prettyPrint} from "./time-series-util" +import {Dropdown} from "primereact/dropdown" +import {InputText} from "primereact/inputtext" + +const placeholder = ` +bijvoorbeeld: +0.23 +0,23 +.23 +23 +2.3 +`.trim() + +const kwhNumberFormatter = new Intl.NumberFormat("nl-NL", {maximumFractionDigits: 1}) + +export const TimeSeriesTextarea: FunctionComponent<{timeSeries: TimeSeries, setTimeSeries: (t: TimeSeries) => void}> = ({timeSeries, setTimeSeries}) => { + const [internalTimeSeries, setInternalTimeSeries] = useState(timeSeries) + + const setTimeSeriesImpl = (timeSeries: TimeSeries) => { + setInternalTimeSeries(timeSeries) + if (timeSeries.isValid()) { + setTimeSeries(timeSeries) + } + } + + const localStart = kotlinInstantToJsJodaInstant(internalTimeSeries.start) + .atZone(ZoneId.of(displayTimeZone)) + .toLocalDateTime() + + const end = kotlinInstantToJsJodaInstant(internalTimeSeries.calculateEnd()) + + return ( + <> + + { + const local = LocalDateTime.parse(e.target.value) + const zoned = ZonedDateTime.of(local, ZoneId.of(displayTimeZone)) + setTimeSeriesImpl( + internalTimeSeries.withStartEpochSeconds(zoned.toEpochSecond()), + ) + }} /> + + + + + + val.toLocaleString()).toArray().join("\n")} + onInput={e => {setTimeSeriesImpl(internalTimeSeries.withValues(parseTextArea((e.target as HTMLTextAreaElement).value)))}} + // onChange={e => setTimeSeriesImpl(internalTimeSeries.withValues(parseTextArea((e.target as HTMLTextAreaElement).value)))} + style={{display: "block", height: "10rem"}} + placeholder={placeholder} /> + + +

Eind kwartierwaarden: {prettyPrint(end)}

+

Totaal: {kwhNumberFormatter.format(internalTimeSeries.sum())} kWh

+ + ) +} + +function parseTextArea(content: String): Float32Array { + const lines = content.split("\n") + + const numberArray = lines.map(line => line + .trim() + .replace(",", ".") + ).filter(line => line).map(parseFloat) + + return new Float32Array(numberArray) +} + diff --git a/frontend/src/components/company-survey-v2/time-series/time-series-util.ts b/frontend/src/components/company-survey-v2/time-series/time-series-util.ts new file mode 100644 index 00000000..a34180de --- /dev/null +++ b/frontend/src/components/company-survey-v2/time-series/time-series-util.ts @@ -0,0 +1,23 @@ +import {convert, Instant} from "@js-joda/core" +import "@js-joda/timezone/dist/js-joda-timezone-2017-2027.esm.js" +import {instantToEpochSeconds, toJsJodaInstant} from "zero-zummon" + +export const targetYear = 2023 +export const displayTimeZone = "Europe/Amsterdam" + +// This seems dubious because it mixes different versions of js-joda. +// If this leads to issues we can do it with a conversion through epoch seconds +// export const kotlinInstantToJsJodaInstant = (kotlinInstant: any): Instant => toJsJodaInstant(kotlinInstant) +export const kotlinInstantToJsJodaInstant = (kotlinInstant: any): Instant => Instant.ofEpochSecond(instantToEpochSeconds(kotlinInstant)) + +// We use native date formatting because js-joda does not support Dutch locale +const dateFormatter = new Intl.DateTimeFormat("nl-NL", { + dateStyle: "full", + timeStyle: "long", + timeZone: displayTimeZone, +}) + +export function prettyPrint(instant: Instant): string { + const jsDate = convert(instant).toDate() + return dateFormatter.format(jsDate) +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index db484311..a9c23fe2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,7 +8,9 @@ export default defineConfig({ port: 3000, }, build: { + minify: false, rollupOptions: { + treeshake: false, output: { entryFileNames: "[name].js", assetFileNames: '[name].css', diff --git a/zummon/build.gradle.kts b/zummon/build.gradle.kts index 050b6f10..6c8f57be 100644 --- a/zummon/build.gradle.kts +++ b/zummon/build.gradle.kts @@ -36,7 +36,7 @@ kotlin { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:${libs.versions.kotlinx.serialization.json.get()}") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${libs.versions.kotlinx.serialization.json.get()}") - implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") implementation("com.benasher44:uuid:0.8.4") } } @@ -45,5 +45,12 @@ kotlin { implementation(kotlin("test")) } } + jsMain { + dependencies { + // align versions with frontend + implementation(npm("@js-joda/core", "^5.6.3")) + implementation(npm("@js-joda/timezone", "^2.21.1")) + } + } } } diff --git a/zummon/src/commonMain/kotlin/companysurvey/TimeSeries.kt b/zummon/src/commonMain/kotlin/companysurvey/TimeSeries.kt index 13068fbb..320134d7 100644 --- a/zummon/src/commonMain/kotlin/companysurvey/TimeSeries.kt +++ b/zummon/src/commonMain/kotlin/companysurvey/TimeSeries.kt @@ -1,6 +1,7 @@ package com.zenmo.zummon.companysurvey import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours @@ -10,8 +11,9 @@ import com.zenmo.zummon.BenasherUuidSerializer import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.encodeToString import kotlin.js.JsExport - +import kotlin.time.Duration /** * This contains values parsed from a CSV/excel or fetched from an API. @@ -22,10 +24,11 @@ data class TimeSeries ( @Serializable(with = BenasherUuidSerializer::class) val id: Uuid = uuid4(), val type: TimeSeriesType, - // Measurement start time + /** Start of the first measurement interval */ val start: Instant, - val timeStep: kotlin.time.Duration = 15.minutes, - val unit: TimeSeriesUnit = TimeSeriesUnit.KWH, + /** Duration of the measurement interval */ + val timeStep: Duration = type.defaultStep(), + val unit: TimeSeriesUnit = type.defaultUnit(), val values: FloatArray = floatArrayOf(), ) { @Deprecated("Use .values", ReplaceWith("values")) @@ -33,6 +36,14 @@ data class TimeSeries ( fun calculateEnd(): Instant = start + (timeStep * values.size) + fun sum(): Float = values.sum() + + fun isValid() = !isEmpty() && !sum().isNaN() + + fun withStartEpochSeconds(epochSeconds: Double) = copy( + start = Instant.fromEpochSeconds(epochSeconds.toLong()) + ) + /** * The number of values needed to fill a year using the specified time step. */ @@ -47,6 +58,10 @@ data class TimeSeries ( } } + fun withValues(values: FloatArray) = copy(values = values) + + fun isEmpty() = values.isEmpty() + fun hasFullYear(year: Int? = null): Boolean { return try { val workingYear = year ?: this.start.toLocalDateTime(TimeZone.of("UTC+01:00")).year @@ -150,8 +165,13 @@ data class TimeSeries ( result = 31 * result + values.contentHashCode() return result } + + fun toJson(): String = Json.encodeToString(this) } +@JsExport +fun timeSeriesFromJson(json: String): TimeSeries = Json.decodeFromString(TimeSeries.serializer(), json) + @JsExport enum class TimeSeriesUnit { KWH, @@ -161,14 +181,36 @@ enum class TimeSeriesUnit { @JsExport enum class TimeSeriesType { // Delivery from grid to end-user - ELECTRICITY_DELIVERY, + ELECTRICITY_DELIVERY { + override fun defaultUnit() = TimeSeriesUnit.KWH + override fun defaultStep() = 15.minutes + }, // Feed-in of end-user back in to the rid - ELECTRICITY_FEED_IN, + ELECTRICITY_FEED_IN { + override fun defaultUnit() = TimeSeriesUnit.KWH + override fun defaultStep() = 15.minutes + }, // Solar panel production - ELECTRICITY_PRODUCTION, - GAS_DELIVERY, + ELECTRICITY_PRODUCTION { + override fun defaultUnit() = TimeSeriesUnit.KWH + override fun defaultStep() = 15.minutes + }, + GAS_DELIVERY { + override fun defaultUnit() = TimeSeriesUnit.M3 + override fun defaultStep() = 1.hours + }; + + abstract fun defaultUnit(): TimeSeriesUnit + abstract fun defaultStep(): Duration } +@JsExport +fun createEmptyTimeSeriesForYear(type: TimeSeriesType, year: Int) = + TimeSeries( + type = type, + start = Instant.parse("$year-01-01T00:00:00+01:00") + ) + /** * Represents a single point within the time series. * Improvement: add timestamp @@ -194,3 +236,6 @@ data class DataPoint ( return value * (1.hours / this.timeStep) } } + +@JsExport +fun instantToEpochSeconds(instant: Instant) = instant.epochSeconds.toDouble() diff --git a/zummon/src/commonMain/kotlin/companysurvey/TimeSeriesDataPoint.kt b/zummon/src/commonMain/kotlin/companysurvey/TimeSeriesDataPoint.kt index 0b25bc89..d6e478f8 100644 --- a/zummon/src/commonMain/kotlin/companysurvey/TimeSeriesDataPoint.kt +++ b/zummon/src/commonMain/kotlin/companysurvey/TimeSeriesDataPoint.kt @@ -2,8 +2,9 @@ package com.zenmo.zummon.companysurvey import kotlinx.serialization.Serializable +@Deprecated("unused", ReplaceWith("DataPoint")) @Serializable data class TimeSeriesDataPoint ( val timestamp: kotlinx.datetime.Instant, val value: Float, -) \ No newline at end of file +) diff --git a/zummon/src/jsMain/kotlin/Instant.kt b/zummon/src/jsMain/kotlin/Instant.kt new file mode 100644 index 00000000..5822ccfb --- /dev/null +++ b/zummon/src/jsMain/kotlin/Instant.kt @@ -0,0 +1,11 @@ +import kotlinx.datetime.Instant +import kotlinx.datetime.internal.JSJoda.Instant as jtInstant + +@JsModule("@js-joda/timezone") +@JsNonModule +external object JsJodaTimeZoneModule + +private val jsJodaTz = JsJodaTimeZoneModule + +@JsExport +fun toJsJodaInstant(instant: Instant) = jtInstant.ofEpochSecond(instant.epochSeconds.toDouble(), 0)