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 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: {

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 Page} from './Page'
export {default as RoadsLayer} from './RoadsLayer'
export {default as Stats} from './Stats'
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 {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 (
<Page>
<Grid centered relaxed divided>
<Grid.Row>
<Grid.Column width={8}>
<Grid centered relaxed divided>
<Grid.Row>
<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>
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Ref innerRef={findInput(register)}>
<Form.Input error={errors?.username} label="Username" name="username" defaultValue={login.username} />
</Ref>
<Form.Field error={errors?.bio}>
<label>Bio</label>
<Ref innerRef={register}>
<TextArea name="bio" rows={4} defaultValue={login.bio} />
</Ref>
</Form.Field>
<Form.Field error={errors?.image}>
<label>Avatar URL</label>
<Ref innerRef={findInput(register)}>
<Input name="image" defaultValue={login.image} />
</Ref>
</Form.Field>
<Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Ref innerRef={findInput(register)}>
<Form.Input error={errors?.username} label="Username" name="username" defaultValue={login.username} />
</Ref>
<Form.Field error={errors?.bio}>
<label>Bio</label>
<Ref innerRef={register}>
<TextArea name="bio" rows={4} defaultValue={login.bio} />
</Ref>
</Form.Field>
<Form.Field error={errors?.image}>
<label>Avatar URL</label>
<Ref innerRef={findInput(register)}>
<Input name="image" defaultValue={login.image} />
</Ref>
</Form.Field>
<Button type="submit" primary>
Save
</Button>
</Form>
</Grid.Column>
<Grid.Column width={6}>
<ApiKeyDialog {...{login}} />
<Button type="submit" primary>
Save
</Button>
</Form>
</Grid.Column>
<Grid.Column width={6}>
<ApiKeyDialog {...{login}} />
</Grid.Column>
</Grid.Row>
</Grid>
<Divider />
<Stats user={login.username} />
</Grid.Column>
</Grid.Row>
</Grid>
</Page>
)
})
@ -87,9 +89,8 @@ function ApiKeyDialog({login}) {
<>
<Header as="h2">Your API Key</Header>
<p>
Here you find your API Key, for use in the OpenBikeSensor. You can
to copy and paste it into your sensor's configuration interface to
allow direct upload from the device.
Here you find your API Key, for use in the OpenBikeSensor. You can to copy and paste it into your sensor's
configuration interface to allow direct upload from the device.
</p>
<p>Please protect your API Key carefully as it allows full control over your account.</p>
{show ? (
@ -97,7 +98,9 @@ function ApiKeyDialog({login}) {
<Input value={login.apiKey} fluid />
</Ref>
) : (
<Button onClick={onClick}><Icon name='lock' /> Show API Key</Button>
<Button onClick={onClick}>
<Icon name="lock" /> Show API Key
</Button>
)}
</>
)