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 }) => { -
- }> - - -