diff --git a/src/components/Analysis/MonthlyAnalysis.tsx b/src/components/Analysis/MonthlyAnalysis.tsx index b70cc4f5de0fe36a92c82806e07e3b012d4610dc..01907b7a3eb3f1035807049a8295b52df8ebcfc0 100644 --- a/src/components/Analysis/MonthlyAnalysis.tsx +++ b/src/components/Analysis/MonthlyAnalysis.tsx @@ -23,6 +23,7 @@ import StyledSpinner from 'components/CommonKit/Spinner/StyledSpinner' import AnalysisErrorModal from './AnalysisErrorModal' import { DateTime } from 'luxon' import StyledCard from 'components/CommonKit/Card/StyledCard' +import TotalAnalysisChart from './TotalAnalysisChart' interface MonthlyAnalysisProps { analysisDate: DateTime @@ -126,19 +127,12 @@ const MonthlyAnalysis: React.FC<MonthlyAnalysisProps> = ({ </div> </div> <div className="analysis-content"> - <StyledCard> - <span className="analysis-header text-16-normal-uppercase"> - {t('analysis.analysis_date')} - </span> - <div> - <PerformanceIndicatorContent - performanceIndicator={aggregatedPerformanceIndicators} - timeStep={timeStep} - fluidLackOfData={fluidLackOfData} - analysisDate={analysisDate} - /> - </div> - </StyledCard> + <div className="card total-card"> + <TotalAnalysisChart + analysisDate={analysisDate} + fluidTypes={fluidTypes} + /> + </div> </div> <div className="analysis-content"> <StyledCard> diff --git a/src/components/Analysis/PieChart.spec.tsx b/src/components/Analysis/PieChart.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..035cd072f3108718a1db2886751e56e6be44e69b --- /dev/null +++ b/src/components/Analysis/PieChart.spec.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { mount } from 'enzyme' +import { DateTime } from 'luxon' +import { globalStateData } from '../../../tests/__mocks__/globalStateData.mock' +import { Provider } from 'react-redux' +import configureStore from 'redux-mock-store' +import PieChart from './PieChart' + +jest.mock('cozy-ui/transpiled/react/I18n', () => { + return { + useI18n: jest.fn(() => { + return { + t: (str: string) => str, + } + }), + } +}) +const mockStore = configureStore([]) + +describe('PieChart component', () => { + it('should be rendered correctly', () => { + const store = mockStore({ + ecolyo: { + global: globalStateData, + }, + }) + const wrapper = mount( + <Provider store={store}> + <PieChart + width={300} + height={300} + outerRadius={300} + innerRadius={300} + currentAnalysisDate={DateTime.fromISO('2021-07-01T00:00:00.000Z', { + zone: 'utc', + })} + totalValue={60} + dataArray={[10, 20, 30]} + /> + </Provider> + ).getElement() + expect(wrapper).toMatchSnapshot() + }) + it('should open estimation modal', () => { + const store = mockStore({ + ecolyo: { + global: globalStateData, + }, + }) + const wrapper = mount( + <Provider store={store}> + <PieChart + width={300} + height={300} + outerRadius={300} + innerRadius={300} + currentAnalysisDate={DateTime.fromISO('2021-07-01T00:00:00.000Z', { + zone: 'utc', + })} + totalValue={60} + dataArray={[10, 20, 30]} + /> + </Provider> + ) + expect(wrapper.find('.estimation-text').simulate('click')) + }) +}) diff --git a/src/components/Analysis/PieChart.tsx b/src/components/Analysis/PieChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7091bd02c1416411a3b15d753f60758292f966d0 --- /dev/null +++ b/src/components/Analysis/PieChart.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import './totalAnalysisChart.scss' +import * as d3 from 'd3' +import { useI18n } from 'cozy-ui/transpiled/react/I18n' +import { formatNumberValues } from 'utils/utils' +import { DateTime } from 'luxon' +import { convertDateToMonthString } from 'utils/date' +import EstimatedConsumptionModal from 'components/ConsumptionVisualizer/EstimatedConsumptionModal' + +interface PieProps { + innerRadius: number + outerRadius: number + dataArray: number[] + width: number + height: number + totalValue: number + currentAnalysisDate: DateTime +} + +const PieChart: React.FC<PieProps> = ({ + innerRadius, + outerRadius, + dataArray, + width, + height, + totalValue, + currentAnalysisDate, +}: PieProps) => { + const ref = useRef(null) + const { t } = useI18n() + const createPie = d3.pie().sort(null) + const arcWidth = outerRadius - innerRadius + const [openEstimationModal, setOpenEstimationModal] = useState<boolean>(false) + const toggleEstimationModal = useCallback(() => { + setOpenEstimationModal(prev => !prev) + }, []) + + useEffect(() => { + const data = createPie(dataArray) + const group = d3.select(ref.current) + const groupWithData = group.selectAll('g.arc').data(data) + const colors = ['#D87B39', '#3A98EC', '#45D1B8'] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createArc: any = d3 + .arc() + .innerRadius(innerRadius) + .outerRadius(outerRadius) + groupWithData.exit().remove() + + const groupWithUpdate = groupWithData + .enter() + .append('g') + .attr('class', 'arc') + .attr('filter', 'url(#glow)') + + const path = groupWithUpdate + .append('path') + .merge(groupWithData.select('path.arc')) + + path + .attr('class', 'arc') + .attr('d', createArc) + .attr('fill', (d, i) => colors[i]) + }, [createPie, dataArray, innerRadius, outerRadius]) + + return ( + <div + className="pie-container" + style={{ + width: width, + height: height, + }} + > + <svg width={width} height={height}> + <defs> + <filter id="glow" height="300%" width="300%" x="-75%" y="-75%"> + <feGaussianBlur stdDeviation="10" result="coloredBlur" /> + <feMerge> + <feMergeNode in="coloredBlur" /> + <feMergeNode in="SourceGraphic" /> + </feMerge> + </filter> + </defs> + <g ref={ref} transform={`translate(${outerRadius} ${outerRadius})`} /> + </svg> + <div + className="pie-center" + style={{ + width: width - arcWidth, + height: height - arcWidth, + top: arcWidth / 2, + left: arcWidth - arcWidth / 2, + }} + > + <div className="text-36-bold"> + {formatNumberValues(totalValue)} + <span className="euro-unit">{`${t('FLUID.MULTIFLUID.UNIT')}`}</span> + </div> + <div className="text-16-normal date"> + {t('analysis_pie.month') + + convertDateToMonthString(currentAnalysisDate).substring(3)} + </div> + <div + className="text-14-normal estimation-text" + onClick={toggleEstimationModal} + > + <span className="estimated">{t('analysis_pie.estimation')}</span> + <span className="estimated">{t('analysis_pie.estimation2')}</span> + </div> + <div + className="circle" + style={{ + width: width - arcWidth * 2, + height: height - arcWidth * 2, + top: arcWidth / 2, + left: arcWidth - arcWidth / 2, + }} + ></div> + </div> + <EstimatedConsumptionModal + open={openEstimationModal} + handleCloseClick={toggleEstimationModal} + /> + </div> + ) +} + +export default PieChart diff --git a/src/components/Analysis/TotalAnalysisChart.spec.tsx b/src/components/Analysis/TotalAnalysisChart.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b1620f97cefa7aa39f1704b29467043af316ff73 --- /dev/null +++ b/src/components/Analysis/TotalAnalysisChart.spec.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { mount } from 'enzyme' +import { DateTime } from 'luxon' + +import { globalStateData } from '../../../tests/__mocks__/globalStateData.mock' +import { Provider } from 'react-redux' +import configureStore from 'redux-mock-store' +import TotalAnalysisChart from './TotalAnalysisChart' +import { FluidType } from 'enum/fluid.enum' +import { graphMonthData } from '../../../tests/__mocks__/datachartData.mock' +import { act } from 'react-dom/test-utils' + +jest.mock('cozy-ui/transpiled/react/I18n', () => { + return { + useI18n: jest.fn(() => { + return { + t: (str: string) => str, + } + }), + } +}) +const mockgetGraphData = jest.fn() +jest.mock('services/consumption.service', () => { + return jest.fn(() => { + return { + getGraphData: mockgetGraphData, + } + }) +}) +const mockStore = configureStore([]) + +describe('TotalAnalysisChart component', () => { + it('should be rendered correctly', () => { + const store = mockStore({ + ecolyo: { + global: globalStateData, + }, + }) + const wrapper = mount( + <Provider store={store}> + <TotalAnalysisChart + analysisDate={DateTime.fromISO('2021-07-01T00:00:00.000Z', { + zone: 'utc', + })} + fluidTypes={[FluidType.ELECTRICITY]} + /> + </Provider> + ).getElement() + expect(wrapper).toMatchSnapshot() + }) + it('should render several fluids and display month data', async () => { + const store = mockStore({ + ecolyo: { + global: globalStateData, + }, + }) + mockgetGraphData.mockResolvedValueOnce(graphMonthData) + const wrapper = mount( + <Provider store={store}> + <TotalAnalysisChart + analysisDate={DateTime.fromISO('2021-07-01T00:00:00.000Z', { + zone: 'utc', + })} + fluidTypes={[FluidType.ELECTRICITY, FluidType.WATER, FluidType.GAS]} + /> + </Provider> + ) + await act(async () => { + await new Promise(resolve => setTimeout(resolve)) + wrapper.update() + }) + expect(wrapper.find('.fluidconso').exists()).toBeTruthy() + }) + it('should render empty price', async () => { + const store = mockStore({ + ecolyo: { + global: globalStateData, + }, + }) + const emptyData = { + actualData: [ + { + date: DateTime.fromISO('2020-09-01T00:00:00.000Z', { + zone: 'utc', + }), + value: 69.18029999999999, + valueDetail: [-1], + }, + ], + } + mockgetGraphData.mockResolvedValueOnce(emptyData) + const wrapper = mount( + <Provider store={store}> + <TotalAnalysisChart + analysisDate={DateTime.fromISO('2021-07-01T00:00:00.000Z', { + zone: 'utc', + })} + fluidTypes={[FluidType.ELECTRICITY]} + /> + </Provider> + ) + await act(async () => { + await new Promise(resolve => setTimeout(resolve)) + wrapper.update() + }) + expect(wrapper.find('.fluidconso').text()).toBe('--- €') + }) +}) diff --git a/src/components/Analysis/TotalAnalysisChart.tsx b/src/components/Analysis/TotalAnalysisChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e61e09f33908856d3acfd0a33ce292a1fed22e6b --- /dev/null +++ b/src/components/Analysis/TotalAnalysisChart.tsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from 'react' +import './analysisView.scss' +import { DateTime } from 'luxon' +import { useClient } from 'cozy-client' +import ConsumptionDataManager from 'services/consumption.service' +import { FluidType } from 'enum/fluid.enum' +import { TimeStep } from 'enum/timeStep.enum' +import { TimePeriod } from 'models' +import PieChart from './PieChart' +import './totalAnalysisChart.scss' +import { getNavPicto } from 'utils/picto' +import Icon from 'cozy-ui/transpiled/react/Icon' + +import { formatNumberValues } from 'utils/utils' +import { useI18n } from 'cozy-ui/transpiled/react/I18n' + +interface TotalAnalysisChartProps { + analysisDate: DateTime + fluidTypes: FluidType[] +} + +const TotalAnalysisChart: React.FC<TotalAnalysisChartProps> = ({ + analysisDate, + fluidTypes, +}: TotalAnalysisChartProps) => { + const [dataLoadArray, setDataLoadArray] = useState<number[] | null>(null) + const [totalLoadValue, setTotalLoadValue] = useState<number>(0) + const client = useClient() + const { t } = useI18n() + const arcWidth = 30 + const radius = Math.min(375, innerWidth - 100) + const outerRadius = radius / 2 + const innerRadius = outerRadius - arcWidth + + useEffect(() => { + let subscribed = true + async function getTotalData() { + const timePeriod: TimePeriod = { + startDate: analysisDate.minus({ month: 1 }).startOf('month'), + endDate: analysisDate.minus({ month: 1 }).endOf('month'), + } + const consumptionService = new ConsumptionDataManager(client) + const monthTotalData = await consumptionService.getGraphData( + timePeriod, + TimeStep.MONTH, + fluidTypes, + undefined, + true + ) + if (monthTotalData && monthTotalData.actualData) { + setDataLoadArray(monthTotalData.actualData[0].valueDetail) + setTotalLoadValue(monthTotalData.actualData[0].value) + } + } + if (subscribed) { + getTotalData() + } + return () => { + subscribed = false + } + }, [analysisDate, client, fluidTypes]) + return ( + <div className="totalAnalysis-container"> + {fluidTypes.length >= 2 && ( + <div className="text-24-normal title">{t('analysis_pie.total')}</div> + )} + {dataLoadArray && ( + <PieChart + dataArray={dataLoadArray} + totalValue={totalLoadValue} + width={radius} + height={radius} + innerRadius={innerRadius} + outerRadius={outerRadius} + currentAnalysisDate={analysisDate + .minus({ month: 1 }) + .startOf('month')} + /> + )} + {dataLoadArray && ( + <div className="total-card-container"> + {dataLoadArray.map((load, index) => { + return ( + <div key={index} className="total-card"> + <div className="text-20-bold fluidconso"> + {load !== -1 ? `${formatNumberValues(load)} €` : '--- €'} + </div> + <Icon + className="euro-fluid-icon" + icon={getNavPicto(index, true, true)} + size={38} + /> + <div className="text-16-normal"> + {t('FLUID.' + FluidType[index] + '.LABEL')} + </div> + </div> + ) + })} + </div> + )} + </div> + ) +} + +export default TotalAnalysisChart diff --git a/src/components/Analysis/__snapshots__/PieChart.spec.tsx.snap b/src/components/Analysis/__snapshots__/PieChart.spec.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..9e545f371d73d8e87cb173cc6a0e8cfde8465071 --- /dev/null +++ b/src/components/Analysis/__snapshots__/PieChart.spec.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PieChart component should be rendered correctly 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <PieChart + currentAnalysisDate={"2021-07-01T00:00:00.000Z"} + dataArray={ + Array [ + 10, + 20, + 30, + ] + } + height={300} + innerRadius={300} + outerRadius={300} + totalValue={60} + width={300} + /> +</Provider> +`; diff --git a/src/components/Analysis/__snapshots__/TotalAnalysisChart.spec.tsx.snap b/src/components/Analysis/__snapshots__/TotalAnalysisChart.spec.tsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..f3c1e0102d9d3b875fd8535f8589558562a5d734 --- /dev/null +++ b/src/components/Analysis/__snapshots__/TotalAnalysisChart.spec.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TotalAnalysisChart component should be rendered correctly 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <TotalAnalysisChart + analysisDate={"2021-07-01T00:00:00.000Z"} + fluidTypes={ + Array [ + 0, + ] + } + /> +</Provider> +`; diff --git a/src/components/Analysis/monthlyanalysis.scss b/src/components/Analysis/monthlyanalysis.scss index 2ce582839b8cbbd2dbfc9191f449082324956ee7..d2d9acaae1244c76ceac7dfcdd221c2b023ef992 100644 --- a/src/components/Analysis/monthlyanalysis.scss +++ b/src/components/Analysis/monthlyanalysis.scss @@ -40,6 +40,9 @@ margin: 0; } } + .total-card { + padding: 0; + } } .analysis-container-spinner { position: absolute; diff --git a/src/components/Analysis/totalAnalysisChart.scss b/src/components/Analysis/totalAnalysisChart.scss new file mode 100644 index 0000000000000000000000000000000000000000..da57ee272b978ae5a18659e3d1e8c2ae9d6711aa --- /dev/null +++ b/src/components/Analysis/totalAnalysisChart.scss @@ -0,0 +1,91 @@ +@import '../../styles/base/color'; +@import '../../styles/base/breakpoint'; + +.totalAnalysis-container { + display: flex; + justify-content: center; + flex-direction: column; + color: white; + .title { + text-align: center; + color: $grey-bright; + margin: 1.5rem 0; + } + .pie-container { + text-align: center; + position: relative; + margin: auto; + } + svg { + margin: auto; + overflow: visible; + } + .pie-center { + box-sizing: border-box; + position: absolute; + border-radius: 50%; + background: $dark-light-2; + z-index: 5; + padding: 1rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + .euro-unit { + margin-left: 0.6rem; + } + .date { + text-transform: uppercase; + color: $soft-grey; + display: inline-block; + margin: 0.5rem 0; + } + .estimated { + display: block; + text-decoration: underline; + } + .circle { + box-sizing: border-box; + border: 1px solid $grey-dark; + position: absolute; + border-radius: 50%; + background: transparent; + z-index: 6; + padding: 1rem; + @media screen and (max-width: 345px) { + display: none; + } + } + } + .total-card-container { + display: flex; + margin: auto; + justify-content: center; + margin-top: 1rem; + .total-card { + margin: 1rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + svg { + margin: 0.7rem 0; + } + } + } + .text-36-bold { + @media screen and (max-width: 345px) { + font-size: 1.6rem; + } + } + .date { + @media screen and (max-width: 345px) { + font-size: 1rem; + } + } + .text-20-bold { + @media screen and (max-width: 345px) { + font-size: 1rem; + } + } +} diff --git a/src/locales/fr.json b/src/locales/fr.json index c5597da739a13dcf3597c1f093a1df67f20ddca8..2bd596b18282cdb577028cd1c269883a13243d5f 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -97,6 +97,12 @@ "button_goto_konnector": "Aller aux connecteurs" } }, + "analysis_pie": { + "total": "Conso totale", + "month": "Au mois de ", + "estimation": "Comment sont estimés", + "estimation2": "les prix ?" + }, "auth": { "enedisgrandlyon": { "connect": {