diff --git a/frontend/src/api/stats.js b/frontend/src/api/stats.js
index 5bacdd28dc..6e778dd7ef 100644
--- a/frontend/src/api/stats.js
+++ b/frontend/src/api/stats.js
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
+import { fetchExternalJSONAPI } from '../network/genericJSONRequest';
import api from './apiClient';
import { OHSOME_STATS_BASE_URL } from '../config';
@@ -42,7 +43,7 @@ export const useOsmStatsQuery = () => {
queryKey: ['osm-stats'],
queryFn: fetchOsmStats,
useErrorBoundary: true,
- select: (data) => data.data.result
+ select: (data) => data.data.result,
});
};
@@ -61,3 +62,20 @@ export const useOsmHashtagStatsQuery = (defaultComment) => {
select: (data) => data.data.result,
});
};
+
+export const useUserOsmStatsQuery = (id) => {
+ const fetchUserOsmStats = () => {
+ return fetchExternalJSONAPI(
+ `${OHSOME_STATS_BASE_URL}/topic/poi,highway,building,waterway/user?userId=${id}`,
+ true,
+ );
+ };
+
+ return useQuery({
+ queryKey: ['user-osm-stats'],
+ queryFn: fetchUserOsmStats,
+ useErrorBoundary: true,
+ select: (data) => data.result,
+ enabled: !!id,
+ });
+};
diff --git a/frontend/src/assets/styles/_extra.scss b/frontend/src/assets/styles/_extra.scss
index b5887bdfb5..171413e00f 100644
--- a/frontend/src/assets/styles/_extra.scss
+++ b/frontend/src/assets/styles/_extra.scss
@@ -621,3 +621,17 @@ a[href="https://www.mapbox.com/map-feedback/"]
.code {
font-family: inherit;
}
+
+// margin auto
+.mt-auto {
+ margin-top: auto;
+}
+.mb-auto {
+ margin-bottom: auto;
+}
+.ml-auto {
+ margin-left: auto;
+}
+.mr-auto {
+ margin-right: auto;
+}
diff --git a/frontend/src/components/statsCard.js b/frontend/src/components/statsCard.js
index 5a9446606f..a8c3d5a754 100644
--- a/frontend/src/components/statsCard.js
+++ b/frontend/src/components/statsCard.js
@@ -1,5 +1,6 @@
import React from 'react';
import { FormattedNumber } from 'react-intl';
+import shortNumber from 'short-number';
export const StatsCard = ({ icon, description, value, className, invertColors = false }) => {
return (
@@ -25,3 +26,72 @@ export const StatsCardContent = ({ value, label, className, invertColors = false
{label}
);
+
+function getFormattedNumber(num) {
+ if (typeof num !== 'number') return '-';
+ const value = shortNumber(num);
+ return typeof value === 'number' ? : value;
+}
+
+export const DetailedStatsCard = ({
+ icon,
+ description,
+ subDescription,
+ mapped,
+ created,
+ modified,
+ deleted,
+ unitMore,
+ unitLess,
+}) => {
+ return (
+
+
+
{icon}
+
+
{getFormattedNumber(mapped)}
+
+ {description}
+ {subDescription}
+
+
+
+
+ {/* seperator line */}
+
+
+
+
+
{getFormattedNumber(created)}
+ Created
+
+
+
+ {unitMore || unitLess ? (
+ <>
+ -{getFormattedNumber(unitLess)}
+ {/* seperator line */}
+
+ +{getFormattedNumber(unitMore)}
+ >
+ ) : (
+ getFormattedNumber(modified)
+ )}
+
+
Modified
+
+
+
{getFormattedNumber(deleted)}
+ Deleted
+
+
+
+ );
+};
diff --git a/frontend/src/components/userDetail/editsByNumbers.js b/frontend/src/components/userDetail/editsByNumbers.js
deleted file mode 100644
index 45546d9ec5..0000000000
--- a/frontend/src/components/userDetail/editsByNumbers.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
-import { Doughnut } from 'react-chartjs-2';
-import { FormattedMessage, useIntl } from 'react-intl';
-
-import messages from './messages';
-import typesMessages from '../messages';
-import { CHART_COLOURS } from '../../config';
-import { formatChartData, formatTooltip } from '../../utils/formatChartJSData';
-
-ChartJS.register(ArcElement, Tooltip, Legend);
-
-const EditsByNumbers = ({ osmStats }) => {
- const intl = useIntl();
- let reference = [
- {
- label: intl.formatMessage(typesMessages.buildings),
- field: 'buildings',
- backgroundColor: CHART_COLOURS.red,
- borderColor: CHART_COLOURS.white,
- },
- {
- label: intl.formatMessage(typesMessages.roads),
- field: 'roads',
- backgroundColor: CHART_COLOURS.green,
- borderColor: CHART_COLOURS.white,
- },
- {
- label: intl.formatMessage(typesMessages.pointsOfInterest),
- field: 'total_poi_count_add',
- backgroundColor: CHART_COLOURS.orange,
- borderColor: CHART_COLOURS.white,
- },
- {
- label: intl.formatMessage(typesMessages.waterways),
- field: 'total_waterway_count_add',
- backgroundColor: CHART_COLOURS.blue,
- borderColor: CHART_COLOURS.white,
- },
- ];
-
- const data = formatChartData(reference, osmStats);
-
- return (
-
-
-
-
-
- {Object.keys(osmStats).length && data.datasets[0].data.some((x) => !isNaN(x)) ? (
-
formatTooltip(context) } },
- },
- }}
- />
- ) : (
-
-
-
- )}
-
-
- );
-};
-
-export default EditsByNumbers;
diff --git a/frontend/src/components/userDetail/elementsMapped.js b/frontend/src/components/userDetail/elementsMapped.js
index 72c9a58126..7819826b57 100644
--- a/frontend/src/components/userDetail/elementsMapped.js
+++ b/frontend/src/components/userDetail/elementsMapped.js
@@ -12,7 +12,7 @@ import {
MappedIcon,
ValidatedIcon,
} from '../svgIcons';
-import { StatsCard } from '../statsCard';
+import { StatsCard, DetailedStatsCard } from '../statsCard';
import StatsTimestamp from '../statsTimestamp';
export const TaskStats = ({ userStats, username }) => {
@@ -133,25 +133,45 @@ export const ElementsMapped = ({ userStats, osmStats }) => {
description={}
value={duration}
/>
- }
description={}
- value={osmStats.buildings || 0}
+ subDescription="Created - Deleted"
+ mapped={osmStats?.building?.value}
+ created={osmStats?.building?.added}
+ modified={osmStats?.building?.modified?.count_modified}
+ deleted={osmStats?.building?.deleted}
/>
- }
description={}
- value={osmStats.roads || 0}
+ subDescription="Created + Modified - Deleted"
+ mapped={osmStats?.highway?.value}
+ created={osmStats?.highway?.added}
+ modified={osmStats?.highway?.modified?.count_modified}
+ deleted={osmStats?.highway?.deleted}
+ unitMore={osmStats?.highway?.modified?.unit_more}
+ unitLess={osmStats?.highway?.modified?.unit_less}
/>
- }
description={}
- value={osmStats.total_poi_count_add || '-'}
+ subDescription="Created - Deleted"
+ mapped={osmStats?.poi?.value}
+ created={osmStats?.poi?.added}
+ modified={osmStats?.poi?.modified?.count_modified}
+ deleted={osmStats?.poi?.deleted}
/>
- }
description={}
- value={osmStats.total_waterway_km_add || '-'}
+ subDescription="Created + Modified - Deleted"
+ mapped={osmStats?.waterway?.value}
+ created={osmStats?.waterway?.added}
+ modified={osmStats?.waterway?.modified?.count_modified}
+ deleted={osmStats?.waterway?.deleted}
+ unitMore={osmStats?.waterway?.modified?.unit_more}
+ unitLess={osmStats?.waterway?.modified?.unit_less}
/>
diff --git a/frontend/src/components/userDetail/tests/editByNumbers.test.js b/frontend/src/components/userDetail/tests/editByNumbers.test.js
deleted file mode 100644
index e8d370e897..0000000000
--- a/frontend/src/components/userDetail/tests/editByNumbers.test.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-
-import { ReduxIntlProviders } from '../../../utils/testWithIntl';
-import EditsByNumbers from '../editsByNumbers';
-
-jest.mock('react-chartjs-2', () => ({
- Doughnut: () => null,
-}));
-
-describe('EditsByNumbers card', () => {
- it('renders a message if the user has not stats yet', () => {
- render(
-
-
- ,
- );
-
- expect(screen.getByText('Edits by numbers').className).toBe('f125 mv3 fw6');
- expect(
- screen.getByText(
- 'No data to show yet. OpenStreetMap edits stats are updated with a delay of one hour.',
- ),
- ).toBeInTheDocument();
- });
-
- it('renders the chart if osmStats data is present', () => {
- const stats = {
- buildings: 3282,
- total_waterway_count_add: 11493,
- total_poi_count_add: 10805,
- roads: 5571.84370201545,
- total_waterway_km_add: 512.706405358494,
- total_road_count_add: 13345,
- total_road_count_mod: 51730,
- };
- render(
-
-
- ,
- );
-
- expect(screen.getByText('Edits by numbers').className).toBe('f125 mv3 fw6');
- expect(
- screen.queryByText(
- 'No data to show yet. OpenStreetMap edits stats are updated with a delay of one hour.',
- ),
- ).not.toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/userDetail/tests/elementsMapped.test.js b/frontend/src/components/userDetail/tests/elementsMapped.test.js
index 592e1e6a68..a4d5b26082 100644
--- a/frontend/src/components/userDetail/tests/elementsMapped.test.js
+++ b/frontend/src/components/userDetail/tests/elementsMapped.test.js
@@ -11,10 +11,38 @@ describe('ElementsMapped & TaskStats components', () => {
timeSpentMapping: 3000,
};
const osmStats = {
- total_building_count_add: 10,
- roads: 229.113,
- total_poi_count_add: 15,
- total_waterway_count_add: 20,
+ poi: {
+ added: 4,
+ modified: {
+ count_modified: 1,
+ },
+ deleted: 0,
+ value: 4,
+ },
+ highway: {
+ added: 6,
+ modified: {
+ count_modified: 21,
+ },
+ deleted: 0,
+ value: 229,
+ },
+ building: {
+ added: 293,
+ modified: {
+ count_modified: 83,
+ },
+ deleted: 44,
+ value: 249,
+ },
+ waterway: {
+ added: 16,
+ modified: {
+ count_modified: 27,
+ },
+ deleted: 0,
+ value: 17,
+ },
};
const { getByText } = render(
diff --git a/frontend/src/views/tests/contributions.test.js b/frontend/src/views/tests/contributions.test.js
index cdac0760a6..864f2d7563 100644
--- a/frontend/src/views/tests/contributions.test.js
+++ b/frontend/src/views/tests/contributions.test.js
@@ -76,9 +76,12 @@ describe('Contributions Page Index', () => {
describe('User Stats Page', () => {
it('should render the child component', async () => {
renderWithRouter(
-
-
- ,
+
+
+
+
+ ,
+ ,
);
expect(screen.getByRole('heading', { name: 'Contribution Timeline' })).toBeInTheDocument();
});
diff --git a/frontend/src/views/tests/userDetail.test.js b/frontend/src/views/tests/userDetail.test.js
index a70b0e32b4..6d94eb4b69 100644
--- a/frontend/src/views/tests/userDetail.test.js
+++ b/frontend/src/views/tests/userDetail.test.js
@@ -22,9 +22,11 @@ describe('User Detail Component', () => {
});
const { router } = createComponentWithMemoryRouter(
-
-
- ,
+
+
+
+
+ ,
);
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
@@ -102,9 +104,11 @@ describe('User Detail Component', () => {
});
renderWithRouter(
-
-
- ,
+
+
+
+
+ ,
);
expect(
@@ -116,9 +120,11 @@ describe('User Detail Component', () => {
it('should not display header when the prop is falsy', () => {
renderWithRouter(
-
-
- ,
+
+
+
+
+ ,
);
expect(screen.queryByText('Somebody')).not.toBeInTheDocument();
});
diff --git a/frontend/src/views/userDetail.js b/frontend/src/views/userDetail.js
index 305d508f86..469d77ce11 100644
--- a/frontend/src/views/userDetail.js
+++ b/frontend/src/views/userDetail.js
@@ -12,17 +12,15 @@ import { CountriesMapped } from '../components/userDetail/countriesMapped';
import { TopProjects } from '../components/userDetail/topProjects';
import { ContributionTimeline } from '../components/userDetail/contributionTimeline';
import { NotFound } from './notFound';
-import { OHSOME_STATS_BASE_URL, OSM_SERVER_URL } from '../config';
+import { OSM_SERVER_URL } from '../config';
import { fetchExternalJSONAPI } from '../network/genericJSONRequest';
import { useFetch } from '../hooks/UseFetch';
import { useSetTitleTag } from '../hooks/UseMetaTags';
+import { useUserOsmStatsQuery } from '../api/stats';
const TopCauses = React.lazy(() =>
import('../components/userDetail/topCauses' /* webpackChunkName: "topCauses" */),
);
-const EditsByNumbers = React.lazy(() =>
- import('../components/userDetail/editsByNumbers' /* webpackChunkName: "editsByNumbers" */),
-);
export const UserDetail = ({ withHeader = true }) => {
const navigate = useNavigate();
@@ -32,7 +30,6 @@ export const UserDetail = ({ withHeader = true }) => {
useSetTitleTag(username);
const token = useSelector((state) => state.auth.token);
const currentUser = useSelector((state) => state.auth.userDetails);
- const [osmStats, setOsmStats] = useState({});
const [userOsmDetails, setUserOsmDetails] = useState({});
const [errorDetails, loadingDetails, userDetails] = useFetch(
`users/queries/${username}/`,
@@ -46,6 +43,7 @@ export const UserDetail = ({ withHeader = true }) => {
`projects/queries/${username}/touched/`,
username !== undefined,
);
+ const { data: osmStats } = useUserOsmStatsQuery(userDetails.id);
useEffect(() => {
if (!token) {
@@ -58,9 +56,6 @@ export const UserDetail = ({ withHeader = true }) => {
fetchExternalJSONAPI(`${OSM_SERVER_URL}/api/0.6/user/${userDetails.id}.json`, false)
.then((res) => setUserOsmDetails(res?.user))
.catch((e) => console.log(e));
- fetchExternalJSONAPI(`${OHSOME_STATS_BASE_URL}/hot-tm-user?userId=${userDetails.id}`, true)
- .then((res) => setOsmStats(res.result))
- .catch((e) => console.log(e));
}
}, [userDetails.id]);
@@ -78,7 +73,10 @@ export const UserDetail = ({ withHeader = true }) => {
rows={5}
ready={!errorDetails && !loadingDetails}
>
-
+
)}
@@ -126,11 +124,6 @@ export const UserDetail = ({ withHeader = true }) => {
-
- }>
-
-
-