Show per-user stats in profile settings

This commit is contained in:
Paul Bienkowski 2021-05-14 20:44:36 +02:00
parent 630f8ca10c
commit 54d0a56b9a
6 changed files with 258 additions and 206 deletions

View file

@ -5,6 +5,7 @@ const { DateTime } = require('luxon');
const Track = mongoose.model('Track'); const Track = mongoose.model('Track');
const User = mongoose.model('User'); const User = mongoose.model('User');
const wrapRoute = require('../../_helpers/wrapRoute'); const wrapRoute = require('../../_helpers/wrapRoute');
const auth = require('../../passport');
// round to this number of meters for privacy reasons // round to this number of meters for privacy reasons
const TRACK_LENGTH_ROUNDING = 1000; const TRACK_LENGTH_ROUNDING = 1000;
@ -14,6 +15,7 @@ const TRACK_DURATION_ROUNDING = 120;
router.get( router.get(
'/', '/',
auth.optional,
wrapRoute(async (req, res) => { wrapRoute(async (req, res) => {
const start = DateTime.fromISO(req.query.start); const start = DateTime.fromISO(req.query.start);
const end = DateTime.fromISO(req.query.end); const end = DateTime.fromISO(req.query.end);
@ -24,25 +26,40 @@ router.get(
...(end.isValid ? { $lt: end.toJSDate() } : {}), ...(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, 'statistics.recordedAt': dateFilter,
}).count(); ...(userFilter ? { author: userFilter } : {}),
};
const trackCount = await Track.find(trackFilter).count();
const publicTrackCount = await Track.find({ const publicTrackCount = await Track.find({
'statistics.recordedAt': dateFilter, ...trackFilter,
public: true, public: true,
}).count(); }).count();
const userCount = await User.find({ const userCount = await User.find({
...(userFilter
? { _id: userFilter }
: {
createdAt: dateFilter, createdAt: dateFilter,
}),
}).count(); }).count();
const trackStats = await Track.aggregate([ const trackStats = await Track.aggregate([
{ { $match: trackFilter },
$match: {
'statistics.recordedAt': dateFilter,
},
},
{ {
$addFields: { $addFields: {
trackLength: { trackLength: {

View file

@ -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 (
<>
<Header as="h2">Statistics</Header>
<div>
<Segment attached="top">
<Loader active={stats == null} />
<Statistic.Group widths={2} size="tiny">
<Statistic>
<Statistic.Value>{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : '...'}</Statistic.Value>
<Statistic.Label>Total track length</Statistic.Label>
</Statistic>
<Statistic>
<Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : '...'}</Statistic.Value>
<Statistic.Label>Time recorded</Statistic.Label>
</Statistic>
<Statistic>
<Statistic.Value>{stats?.numEvents ?? '...'}</Statistic.Value>
<Statistic.Label>Events confirmed</Statistic.Label>
</Statistic>
{user ? (
<Statistic>
<Statistic.Value>{stats?.trackCount ?? '...'}</Statistic.Value>
<Statistic.Label>Tracks recorded</Statistic.Label>
</Statistic>
) : (
<Statistic>
<Statistic.Value>{stats?.userCount ?? '...'}</Statistic.Value>
<Statistic.Label>Members joined</Statistic.Label>
</Statistic>
)}
</Statistic.Group>
</Segment>
<Menu widths={3} attached="bottom" size="small">
<Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}>
This month
</Menu.Item>
<Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}>
This year
</Menu.Item>
<Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}>
All time
</Menu.Item>
</Menu>
</div>
</>
)
}

View file

@ -6,4 +6,5 @@ export {default as LoginButton} from './LoginButton'
export {default as Map} from './Map' export {default as Map} from './Map'
export {default as Page} from './Page' export {default as Page} from './Page'
export {default as RoadsLayer} from './RoadsLayer' export {default as RoadsLayer} from './RoadsLayer'
export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown' export {default as StripMarkdown} from './StripMarkdown'

View file

@ -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 (
<Map className={styles.welcomeMap}>
<RoadsLayer />
<Map.TileLayer
osm={{
url: 'https://tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
crossOrigin: null,
}}
/>
{/* <Map.View maxZoom={22} zoom={6} center={fromLonLat([10, 51])} /> */}
<Map.View maxZoom={22} zoom={13} center={fromLonLat([9.1798, 48.7759])} />
</Map>
)
}
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 (
<>
<Header as="h2">Statistics</Header>
<div>
<Segment attached="top">
<Loader active={stats == null} />
<Statistic.Group widths={2} size="tiny">
<Statistic>
<Statistic.Value>{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : '...'}</Statistic.Value>
<Statistic.Label>Total track length</Statistic.Label>
</Statistic>
<Statistic>
<Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : '...'}</Statistic.Value>
<Statistic.Label>Time recorded</Statistic.Label>
</Statistic>
<Statistic>
<Statistic.Value>{stats?.numEvents ?? '...'}</Statistic.Value>
<Statistic.Label>Events confirmed</Statistic.Label>
</Statistic>
<Statistic>
<Statistic.Value>{stats?.userCount ?? '...'}</Statistic.Value>
<Statistic.Label>Members joined</Statistic.Label>
</Statistic>
</Statistic.Group>
</Segment>
<Menu widths={3} attached="bottom" size="small">
<Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}>
This month
</Menu.Item>
<Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}>
This year
</Menu.Item>
<Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}>
All time
</Menu.Item>
</Menu>
</div>
</>
)
}
function MostRecentTrack() {
const track: Track | null = useObservable(
() =>
of(null).pipe(
switchMap(() => from(api.fetch('/tracks?limit=1'))),
map((response) => response?.tracks?.[0])
),
null,
[]
)
return (
<>
<Header as="h2">Most recent track</Header>
<Loader active={track === null} />
{track === undefined ? (
<Message>
No public tracks yet. <Link to="/upload">Upload the first!</Link>
</Message>
) : track ? (
<Item.Group>
<TrackListItem track={track} />
</Item.Group>
) : null}
</>
)
}
export default function HomePage() {
return (
<Page>
<Grid stackable>
<Grid.Row>
<Grid.Column width={10}>
<WelcomeMap />
</Grid.Column>
<Grid.Column width={6}>
<Stats />
<MostRecentTrack />
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
)
}

View file

@ -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 (
<Map className={styles.welcomeMap}>
<RoadsLayer />
<Map.TileLayer
osm={{
url: 'https://tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
crossOrigin: null,
}}
/>
{/* <Map.View maxZoom={22} zoom={6} center={fromLonLat([10, 51])} /> */}
<Map.View maxZoom={22} zoom={13} center={fromLonLat([9.1798, 48.7759])} />
</Map>
)
}
function MostRecentTrack() {
const track: Track | null = useObservable(
() =>
of(null).pipe(
switchMap(() => from(api.fetch('/tracks?limit=1'))),
map((response) => response?.tracks?.[0])
),
null,
[]
)
return (
<>
<Header as="h2">Most recent track</Header>
<Loader active={track === null} />
{track === undefined ? (
<Message>
No public tracks yet. <Link to="/upload">Upload the first!</Link>
</Message>
) : track ? (
<Item.Group>
<TrackListItem track={track} />
</Item.Group>
) : null}
</>
)
}
export default function HomePage() {
return (
<Page>
<Grid stackable>
<Grid.Row>
<Grid.Column width={10}>
<WelcomeMap />
</Grid.Column>
<Grid.Column width={6}>
<Stats />
<MostRecentTrack />
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
)
}

View file

@ -1,14 +1,13 @@
import React from 'react' import React from 'react'
import {connect} from 'react-redux' 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 {useForm} from 'react-hook-form'
import {setLogin} from 'reducers/login' import {setLogin} from 'reducers/login'
import {Page} from 'components' import {Page, Stats} from 'components'
import api from 'api' import api from 'api'
import {findInput} from 'utils' import {findInput} from 'utils'
const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) { const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) {
const {register, handleSubmit} = useForm() const {register, handleSubmit} = useForm()
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
@ -35,7 +34,6 @@ const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(func
<Grid centered relaxed divided> <Grid centered relaxed divided>
<Grid.Row> <Grid.Row>
<Grid.Column width={8}> <Grid.Column width={8}>
<Header as="h2">Your profile</Header> <Header as="h2">Your profile</Header>
<Message info>All of this information is public.</Message> <Message info>All of this information is public.</Message>
@ -64,6 +62,10 @@ const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(func
</Grid.Column> </Grid.Column>
<Grid.Column width={6}> <Grid.Column width={6}>
<ApiKeyDialog {...{login}} /> <ApiKeyDialog {...{login}} />
<Divider />
<Stats user={login.username} />
</Grid.Column> </Grid.Column>
</Grid.Row> </Grid.Row>
</Grid> </Grid>
@ -87,9 +89,8 @@ function ApiKeyDialog({login}) {
<> <>
<Header as="h2">Your API Key</Header> <Header as="h2">Your API Key</Header>
<p> <p>
Here you find your API Key, for use in the OpenBikeSensor. You can Here you find your API Key, for use in the OpenBikeSensor. You can to copy and paste it into your sensor's
to copy and paste it into your sensor's configuration interface to configuration interface to allow direct upload from the device.
allow direct upload from the device.
</p> </p>
<p>Please protect your API Key carefully as it allows full control over your account.</p> <p>Please protect your API Key carefully as it allows full control over your account.</p>
{show ? ( {show ? (
@ -97,7 +98,9 @@ function ApiKeyDialog({login}) {
<Input value={login.apiKey} fluid /> <Input value={login.apiKey} fluid />
</Ref> </Ref>
) : ( ) : (
<Button onClick={onClick}><Icon name='lock' /> Show API Key</Button> <Button onClick={onClick}>
<Icon name="lock" /> Show API Key
</Button>
)} )}
</> </>
) )