diff --git a/api/src/routes/api/stats.js b/api/src/routes/api/stats.js index a1c8934..322fac6 100644 --- a/api/src/routes/api/stats.js +++ b/api/src/routes/api/stats.js @@ -5,6 +5,7 @@ const { DateTime } = require('luxon'); const Track = mongoose.model('Track'); const User = mongoose.model('User'); const wrapRoute = require('../../_helpers/wrapRoute'); +const auth = require('../../passport'); // round to this number of meters for privacy reasons const TRACK_LENGTH_ROUNDING = 1000; @@ -14,6 +15,7 @@ const TRACK_DURATION_ROUNDING = 120; router.get( '/', + auth.optional, wrapRoute(async (req, res) => { const start = DateTime.fromISO(req.query.start); const end = DateTime.fromISO(req.query.end); @@ -24,25 +26,40 @@ router.get( ...(end.isValid ? { $lt: end.toJSDate() } : {}), }; - const trackCount = await Track.find({ + let userFilter; + if (req.query.user) { + const user = await User.findOne({ username: req.query.user }); + + // Only the user can look for their own stats, for now + if (req.user && req.user._id.equals(user._id)) { + userFilter = user._id; + } else { + userFilter = { $ne: null }; + } + } + + const trackFilter = { 'statistics.recordedAt': dateFilter, - }).count(); + ...(userFilter ? { author: userFilter } : {}), + }; + + const trackCount = await Track.find(trackFilter).count(); const publicTrackCount = await Track.find({ - 'statistics.recordedAt': dateFilter, + ...trackFilter, public: true, }).count(); const userCount = await User.find({ - createdAt: dateFilter, + ...(userFilter + ? { _id: userFilter } + : { + createdAt: dateFilter, + }), }).count(); const trackStats = await Track.aggregate([ - { - $match: { - 'statistics.recordedAt': dateFilter, - }, - }, + { $match: trackFilter }, { $addFields: { trackLength: { diff --git a/frontend/src/components/Stats/index.tsx b/frontend/src/components/Stats/index.tsx new file mode 100644 index 0000000..efa7381 --- /dev/null +++ b/frontend/src/components/Stats/index.tsx @@ -0,0 +1,114 @@ +import React, {useState, useCallback} from 'react' +import {pickBy} from 'lodash' +import {Loader, Statistic, Segment, Header, Menu} from 'semantic-ui-react' +import {useObservable} from 'rxjs-hooks' +import {of, from, concat, combineLatest} from 'rxjs' +import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' +import {Duration, DateTime} from 'luxon' + +import api from 'api' + +function formatDuration(seconds) { + return ( + Duration.fromMillis((seconds ?? 0) * 1000) + .as('hours') + .toFixed(1) + ' h' + ) +} + +export default function Stats({user = null}: {user?: null | string}) { + const [timeframe, setTimeframe] = useState('all_time') + const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe]) + + const stats = useObservable( + (_$, inputs$) => { + const timeframe$ = inputs$.pipe( + map((inputs) => inputs[0]), + distinctUntilChanged() + ) + + const user$ = inputs$.pipe( + map((inputs) => inputs[1]), + distinctUntilChanged() + ) + + return combineLatest(timeframe$, user$).pipe( + map(([timeframe_, user_]) => { + const now = DateTime.now() + + let start, end + + switch (timeframe_) { + case 'this_month': + start = now.startOf('month') + end = now.endOf('month') + break + + case 'this_year': + start = now.startOf('year') + end = now.endOf('year') + break + } + + return pickBy({ + start: start?.toISODate(), + end: end?.toISODate(), + user: user_, + }) + }), + switchMap((query) => concat(of(null), from(api.get('/stats', {query})))) + ) + }, + null, + [timeframe, user] + ) + + return ( + <> +
Statistics
+ +
+ + + + + {stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : '...'} + Total track length + + + {stats ? formatDuration(stats?.trackDuration) : '...'} + Time recorded + + + {stats?.numEvents ?? '...'} + Events confirmed + + {user ? ( + + {stats?.trackCount ?? '...'} + Tracks recorded + + ) : ( + + {stats?.userCount ?? '...'} + Members joined + + )} + + + + + + This month + + + This year + + + All time + + +
+ + ) +} diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index 71d2b37..741b859 100644 --- a/frontend/src/components/index.js +++ b/frontend/src/components/index.js @@ -6,4 +6,5 @@ export {default as LoginButton} from './LoginButton' export {default as Map} from './Map' export {default as Page} from './Page' export {default as RoadsLayer} from './RoadsLayer' +export {default as Stats} from './Stats' export {default as StripMarkdown} from './StripMarkdown' diff --git a/frontend/src/pages/HomePage.js b/frontend/src/pages/HomePage.js deleted file mode 100644 index 3417039..0000000 --- a/frontend/src/pages/HomePage.js +++ /dev/null @@ -1,159 +0,0 @@ -import React, {useState, useCallback} from 'react' -import {Link} from 'react-router-dom' -import {Message, Grid, Loader, Statistic, Segment, Header, Item, Menu} from 'semantic-ui-react' -import {useObservable} from 'rxjs-hooks' -import {of, from, concat} from 'rxjs' -import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' -import {fromLonLat} from 'ol/proj' -import {Duration, DateTime} from 'luxon' - -import api from '../api' -import {Map, Page, RoadsLayer} from '../components' - -import {TrackListItem} from './TracksPage' -import styles from './HomePage.module.scss' - -function formatDuration(seconds) { - return Duration.fromMillis((seconds ?? 0) * 1000) - .as('hours') - .toFixed(1) + ' h' -} - -function WelcomeMap() { - return ( - - - - {/* */} - - - ) -} - -function Stats() { - const [timeframe, setTimeframe] = useState('all_time') - const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe]) - - const stats = useObservable( - (_$, inputs$) => - inputs$.pipe( - map((inputs) => inputs[0]), - distinctUntilChanged(), - map((timeframe_) => { - const now = DateTime.now() - - switch (timeframe_) { - case 'this_month': - return { - start: now.startOf('month').toISODate(), - end: now.endOf('month').toISODate(), - } - case 'this_year': - return { - start: now.startOf('year').toISODate(), - end: now.endOf('year').toISODate(), - } - case 'all_time': - default: - return {} - } - }), - switchMap((query) => concat(of(null), from(api.get('/stats', {query})))), - ), - null, - [timeframe] - ) - - return ( - <> -
Statistics
- -
- - - - - {stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : '...'} - Total track length - - - {stats ? formatDuration(stats?.trackDuration) : '...'} - Time recorded - - - {stats?.numEvents ?? '...'} - Events confirmed - - - {stats?.userCount ?? '...'} - Members joined - - - - - - - This month - - - This year - - - All time - - -
- - ) -} - -function MostRecentTrack() { - const track: Track | null = useObservable( - () => - of(null).pipe( - switchMap(() => from(api.fetch('/tracks?limit=1'))), - map((response) => response?.tracks?.[0]) - ), - null, - [] - ) - - return ( - <> -
Most recent track
- - {track === undefined ? ( - - No public tracks yet. Upload the first! - - ) : track ? ( - - - - ) : null} - - ) -} - -export default function HomePage() { - return ( - - - - - - - - - - - - - - ) -} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..8c53fad --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {Link} from 'react-router-dom' +import {Message, Grid, Loader, Header, Item} from 'semantic-ui-react' +import {useObservable} from 'rxjs-hooks' +import {of, from} from 'rxjs' +import {map, switchMap} from 'rxjs/operators' +import {fromLonLat} from 'ol/proj' + +import api from 'api' +import {Stats, Map, Page, RoadsLayer} from 'components' + +import {TrackListItem} from './TracksPage' +import styles from './HomePage.module.scss' + +function WelcomeMap() { + return ( + + + + {/* */} + + + ) +} + + +function MostRecentTrack() { + const track: Track | null = useObservable( + () => + of(null).pipe( + switchMap(() => from(api.fetch('/tracks?limit=1'))), + map((response) => response?.tracks?.[0]) + ), + null, + [] + ) + + return ( + <> +
Most recent track
+ + {track === undefined ? ( + + No public tracks yet. Upload the first! + + ) : track ? ( + + + + ) : null} + + ) +} + +export default function HomePage() { + return ( + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c9b95ae..f87571e 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,14 +1,13 @@ import React from 'react' import {connect} from 'react-redux' -import {Message, Icon, Grid, Form, Button, TextArea, Ref, Input, Header} from 'semantic-ui-react' +import {Message, Icon, Grid, Form, Button, TextArea, Ref, Input, Header, Divider} from 'semantic-ui-react' import {useForm} from 'react-hook-form' import {setLogin} from 'reducers/login' -import {Page} from 'components' +import {Page, Stats} from 'components' import api from 'api' import {findInput} from 'utils' - const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) { const {register, handleSubmit} = useForm() const [loading, setLoading] = React.useState(false) @@ -32,41 +31,44 @@ const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(func return ( - - - + + + +
Your profile
-
Your profile
+ All of this information is public. - All of this information is public. +
+ + + + + + +