Show per-user stats in profile settings
This commit is contained in:
parent
630f8ca10c
commit
54d0a56b9a
|
@ -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: {
|
||||
|
|
114
frontend/src/components/Stats/index.tsx
Normal file
114
frontend/src/components/Stats/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
76
frontend/src/pages/HomePage.tsx
Normal file
76
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue