Skip to content

Commit a84e83a

Browse files
committed
feat: extract operation learning stats into a standalone component
1 parent b1f2558 commit a84e83a

File tree

6 files changed

+339
-273
lines changed

6 files changed

+339
-273
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"namespace": "operationalLearning",
3+
"strings": {
4+
"operationsIncluded": "Operations Included",
5+
"sourcesUsed": "Sources Used",
6+
"learningExtract": "Learning Extracts",
7+
"sectorsCovered": "Sectors Covered",
8+
"learningBySector": "Learning by sectors",
9+
"learningByRegions": "Learning by regions",
10+
"sourcesOverTime": "Sources over time",
11+
"failedToFetchStats": "Failed To fetch Operational Learning statistics."
12+
}
13+
}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import {
2+
useCallback,
3+
useMemo,
4+
useState,
5+
} from 'react';
6+
import {
7+
BarChart,
8+
BlockLoading,
9+
Container,
10+
KeyFigure,
11+
TimeSeriesChart,
12+
} from '@ifrc-go/ui';
13+
import { useTranslation } from '@ifrc-go/ui/hooks';
14+
import { getDatesSeparatedByYear } from '@ifrc-go/ui/utils';
15+
import {
16+
isDefined,
17+
isNotDefined,
18+
} from '@togglecorp/fujs';
19+
20+
import useAlert from '#hooks/useAlert';
21+
import {
22+
type GoApiResponse,
23+
type GoApiUrlQuery,
24+
useRequest,
25+
} from '#utils/restRequest';
26+
27+
import OperationalLearningMap from '../OperationalLearningMap';
28+
29+
import i18n from './i18n.json';
30+
import styles from './styles.module.css';
31+
32+
type OpsLearningQuery = GoApiUrlQuery<'/api/v2/ops-learning/'>;
33+
type OpsLearningSummaryResponse = GoApiResponse<'/api/v2/ops-learning/stats/'>;
34+
type SectorStatItem = NonNullable<OpsLearningSummaryResponse['learning_by_sector']>[number];
35+
type RegionStatItem = NonNullable<OpsLearningSummaryResponse['learning_by_region']>[number];
36+
type SourcesOverTimeItem = NonNullable<OpsLearningSummaryResponse['sources_overtime']>[number];
37+
38+
const sectorKeySelector = (datum: SectorStatItem) => datum.sector_id;
39+
const sectorValueSelector = (datum: SectorStatItem) => datum.count;
40+
const sectorLabelSelector = (datum: SectorStatItem) => datum.title;
41+
42+
const regionKeySelector = (datum: RegionStatItem) => datum.region_id;
43+
const regionValueSelector = (datum: RegionStatItem) => datum.count;
44+
const regionLabelSelector = (datum: RegionStatItem) => datum.region_name;
45+
46+
type SourceType = 'dref' | 'emergencyAppeal' | 'others';
47+
const dataKeyToClassNameMap: Record<SourceType, string> = {
48+
dref: styles.dref,
49+
emergencyAppeal: styles.emergencyAppeal,
50+
others: styles.others,
51+
};
52+
const dataKeys: SourceType[] = [
53+
'dref',
54+
'emergencyAppeal',
55+
'others',
56+
];
57+
const sourceClassNameSelector = (dataKey: SourceType) => dataKeyToClassNameMap[dataKey];
58+
const xAxisFormatter = (date: Date) => date.toLocaleString(
59+
navigator.language,
60+
{ year: 'numeric' },
61+
);
62+
63+
interface Props {
64+
query: OpsLearningQuery | undefined
65+
}
66+
67+
const transformSourcesOverTimeData = (data: SourcesOverTimeItem[]) => {
68+
const groupedData: Record<string, Record<SourceType, number>> = {};
69+
70+
data.forEach((entry) => {
71+
const year = new Date(entry.date).getFullYear().toString();
72+
if (!groupedData[year]) {
73+
groupedData[year] = { dref: 0, emergencyAppeal: 0, others: 0 };
74+
}
75+
if (entry.type_display === 'DREF') {
76+
groupedData[year].dref += entry.count;
77+
} else if (entry.type_display === 'Emergency Appeal') {
78+
groupedData[year].emergencyAppeal += entry.count;
79+
} else {
80+
groupedData[year].others += entry.count;
81+
}
82+
});
83+
84+
return groupedData;
85+
};
86+
87+
function Stats(props: Props) {
88+
const {
89+
query,
90+
} = props;
91+
92+
const strings = useTranslation(i18n);
93+
const alert = useAlert();
94+
const [activePointKey, setActivePointKey] = useState<string>();
95+
96+
const {
97+
response: learningStatsResponse,
98+
pending: learningStatsPending,
99+
} = useRequest({
100+
url: '/api/v2/ops-learning/stats/',
101+
query,
102+
onFailure: () => {
103+
alert.show(
104+
strings.failedToFetchStats,
105+
{ variant: 'danger' },
106+
);
107+
},
108+
});
109+
110+
const sourcesOverTimeData = useMemo(
111+
() => {
112+
if (isNotDefined(learningStatsResponse)) {
113+
return undefined;
114+
}
115+
return transformSourcesOverTimeData(learningStatsResponse.sources_overtime);
116+
},
117+
[learningStatsResponse],
118+
);
119+
const dateList = useMemo(() => {
120+
if (isNotDefined(sourcesOverTimeData)) {
121+
return undefined;
122+
}
123+
const dates = Object.keys(sourcesOverTimeData).map((year) => new Date(Number(year), 0, 1));
124+
const oldestDate = new Date(Math.min(...dates.map((date) => date.getTime())));
125+
const latestDate = new Date(Math.max(...dates.map((date) => date.getTime())));
126+
return getDatesSeparatedByYear(oldestDate, latestDate);
127+
}, [sourcesOverTimeData]);
128+
129+
const sourcesOverTimeValueSelector = useCallback(
130+
(key: SourceType, date: Date) => {
131+
const value = sourcesOverTimeData?.[date.getFullYear()]?.[key];
132+
if (isDefined(value) && value > 0) {
133+
return value;
134+
}
135+
return undefined;
136+
},
137+
[sourcesOverTimeData],
138+
);
139+
140+
return (
141+
<div className={styles.stats}>
142+
{learningStatsPending && <BlockLoading />}
143+
<div className={styles.keyFigureCard}>
144+
<KeyFigure
145+
className={styles.keyFigure}
146+
value={learningStatsResponse?.operations_included}
147+
label={strings.operationsIncluded}
148+
labelClassName={styles.keyFigureDescription}
149+
/>
150+
<div className={styles.separator} />
151+
<KeyFigure
152+
className={styles.keyFigure}
153+
value={learningStatsResponse?.sources_used}
154+
label={strings.sourcesUsed}
155+
labelClassName={styles.keyFigureDescription}
156+
/>
157+
<div className={styles.separator} />
158+
<KeyFigure
159+
className={styles.keyFigure}
160+
value={learningStatsResponse?.learning_extracts}
161+
label={strings.learningExtract}
162+
labelClassName={styles.keyFigureDescription}
163+
/>
164+
<div className={styles.separator} />
165+
<KeyFigure
166+
className={styles.keyFigure}
167+
value={learningStatsResponse?.sectors_covered}
168+
label={strings.sectorsCovered}
169+
labelClassName={styles.keyFigureDescription}
170+
/>
171+
</div>
172+
<div className={styles.learningOverview}>
173+
<OperationalLearningMap
174+
learning={learningStatsResponse}
175+
/>
176+
<div className={styles.charts}>
177+
<Container
178+
heading={strings.learningBySector}
179+
className={styles.learningChart}
180+
withHeaderBorder
181+
withInternalPadding
182+
compactMessage
183+
empty={isDefined(learningStatsResponse?.learning_by_sector) && (
184+
learningStatsResponse?.learning_by_sector.length < 1
185+
)}
186+
>
187+
<BarChart
188+
data={learningStatsResponse?.learning_by_sector}
189+
keySelector={sectorKeySelector}
190+
valueSelector={sectorValueSelector}
191+
labelSelector={sectorLabelSelector}
192+
/>
193+
</Container>
194+
<Container
195+
heading={strings.learningByRegions}
196+
className={styles.learningChart}
197+
withHeaderBorder
198+
withInternalPadding
199+
compactMessage
200+
empty={isDefined(learningStatsResponse?.learning_by_region) && (
201+
learningStatsResponse?.learning_by_region?.length < 1
202+
)}
203+
>
204+
<BarChart
205+
data={learningStatsResponse?.learning_by_region}
206+
keySelector={regionKeySelector}
207+
valueSelector={regionValueSelector}
208+
labelSelector={regionLabelSelector}
209+
/>
210+
</Container>
211+
<Container
212+
heading={strings.sourcesOverTime}
213+
className={styles.learningChart}
214+
withHeaderBorder
215+
withInternalPadding
216+
compactMessage
217+
empty={isDefined(learningStatsResponse?.sources_overtime) && (
218+
learningStatsResponse?.sources_overtime?.length < 1
219+
)}
220+
>
221+
{isDefined(dateList) && (
222+
<TimeSeriesChart
223+
className={styles.timeSeriesChart}
224+
timePoints={dateList}
225+
dataKeys={dataKeys}
226+
valueSelector={sourcesOverTimeValueSelector}
227+
classNameSelector={sourceClassNameSelector}
228+
activePointKey={activePointKey}
229+
onTimePointClick={setActivePointKey}
230+
xAxisFormatter={xAxisFormatter}
231+
/>
232+
)}
233+
234+
</Container>
235+
</div>
236+
</div>
237+
</div>
238+
);
239+
}
240+
241+
export default Stats;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
.stats {
2+
.key-figure-card {
3+
display: flex;
4+
flex-wrap: wrap;
5+
border-radius: var(--go-ui-border-radius-lg);
6+
box-shadow: var(--go-ui-box-shadow-md);
7+
padding: var(--go-ui-spacing-md);
8+
gap: var(--go-ui-spacing-md);
9+
10+
.separator {
11+
flex-shrink: 0;
12+
background-color: var(--go-ui-color-separator);
13+
width: var(--go-ui-width-separator-sm);
14+
}
15+
16+
.key-figure {
17+
flex-grow: 1;
18+
padding: 0;
19+
}
20+
}
21+
22+
.learning-overview {
23+
display: grid;
24+
grid-gap: var(--go-ui-spacing-md);
25+
grid-template-columns: 5fr 3fr;
26+
27+
.charts {
28+
display: flex;
29+
flex-direction: column;
30+
gap: var(--go-ui-spacing-md);
31+
32+
.learning-chart {
33+
border-radius: var(--go-ui-border-radius-lg);
34+
box-shadow: var(--go-ui-box-shadow-md);
35+
36+
.time-series-chart {
37+
width: 100%;
38+
--color-dref: var(--go-ui-color-primary-blue);
39+
--color-emergency-appeal: var(--go-ui-color-blue-60);
40+
--color-others: var(--go-ui-color-blue-20);
41+
--path-stroke-width: 1pt;
42+
43+
.dref {
44+
stroke: var(--color-dref);
45+
stroke-width: var(--path-stroke-width);
46+
fill: none;
47+
color: var(--color-dref);
48+
}
49+
50+
.emergency-appeal {
51+
stroke: var(--color-emergency-appeal);
52+
stroke-width: var(--path-stroke-width);
53+
fill: none;
54+
color: var(--color-emergency-appeal);
55+
}
56+
57+
.others {
58+
stroke: var(--color-others);
59+
stroke-width: var(--path-stroke-width);
60+
fill: none;
61+
color: var(--color-others);
62+
}
63+
}
64+
}
65+
}
66+
}
67+
}

app/src/views/OperationalLearning/i18n.json

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,7 @@
2121
"disclaimerMessage": "This is an updated implementation of the Operational Learning project started by the DREF and PER teams at IFRC. The previous dashboard can be found {link}.",
2222
"here": "here",
2323
"beta": "beta",
24-
"operationsIncluded": "Operations Included",
25-
"sourcesUsed": "Sources Used",
26-
"learningExtract": "Learning Extracts",
27-
"sectorsCovered": "Sectors Covered",
28-
"learningBySector": "Learning by sectors",
29-
"learningByRegions": "Learning by regions",
30-
"sourceOvertime": "Sources overtime",
31-
"sourceOvertimeResponseError" :"Chart not available"
24+
"failedToFetchSummary": "Failed to fetch operational learning summary",
25+
"failedToFetchLearning": "Failed to fetch operational learning"
3226
}
3327
}

0 commit comments

Comments
 (0)