diff --git a/dashboard/src/components/EvolutiveStatsSelector.tsx b/dashboard/src/components/EvolutiveStatsSelector.tsx index ddc17de3e..3e589ab5f 100644 --- a/dashboard/src/components/EvolutiveStatsSelector.tsx +++ b/dashboard/src/components/EvolutiveStatsSelector.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import SelectCustom from './SelectCustom'; import { dayjsInstance } from '../services/date'; -import DatePicker from './DatePicker'; import type { FilterField } from '../types/field'; import type { IndicatorValue, IndicatorsSelection, IndicatorsBase } from '../types/evolutivesStats'; import { useRecoilValue } from 'recoil'; @@ -181,9 +180,14 @@ const EvolutiveStatsSelector = ({ onChange, selection, title = '', saveInURLPara
@@ -192,25 +196,6 @@ const EvolutiveStatsSelector = ({ onChange, selection, title = '', saveInURLPara ); }; -const dateOptions = [ - { - label: 'Avant', - value: 'before', - }, - { - label: 'Après', - value: 'after', - }, - { - label: 'Date exacte', - value: 'equals', - }, - { - label: 'Non renseigné', - value: 'unfilled', - }, -]; - const numberOptions = [ { label: 'Inférieur à', @@ -249,49 +234,6 @@ const ValueSelector = ({ fieldName, indicatorValues, value, onChangeValue, base if (!current) return <>; const { type, name } = current; - if (['text', 'textarea'].includes(type)) { - return ( - { - e.preventDefault(); - onChangeValue(e.target.value); - }} - /> - ); - } - - if (['date-with-time', 'date'].includes(type)) { - return ( -
-
- opt.value === value?.comparator)} - isClearable={!value} - onChange={(option) => { - if (!option) return setComparator(null); - setComparator(option.value); - onChangeValue({ date: value?.date, comparator: option.value }); - }} - /> -
- {value?.comparator !== 'unfilled' && ( -
- onChangeValue({ date: date.target.value, comparator })} - /> -
- )} -
- ); - } - if (['number'].includes(type)) { return (
@@ -357,7 +299,7 @@ const ValueSelector = ({ fieldName, indicatorValues, value, onChangeValue, base getOptionLabel={(f) => f.label} getOptionValue={(f) => f.value} onChange={(newValue) => onChangeValue(newValue?.value)} - isClearable={!value?.length} + isClearable /> ); } catch (e) { @@ -373,7 +315,7 @@ const ValueSelector = ({ fieldName, indicatorValues, value, onChangeValue, base getOptionLabel={(f) => f.label} getOptionValue={(f) => f.value} onChange={(option) => onChangeValue(option?.value)} - isClearable={!value} + isClearable /> ); }; diff --git a/dashboard/src/components/EvolutiveStatsViewer.tsx b/dashboard/src/components/EvolutiveStatsViewer.tsx index 2ab467ffd..281c64caa 100644 --- a/dashboard/src/components/EvolutiveStatsViewer.tsx +++ b/dashboard/src/components/EvolutiveStatsViewer.tsx @@ -18,24 +18,16 @@ interface EvolutiveStatsViewerProps { export default function EvolutiveStatsViewer({ evolutiveStatsIndicators, period, persons }: EvolutiveStatsViewerProps) { const startDate = period.startDate; const endDate = period.endDate; + const indicatorsBase = useRecoilValue(evolutiveStatsIndicatorsBaseSelector); const evolutiveStatsPerson = useRecoilValue( evolutiveStatsPersonSelector({ persons, startDate: period.startDate ? dayjsInstance(period.startDate).format('YYYY-MM-DD') : null, + evolutiveStatsIndicators, }) ); - const indicatorsBase = useRecoilValue(evolutiveStatsIndicatorsBaseSelector); if (!evolutiveStatsIndicators.length) return null; const indicator = evolutiveStatsIndicators[0]; - - console.log({ - evolutiveStatsPerson, - startDate, - endDate, - evolutiveStatsIndicators, - indicator, - }); - if (!indicator.fieldName) return null; const startDateFormatted = dayjsInstance(startDate ?? startHistoryFeatureDate); @@ -46,12 +38,18 @@ export default function EvolutiveStatsViewer({ evolutiveStatsIndicators, period, const fieldStart = indicator.fromValue; const fieldEnd = indicator.toValue; - if (fieldStart == null || fieldEnd == null) { + const field = indicatorsBase.find((field) => field.name === indicator.fieldName); + + console.log({ + fieldStart, + fieldEnd, + evolutiveStatsPerson, + }); + if (fieldEnd == null) { return ( <>

- Évolution du champ {indicatorsBase.find((field) => field.name === indicator.fieldName)?.label} entre le{' '} - {startDateFormatted.format('DD/MM/YYYY')} et le {endDateFormatted.format('DD/MM/YYYY')} + Évolution du champ {field?.label} entre le {startDateFormatted.format('DD/MM/YYYY')} et le {endDateFormatted.format('DD/MM/YYYY')}

-
+
Au {startDateFormatted.format('DD/MM/YYYY')}
-
+

{valueStart}

{fieldStart}

-

45%

+

{Math.round((valueEnd / valueStart) * 1000) / 10}%

- des “Sans” Couverture Médicale au 31/01/2022 + des{' '} + + {field?.label}: {fieldStart} + {' '} + au {startDateFormatted.format('DD/MM/YYYY')}
- ont évolué vers “AME” au 01/02/2023 + ont évolué vers {fieldEnd} au {endDateFormatted.format('DD/MM/YYYY')}

-
+
Au {endDateFormatted.format('DD/MM/YYYY')}
-
+

{valueEnd}

{fieldEnd}

@@ -111,7 +114,7 @@ function MyResponsiveStream({ indicator, evolutiveStatsPerson, startDateFormatte const legend = []; const fieldData = evolutiveStatsPerson[indicator.fieldName]; const daysDiff = endDateFormatted.diff(startDateFormatted, 'days'); - const spacing = Math.floor(Math.max(1, daysDiff / 12)); + const spacing = Math.floor(Math.max(1, daysDiff / 6)); for (let i = 0; i < daysDiff; i += spacing) { const date = startDateFormatted.add(i, 'days'); legend.push(date.format('DD/MM/YYYY')); @@ -137,69 +140,104 @@ function MyResponsiveStream({ indicator, evolutiveStatsPerson, startDateFormatte }, [startDateFormatted, endDateFormatted, evolutiveStatsPerson, indicator.fieldName]); return ( -
- chartData.legend[index], - }} - axisLeft={{ - // orient: 'left', - tickSize: 5, - tickPadding: 5, - tickRotation: 0, - legend: '', - legendOffset: -40, - }} - curve="basis" - enableGridX={true} - enableGridY={true} - offsetType="diverging" - colors={{ scheme: 'set2' }} - fillOpacity={0.85} - borderColor={{ theme: 'background' }} - dotSize={8} - dotColor={{ from: 'color' }} - dotBorderWidth={2} - dotBorderColor={{ - from: 'color', - modifiers: [['darker', 0.7]], - }} - legends={[ - { - anchor: 'bottom-right', - direction: 'column', - translateX: 100, - itemWidth: 80, - itemHeight: 20, - itemTextColor: '#999999', - symbolSize: 12, - symbolShape: 'circle', - effects: [ - { - on: 'hover', - style: { - itemTextColor: '#000000', +
+
+ chartData.legend[index], + }} + axisLeft={{ + // orient: 'left', + tickSize: 5, + tickPadding: 5, + tickRotation: 0, + truncateTickAt: 1, + legend: '', + legendOffset: -40, + format: (e) => (Math.floor(e) === e ? e : ''), + }} + curve="basis" + enableGridX={true} + enableGridY={false} + offsetType="diverging" + colors={{ scheme: 'set2' }} + fillOpacity={0.85} + borderColor={{ theme: 'background' }} + dotSize={8} + dotColor={{ from: 'color' }} + dotBorderWidth={2} + dotBorderColor={{ + from: 'color', + modifiers: [['darker', 0.7]], + }} + legends={[ + { + anchor: 'bottom-right', + direction: 'column', + translateX: 100, + itemWidth: 80, + itemHeight: 20, + itemTextColor: '#999999', + symbolSize: 12, + symbolShape: 'circle', + effects: [ + { + on: 'hover', + style: { + itemTextColor: '#000000', + }, }, - }, - ], - }, - ]} - /> + ], + }, + ]} + /> +
+
+ + + + + + + + + + + + {chartData.keys.map((option) => { + const startValue = chartData.data[0][option]; + const endValue = chartData.data.at(-1)[option]; + const diff = endValue - startValue; + const sign = diff > 0 ? '+' : ''; + const percentDiff = startValue === 0 || endValue === 0 ? 0 : Math.round((diff / startValue) * 1000) / 10; + return ( + + + + + + + + ); + })} + +
OptionAu {startDateFormatted.format('DD/MM/YYYY')}Au {endDateFormatted.format('DD/MM/YYYY')}DifférenceDifférence (%)
{option}{startValue}{endValue}{diff === 0 ? '' : `${sign}${diff}`}{percentDiff === 0 ? '' : `${sign}${percentDiff}%`}
+
); } diff --git a/dashboard/src/recoil/evolutiveStats.ts b/dashboard/src/recoil/evolutiveStats.ts index fa4446e4b..49b1f96c2 100644 --- a/dashboard/src/recoil/evolutiveStats.ts +++ b/dashboard/src/recoil/evolutiveStats.ts @@ -2,6 +2,7 @@ import { selector, selectorFamily } from 'recoil'; import { capture } from '../services/sentry'; import type { PersonInstance } from '../types/person'; import type { CustomOrPredefinedField } from '../types/field'; +import type { IndicatorsSelection } from '../types/evolutivesStats'; import type { EvolutiveStatsPersonFields, EvolutiveStatOption, EvolutiveStatDateYYYYMMDD } from '../types/evolutivesStats'; import { dayjsInstance } from '../services/date'; import { personFieldsIncludingCustomFieldsSelector } from './persons'; @@ -35,71 +36,125 @@ export const evolutiveStatsIndicatorsBaseSelector = selector({ export const startHistoryFeatureDate = '2022-09-23'; +type FieldsMap = Record; + +function getValuesOptionsByField(field: CustomOrPredefinedField, fieldsMap: FieldsMap): Array { + if (!field) return []; + const current = fieldsMap[field.name]; + if (!current) return []; + if (['yes-no'].includes(current.type)) return ['Oui', 'Non', 'Non renseigné']; + if (['boolean'].includes(current.type)) return ['Oui', 'Non']; + if (current?.name === 'outOfActiveList') return current.options ?? ['Oui', 'Non']; + if (current?.options?.length) { + return [...current?.options, 'Non renseigné'].filter((option) => { + if (option.includes('Choisissez un genre')) return false; + return true; + }); + } + return ['Non renseigné']; +} + +function getValueByField(fieldName: CustomOrPredefinedField['name'], fieldsMap: FieldsMap, value: any): string | Array { + if (!fieldName) return ''; + const current = fieldsMap[fieldName]; + if (!current) return ''; + if (['yes-no'].includes(current.type)) { + if (value === 'Oui') return 'Oui'; + return 'Non'; + } + if (['boolean'].includes(current.type)) { + if (value === true || value === 'Oui') return 'Oui'; + return 'Non'; + } + if (current?.name === 'outOfActiveList') { + if (value === true) return 'Oui'; + return 'Non'; + } + if (value == null || value === '') { + if (current.type === 'multi-choice') return []; + return 'Non renseigné'; // we cover the case of undefined, null, empty string + } + if (value.includes('Choisissez un genre')) return 'Non renseigné'; + return value; +} + +function getPersonSnapshotAtDate({ + person, + snapshotDate, + fieldsMap, +}: { + person: PersonInstance; + fieldsMap: FieldsMap; + snapshotDate: string; // YYYYMMDD +}): PersonInstance | null { + let snapshot = structuredClone(person); + const followedSince = dayjsInstance(snapshot.followedSince || snapshot.createdAt).format('YYYYMMDD'); + if (followedSince > snapshotDate) return null; + const history = snapshot.history; + if (!history?.length) return snapshot; + const reversedHistory = [...history].reverse(); + for (const historyItem of reversedHistory) { + let historyDate = dayjsInstance(historyItem.date).format('YYYYMMDD'); + if (historyDate < snapshotDate) return snapshot; + for (const historyChangeField of Object.keys(historyItem.data)) { + const oldValue = getValueByField(historyChangeField, fieldsMap, historyItem.data[historyChangeField].oldValue); + const historyNewValue = getValueByField(historyChangeField, fieldsMap, historyItem.data[historyChangeField].newValue); + const currentPersonValue = getValueByField(historyChangeField, fieldsMap, snapshot[historyChangeField]); + if (JSON.stringify(historyNewValue) !== JSON.stringify(currentPersonValue)) { + capture(new Error('Incoherent snapshot history'), { + extra: { + snapshot, + historyItem, + historyChangeField, + oldValue, + historyNewValue, + currentPersonValue, + }, + }); + } + if (oldValue === '') continue; + snapshot = { + ...snapshot, + [historyChangeField]: oldValue, + }; + } + } + return snapshot; +} + export const evolutiveStatsPersonSelector = selectorFamily({ key: 'evolutiveStatsPersonSelector', get: - ({ startDate, persons }: { startDate: string | null; persons: Array }) => + ({ + startDate, + persons, + evolutiveStatsIndicators, + }: { + startDate: string | null; + persons: Array; + evolutiveStatsIndicators: IndicatorsSelection; + }) => ({ get }) => { const now = Date.now(); const indicatorsBase = get(evolutiveStatsIndicatorsBaseSelector); - const fieldsMap: Record = indicatorsBase.reduce((acc, field) => { + const fieldsMap: FieldsMap = indicatorsBase.reduce((acc, field) => { acc[field.name] = field; return acc; - }, {} as Record); + }, {} as FieldsMap); const personsFieldsInHistoryObject: EvolutiveStatsPersonFields = {}; - function getValuesOptionsByField(field: CustomOrPredefinedField): Array { - if (!field) return []; - const current = fieldsMap[field.name]; - if (!current) return []; - if (['yes-no'].includes(current.type)) return ['Oui', 'Non', 'Non renseigné']; - if (['boolean'].includes(current.type)) return ['Oui', 'Non']; - if (current?.name === 'outOfActiveList') return current.options ?? ['Oui', 'Non']; - if (current?.options?.length) { - return [...current?.options, 'Non renseigné'].filter((option) => { - if (option.includes('Choisissez un genre')) return false; - return true; - }); - } - return ['Non renseigné']; - } - - function getValueByField(fieldName: CustomOrPredefinedField['name'], value: any): string | Array { - if (!fieldName) return ''; - const current = fieldsMap[fieldName]; - if (!current) return ''; - if (['yes-no'].includes(current.type)) { - if (value === 'Oui') return 'Oui'; - return 'Non'; - } - if (['boolean'].includes(current.type)) { - if (value === true || value === 'Oui') return 'Oui'; - return 'Non'; - } - if (current?.name === 'outOfActiveList') { - if (value === true) return 'Oui'; - return 'Non'; - } - if (value == null || value === '') { - if (current.type === 'multi-choice') return []; - return 'Non renseigné'; // we cover the case of undefined, null, empty string - } - if (value.includes('Choisissez un genre')) return 'Non renseigné'; - return value; - } - // we take the years since the history began, let's say early 2023 const dates: Record = {}; const minimumDateForEvolutiveStats = dayjsInstance(startDate ?? startHistoryFeatureDate).format('YYYYMMDD'); let date = minimumDateForEvolutiveStats; const today = dayjsInstance().format('YYYYMMDD'); - while (date !== today) { + while (date <= today) { dates[date] = 0; date = dayjsInstance(date).add(1, 'day').format('YYYYMMDD'); } for (const field of indicatorsBase) { - const options = getValuesOptionsByField(field); + const options = getValuesOptionsByField(field, fieldsMap); personsFieldsInHistoryObject[field.name] = {}; for (const option of options) { personsFieldsInHistoryObject[field.name][option] = { @@ -108,13 +163,23 @@ export const evolutiveStatsPersonSelector = selectorFamily({ } } - for (const [index, person] of Object.entries(persons)) { + const indicator = evolutiveStatsIndicators[0]; + const indicatorFieldName = indicator?.fieldName; + if (typeof indicatorFieldName === 'string' && indicator?.fromValue) { + persons = persons.filter((p) => { + const snapshot = getPersonSnapshotAtDate({ person: p, snapshotDate: minimumDateForEvolutiveStats, fieldsMap }); + if (!snapshot) return false; + return getValueByField(indicatorFieldName, fieldsMap, p[indicatorFieldName]).includes(indicator.fromValue); + }); + } + + for (const person of persons) { const followedSince = dayjsInstance(person.followedSince || person.createdAt).format('YYYYMMDD'); const minimumDate = followedSince < minimumDateForEvolutiveStats ? minimumDateForEvolutiveStats : followedSince; let currentDate = today; let currentPerson = structuredClone(person); for (const field of indicatorsBase) { - const rawValue = getValueByField(field.name, currentPerson[field.name]); + const rawValue = getValueByField(field.name, fieldsMap, currentPerson[field.name]); if (rawValue === '') continue; const valueToLoop = Array.isArray(rawValue) ? rawValue : [rawValue]; for (const value of valueToLoop) { @@ -139,7 +204,7 @@ export const evolutiveStatsPersonSelector = selectorFamily({ while (currentDate > historyDate && currentDate > minimumDate) { currentDate = dayjsInstance(currentDate).subtract(1, 'day').format('YYYYMMDD'); for (const field of indicatorsBase) { - const rawValue = getValueByField(field.name, currentPerson[field.name]); + const rawValue = getValueByField(field.name, fieldsMap, currentPerson[field.name]); if (rawValue === '') continue; const valueToLoop = Array.isArray(rawValue) ? rawValue : [rawValue]; for (const value of valueToLoop) { @@ -158,9 +223,9 @@ export const evolutiveStatsPersonSelector = selectorFamily({ } } for (const historyChangeField of Object.keys(historyItem.data)) { - const oldValue = getValueByField(historyChangeField, historyItem.data[historyChangeField].oldValue); - const historyNewValue = getValueByField(historyChangeField, historyItem.data[historyChangeField].newValue); - const currentPersonValue = getValueByField(historyChangeField, currentPerson[historyChangeField]); + const oldValue = getValueByField(historyChangeField, fieldsMap, historyItem.data[historyChangeField].oldValue); + const historyNewValue = getValueByField(historyChangeField, fieldsMap, historyItem.data[historyChangeField].newValue); + const currentPersonValue = getValueByField(historyChangeField, fieldsMap, currentPerson[historyChangeField]); if (JSON.stringify(historyNewValue) !== JSON.stringify(currentPersonValue)) { capture(new Error('Incoherent history'), { extra: { @@ -175,14 +240,17 @@ export const evolutiveStatsPersonSelector = selectorFamily({ }); } if (oldValue === '') continue; - currentPerson[historyChangeField] = oldValue; + currentPerson = { + ...currentPerson, + [historyChangeField]: oldValue, + }; } } } while (currentDate >= minimumDate) { currentDate = dayjsInstance(currentDate).subtract(1, 'day').format('YYYYMMDD'); for (const field of indicatorsBase) { - const rawValue = getValueByField(field.name, currentPerson[field.name]); + const rawValue = getValueByField(field.name, fieldsMap, currentPerson[field.name]); if (rawValue === '') continue; const valueToLoop = Array.isArray(rawValue) ? rawValue : [rawValue]; for (const value of valueToLoop) { diff --git a/dashboard/src/types/evolutivesStats.ts b/dashboard/src/types/evolutivesStats.ts index 627dd11f7..b83abf9a4 100644 --- a/dashboard/src/types/evolutivesStats.ts +++ b/dashboard/src/types/evolutivesStats.ts @@ -1,4 +1,5 @@ import type { CustomOrPredefinedField } from '../types/field'; +import type { UUIDV4 } from './uuid'; export type EvolutiveStatOption = string; export type EvolutiveStatDateYYYYMMDD = string; @@ -6,6 +7,10 @@ export type EvolutiveStatsPersonFields = Record< CustomOrPredefinedField['name'], Record> >; +export type EvolutiveStatsOldestStatusPersonFields = Record< + CustomOrPredefinedField['name'], + Record>> +>; export type IndicatorValue = any; export type Indicator = {