Skip to content

Commit 7b4a47a

Browse files
committed
feat(operational-learning): show source details on click for sources over time map
1 parent 6c04b5b commit 7b4a47a

File tree

2 files changed

+154
-78
lines changed

2 files changed

+154
-78
lines changed

app/src/views/OperationalLearning/Stats/index.tsx

Lines changed: 98 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ import {
99
Container,
1010
KeyFigure,
1111
LegendItem,
12+
TextOutput,
1213
TimeSeriesChart,
1314
} from '@ifrc-go/ui';
1415
import { useTranslation } from '@ifrc-go/ui/hooks';
15-
import { getDatesSeparatedByYear } from '@ifrc-go/ui/utils';
16+
import {
17+
formatDate,
18+
getDatesSeparatedByYear,
19+
getFormattedDateKey,
20+
} from '@ifrc-go/ui/utils';
1621
import {
1722
isDefined,
1823
isNotDefined,
@@ -46,6 +51,12 @@ const regionValueSelector = (datum: RegionStatItem) => datum.count;
4651
const regionLabelSelector = (datum: RegionStatItem) => datum.region_name;
4752

4853
type SourceType = 'dref' | 'emergencyAppeal' | 'others';
54+
interface SourceTypeOption {
55+
key: SourceType;
56+
label: string;
57+
color: string;
58+
}
59+
4960
const dataKeys: SourceType[] = [
5061
'dref',
5162
'emergencyAppeal',
@@ -62,11 +73,6 @@ const xAxisFormatter = (date: Date) => date.toLocaleString(
6273
{ year: 'numeric' },
6374
);
6475

65-
interface SourceTypeOption {
66-
key: SourceType;
67-
label: string;
68-
color: string;
69-
}
7076
type SourceTypeEnum = components<'read'>['schemas']['ApiAppealTypeEnumKey'];
7177
const SOURCE_TYPE_EMERGENCY = 1 satisfies SourceTypeEnum;
7278
const SOURCE_TYPE_DREF = 0 satisfies SourceTypeEnum;
@@ -75,13 +81,13 @@ const transformSourcesOverTimeData = (data: SourcesOverTimeItem[]) => {
7581
const groupedData: Record<string, Record<SourceType, number>> = {};
7682

7783
data.forEach((entry) => {
78-
const year = new Date(entry.date).getFullYear().toString();
84+
const year = getFormattedDateKey(entry.date);
7985
if (!groupedData[year]) {
8086
groupedData[year] = { dref: 0, emergencyAppeal: 0, others: 0 };
8187
}
82-
if (entry.type === SOURCE_TYPE_DREF) {
88+
if (entry.atype === SOURCE_TYPE_DREF) {
8389
groupedData[year].dref += entry.count;
84-
} else if (entry.type === SOURCE_TYPE_EMERGENCY) {
90+
} else if (entry.atype === SOURCE_TYPE_EMERGENCY) {
8591
groupedData[year].emergencyAppeal += entry.count;
8692
} else {
8793
groupedData[year].others += entry.count;
@@ -127,19 +133,20 @@ function Stats(props: Props) {
127133
},
128134
[learningStatsResponse],
129135
);
136+
130137
const dateList = useMemo(() => {
131138
if (isNotDefined(sourcesOverTimeData)) {
132139
return undefined;
133140
}
134-
const dates = Object.keys(sourcesOverTimeData).map((year) => new Date(Number(year), 0, 1));
141+
const dates = Object.keys(sourcesOverTimeData).map((year) => new Date(year));
135142
const oldestDate = new Date(Math.min(...dates.map((date) => date.getTime())));
136143
const latestDate = new Date(Math.max(...dates.map((date) => date.getTime())));
137144
return getDatesSeparatedByYear(oldestDate, latestDate);
138145
}, [sourcesOverTimeData]);
139146

140147
const sourcesOverTimeValueSelector = useCallback(
141148
(key: SourceType, date: Date) => {
142-
const value = sourcesOverTimeData?.[date.getFullYear()]?.[key];
149+
const value = sourcesOverTimeData?.[getFormattedDateKey(date)]?.[key];
143150
if (isDefined(value) && value > 0) {
144151
return value;
145152
}
@@ -170,26 +177,32 @@ function Stats(props: Props) {
170177
strings.sourceOthers,
171178
]);
172179

180+
const activePointData = activePointKey ? sourcesOverTimeData?.[activePointKey] : undefined;
181+
173182
return (
174183
<div className={styles.stats}>
175184
{learningStatsPending && <BlockLoading />}
176-
<div className={styles.keyFigureCard}>
185+
<div className={styles.keyFigureList}>
177186
<KeyFigure
187+
className={styles.keyFigure}
178188
value={learningStatsResponse?.operations_included}
179189
label={strings.operationsIncluded}
180190
labelClassName={styles.keyFigureDescription}
181191
/>
182192
<KeyFigure
193+
className={styles.keyFigure}
183194
value={learningStatsResponse?.sources_used}
184195
label={strings.sourcesUsed}
185196
labelClassName={styles.keyFigureDescription}
186197
/>
187198
<KeyFigure
199+
className={styles.keyFigure}
188200
value={learningStatsResponse?.learning_extracts}
189201
label={strings.learningExtract}
190202
labelClassName={styles.keyFigureDescription}
191203
/>
192204
<KeyFigure
205+
className={styles.keyFigure}
193206
value={learningStatsResponse?.sectors_covered}
194207
label={strings.sectorsCovered}
195208
labelClassName={styles.keyFigureDescription}
@@ -245,38 +258,84 @@ function Stats(props: Props) {
245258
withInternalPadding
246259
compactMessage
247260
pending={learningStatsPending}
261+
childrenContainerClassName={styles.chartContainer}
248262
empty={isDefined(learningStatsResponse?.sources_overtime) && (
249263
(learningStatsResponse?.sources_overtime.length ?? 0) < 1
250264
)}
251-
footerIcons={(
252-
<div className={styles.typeOfSourceLegend}>
253-
<div className={styles.legendLabel}>
254-
{strings.sourcesTypeLegendLabel}
255-
</div>
256-
<div className={styles.legendContent}>
257-
{sourceTypeOptions.map((source) => (
258-
<LegendItem
259-
key={source.key}
260-
label={source.label}
261-
color={source.color}
262-
/>
263-
))}
264-
</div>
265-
</div>
266-
)}
267265
>
268266
{isDefined(dateList) && (
269-
<TimeSeriesChart
270-
className={styles.timeSeriesChart}
271-
xAxisTickClassName={styles.xAxisTick}
272-
timePoints={dateList}
273-
dataKeys={dataKeys}
274-
valueSelector={sourcesOverTimeValueSelector}
275-
classNameSelector={sourceClassNameSelector}
276-
activePointKey={activePointKey}
277-
onTimePointClick={setActivePointKey}
278-
xAxisFormatter={xAxisFormatter}
279-
/>
267+
<>
268+
<TimeSeriesChart
269+
className={styles.timeSeriesChart}
270+
xAxisTickClassName={styles.xAxisTick}
271+
timePoints={dateList}
272+
dataKeys={dataKeys}
273+
valueSelector={sourcesOverTimeValueSelector}
274+
classNameSelector={sourceClassNameSelector}
275+
activePointKey={activePointKey}
276+
onTimePointClick={setActivePointKey}
277+
xAxisFormatter={xAxisFormatter}
278+
/>
279+
{isDefined(activePointKey) ? (
280+
<div
281+
className={styles.legend}
282+
>
283+
<TextOutput
284+
value={formatDate(activePointKey, 'yyyy') ?? '--'}
285+
strongValue
286+
/>
287+
<TextOutput
288+
label={(
289+
<LegendItem
290+
label={strings.sourceDREF}
291+
color="var(--color-source-dref)"
292+
/>
293+
)}
294+
withoutLabelColon
295+
value={activePointData?.dref}
296+
valueType="number"
297+
/>
298+
<TextOutput
299+
label={(
300+
<LegendItem
301+
label={strings.sourceEmergencyAppeal}
302+
color="var(--color-source-emergency-appeal)"
303+
/>
304+
)}
305+
withoutLabelColon
306+
value={activePointData?.emergencyAppeal}
307+
valueType="number"
308+
/>
309+
<TextOutput
310+
label={(
311+
<LegendItem
312+
label={strings.sourceOthers}
313+
color="var(--color-source-emergency-appeal)"
314+
/>
315+
)}
316+
withoutLabelColon
317+
value={activePointData?.others}
318+
valueType="number"
319+
/>
320+
</div>
321+
) : (
322+
<div className={styles.typeOfSourceLegend}>
323+
<div className={styles.legendLabel}>
324+
{strings.sourcesTypeLegendLabel}
325+
</div>
326+
<div className={styles.legendContent}>
327+
{sourceTypeOptions.map((source) => (
328+
<LegendItem
329+
key={source.key}
330+
label={source.label}
331+
color={source.color}
332+
/>
333+
))}
334+
</div>
335+
</div>
336+
)}
337+
</>
338+
280339
)}
281340

282341
</Container>

app/src/views/OperationalLearning/Stats/styles.module.css

Lines changed: 56 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33
flex-direction: column;
44
gap: var(--go-ui-spacing-md);
55

6-
.key-figure-card {
6+
.key-figure-list {
77
display: grid;
8-
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
9-
border-radius: var(--go-ui-border-radius-lg);
10-
box-shadow: var(--go-ui-box-shadow-md);
11-
padding: var(--go-ui-spacing-md);
12-
grid-gap: var(--go-ui-spacing-md);
8+
grid-gap: var(--go-ui-spacing-2xs);
9+
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
10+
11+
.key-figure {
12+
border: var(--go-ui-width-separator-sm) solid var(--go-ui-color-separator);
13+
border-radius: var(--go-ui-border-radius-lg);
14+
background-color: var(--go-ui-color-white);
15+
padding: var(--go-ui-spacing-lg) var(--go-ui-spacing-md);
16+
height: 100%;
17+
}
1318
}
1419

1520
.learning-overview {
@@ -38,47 +43,59 @@
3843
background-color: var(--go-ui-color-primary-blue);
3944
}
4045

41-
.time-series-chart {
42-
width: 100%;
43-
--path-stroke-width: 1pt;
46+
.chart-container {
47+
display: flex;
48+
position: relative;
49+
flex-direction: column;
50+
gap: var(--go-ui-spacing-md);
4451

45-
.dref {
46-
stroke: var(--color-source-dref);
47-
stroke-width: var(--path-stroke-width);
48-
fill: none;
49-
color: var(--color-source-dref);
52+
.legend {
53+
display: flex;
54+
gap: var(--go-ui-spacing-md);
5055
}
5156

52-
.emergency-appeal {
53-
stroke: var(--color-source-emergency-appeal);
54-
stroke-width: var(--path-stroke-width);
55-
fill: none;
56-
color: var(--color-source-emergency-appeal);
57-
}
57+
.time-series-chart {
58+
width: 100%;
59+
--path-stroke-width: 1pt;
5860

59-
.others {
60-
stroke: var(--color-source-others);
61-
stroke-width: var(--path-stroke-width);
62-
fill: none;
63-
color: var(--color-source-others);
64-
}
61+
.dref {
62+
color: var(--color-source-dref);
63+
stroke: var(--color-source-dref);
64+
stroke-width: var(--path-stroke-width);
65+
fill: none;
66+
}
6567

66-
.x-axis-tick {
67-
transform: rotate(-30deg);
68+
.emergency-appeal {
69+
color: var(--color-source-emergency-appeal);
70+
stroke: var(--color-source-emergency-appeal);
71+
stroke-width: var(--path-stroke-width);
72+
fill: none;
73+
}
74+
75+
.others {
76+
color: var(--color-source-others);
77+
stroke: var(--color-source-others);
78+
stroke-width: var(--path-stroke-width);
79+
fill: none;
80+
}
81+
82+
.x-axis-tick {
83+
transform: rotate(-30deg);
84+
}
6885
}
6986
}
7087

71-
.type-of-source-legend {
72-
display: flex;
73-
flex-wrap: wrap;
74-
gap: var(--go-ui-spacing-xs) var(--go-ui-spacing-md);
75-
76-
.legend-content {
77-
display: flex;
78-
flex-wrap: wrap;
79-
gap: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
80-
}
81-
}
88+
.type-of-source-legend {
89+
display: flex;
90+
flex-wrap: wrap;
91+
gap: var(--go-ui-spacing-xs) var(--go-ui-spacing-md);
92+
93+
.legend-content {
94+
display: flex;
95+
flex-wrap: wrap;
96+
gap: var(--go-ui-spacing-xs) var(--go-ui-spacing-sm);
97+
}
98+
}
8299
}
83100
}
84101
}

0 commit comments

Comments
 (0)