Merge pull request #22 from turniere/ticket/TURNIERE-148

Create statistics page for a tournament
This commit is contained in:
betanummeric 2019-06-19 19:12:13 +02:00 committed by GitHub
commit f95e85991f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 446 additions and 43 deletions

View File

@ -8,6 +8,11 @@ import {actionTypesUserinfo, defaultStateUserinfo} from './redux/userInfo';
import {actionTypesTournamentinfo, defaultStateTournamentinfo} from './redux/tournamentInfo'; import {actionTypesTournamentinfo, defaultStateTournamentinfo} from './redux/tournamentInfo';
import {actionTypesTournamentlist, defaultStateTournamentlist} from './redux/tournamentList'; import {actionTypesTournamentlist, defaultStateTournamentlist} from './redux/tournamentList';
import {deleteRequest, getRequest, patchRequest, postRequest, putRequest} from './redux/backendApi'; import {deleteRequest, getRequest, patchRequest, postRequest, putRequest} from './redux/backendApi';
import {
actionTypesTournamentStatistics,
defaultStateTournamentStatistics,
transformTournamentInfoToStatistics, transformTournamentStatsToStatistics
} from './redux/tournamentStatistics';
function storeOptionalToken(response) { function storeOptionalToken(response) {
@ -319,16 +324,64 @@ const reducerTournamentlist = (state = defaultStateTournamentlist, action) => {
} }
}; };
const reducerTournamentStatistics = (state = defaultStateTournamentStatistics, action) => {
switch (action.type) {
case actionTypesTournamentStatistics.REQUEST_TOURNAMENT_STATISTICS:
getRequest(action.state, '/tournaments/' + action.parameters.code).then(resp => {
storeOptionalToken(resp);
__store.dispatch({
type: actionTypesTournamentStatistics.INT_REQUEST_TOURNAMENT_STATISTICS,
state: action.state,
parameters: {
code: action.parameters.code,
tournamentInfo: transformTournamentInfoToStatistics(resp.data),
successCallback: action.parameters.successCallback,
errorCallback: action.parameters.errorCallback
}
});
}).catch(error => {
if (error.response) {
storeOptionalToken(error.response);
}
action.parameters.errorCallback();
});
return state;
case actionTypesTournamentStatistics.INT_REQUEST_TOURNAMENT_STATISTICS:
getRequest(action.state, '/tournaments/' + action.parameters.code + '/statistics').then(resp => {
storeOptionalToken(resp);
__store.dispatch({
type: actionTypesTournamentStatistics.REQUEST_TOURNAMENT_STATISTICS_SUCCESS,
parameters: {
tournamentStatistics: transformTournamentStatsToStatistics(resp.data),
successCallback: action.parameters.successCallback
}
});
}).catch(error => {
if (error.response) {
storeOptionalToken(error.response);
}
action.parameters.errorCallback();
});
return Object.assign({}, state, action.parameters.tournamentInfo);
case actionTypesTournamentStatistics.REQUEST_TOURNAMENT_STATISTICS_SUCCESS:
action.parameters.successCallback();
return Object.assign({}, state, action.parameters.tournamentStatistics);
default: return state;
}
};
const reducers = { const reducers = {
userinfo: reducerUserinfo, userinfo: reducerUserinfo,
tournamentinfo: reducerTournamentinfo, tournamentinfo: reducerTournamentinfo,
tournamentlist: reducerTournamentlist tournamentlist: reducerTournamentlist,
tournamentStatistics: reducerTournamentStatistics
}; };
const defaultApplicationState = { const defaultApplicationState = {
userinfo: defaultStateUserinfo, userinfo: defaultStateUserinfo,
tournamentinfo: defaultStateTournamentinfo, tournamentinfo: defaultStateTournamentinfo,
tournamentlist: defaultStateTournamentlist tournamentlist: defaultStateTournamentlist,
tournamentStatistics: defaultStateTournamentStatistics
}; };
let __store; let __store;
@ -499,6 +552,18 @@ export function requestTournamentList(type, successCallback, errorCallback) {
}); });
} }
export function requestTournamentStatistics(code, successCallback, errorCallback) {
__store.dispatch({
type: actionTypesTournamentStatistics.REQUEST_TOURNAMENT_STATISTICS,
parameters: {
code: code,
successCallback: successCallback,
errorCallback: errorCallback
},
state: __store.getState()
});
}
function rehydrateApplicationState() { function rehydrateApplicationState() {
const persistedState = localStorage.getItem('reduxState') ? const persistedState = localStorage.getItem('reduxState') ?
JSON.parse(localStorage.getItem('reduxState')) : JSON.parse(localStorage.getItem('reduxState')) :
@ -517,6 +582,10 @@ function rehydrateApplicationState() {
type: actionTypesTournamentlist.REHYDRATE, type: actionTypesTournamentlist.REHYDRATE,
parameters: Object.assign({}, persistedState.tournamentlist) parameters: Object.assign({}, persistedState.tournamentlist)
}); });
__store.dispatch({
type: actionTypesTournamentStatistics.REHYDRATE,
parameters: Object.assign({}, persistedState.tournamentstatistics)
});
} }
applicationHydrated = true; applicationHydrated = true;
} }

View File

@ -0,0 +1,34 @@
import React from 'react';
import {
Card,
CardBody,
CardTitle,
Table
} from 'reactstrap';
export class DominanceShower extends React.Component {
render() {
return (
<Card className="shadow-sm">
<CardBody>
<CardTitle>{this.props.title}</CardTitle>
<Table borderless className="m-0">
<tbody>
<tr>
<th colSpan="2" className="h3 text-center">{this.props.stats.team_name}</th>
</tr>
<tr>
<td className="h4 text-success pb-0">{this.props.stats.points_made}</td>
<td className="h4 text-danger text-right pb-0">{this.props.stats.points_received}</td>
</tr>
<tr>
<td className="smaller pt-0">Punkte erzielt</td>
<td className="text-right smaller pt-0">Punkte kassiert</td>
</tr>
</tbody>
</Table>
</CardBody>
</Card>
);
}
}

View File

@ -0,0 +1,96 @@
import React from 'react';
import {
Button,
Card,
CardBody,
Collapse,
Table
} from 'reactstrap';
import {rangedmap} from '../utils/rangedmap';
export class StandingsTable extends React.Component {
constructor(props) {
super(props);
this.state = {
showFullTable: false
};
this.toggleShowFullTable = this.toggleShowFullTable.bind(this);
}
render() {
const performances = this.props.data.group_phase_performances;
return (
<Card className="shadow-sm">
<CardBody>
<h1 className="custom-font">Aktuelle Rangliste</h1>
<Table className="mt-3 mb-0">
<thead>
<tr>
<th>#</th>
<th>Team Name</th>
<th className="text-center">Match Differenz</th>
<th className="text-center">Punkt Differenz</th>
</tr>
</thead>
<tbody>
{ rangedmap(performances, (team, index) => (
<TeamRow className={(index % 2 === 0)? 'bg-light' : 'bg-white'}
key={index} teamToShow={team}/>
), 0, 3) }
</tbody>
<Collapse isOpen={ this.state.showFullTable } tag="tbody">
{ rangedmap(performances, (team, index) => (
<TeamRow className={(index % 2 === 0)? 'bg-light' : 'bg-white'}
key={index} teamToShow={team}/>
), 3) }
</Collapse>
<tfoot>
<tr>
<td colSpan='4'>
<TableButton isFullTableShown={this.state.showFullTable}
onToggle={this.toggleShowFullTable}/>
</td>
</tr>
</tfoot>
</Table>
</CardBody>
</Card>
);
}
toggleShowFullTable() {
this.setState({showFullTable: !this.state.showFullTable});
}
}
class TeamRow extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<tr className={this.props.className}>
<td>{ this.props.teamToShow.rank }</td>
<td className="w-100">{ this.props.teamToShow.team_name }</td>
<td className="text-center">{ this.props.teamToShow.win_loss_differential }</td>
<td className="text-center">{ this.props.teamToShow.point_differential }</td>
</tr>
);
}
}
class TableButton extends React.Component {
render() {
const {isFullTableShown} = this.props;
if (isFullTableShown) {
return <Button className="w-100" onClick={this.props.onToggle}>Zeige nur die 3 besten Teams</Button>;
} else {
return <Button className="w-100" onClick={this.props.onToggle}>Zeige alle Teams</Button>;
}
}
}

View File

@ -0,0 +1,22 @@
import {Container, ListGroup, ListGroupItem} from 'reactstrap';
import React from 'react';
export function TournamentBigImage(props) {
return (<div className="big-image mb-0">
<h1 className="display-1">{props.name}</h1>
<Container>
<TournamentProperties {...props}/>
</Container>
</div>);
}
function TournamentProperties(props) {
return (<ListGroup className='text-dark text-left shadow'>
{props.description && <ListGroupItem>{props.description}</ListGroupItem>}
<ListGroupItem>
{props.isPublic ? 'Das Turnier ist öffentlich.' : 'Das Turnier ist privat.'}
</ListGroupItem>
<ListGroupItem>Turnier-Code: <b>{props.code}</b></ListGroupItem>
<ListGroupItem>von <b>{props.ownerUsername}</b></ListGroupItem>
</ListGroup>);
}

View File

@ -0,0 +1,29 @@
import Navbar from 'react-bootstrap/Navbar';
import {Container} from 'reactstrap';
import React from 'react';
export function TournamentStatusBar(props) {
return (<Navbar sticky='top' bg='light' className='border-bottom border-top'>
<Container className='px-3'>
{props.children}
</Container>
</Navbar>);
}
export function TournamentStatusBarButton(props) {
return (<a href={props.href} className='ml-3 btn btn-outline-secondary default-font-family'>
{props.children}
</a>);
}
export function EditButton(props) {
const {tournamentId, isOwner, isSignedIn} = props;
if (isSignedIn && isOwner) {
return (<TournamentStatusBarButton href={'/t/' + tournamentId + '/edit'}>
Turnier bearbeiten
</TournamentStatusBarButton>);
} else {
return null;
}
}

View File

@ -0,0 +1,81 @@
export const actionTypesTournamentStatistics = {
'REQUEST_TOURNAMENT_STATISTICS': 'REQUEST_TOURNAMENT_STATISTICS',
'INT_REQUEST_TOURNAMENT_STATISTICS': 'INT_REQUEST_TOURNAMENT_STATISTICS',
'REQUEST_TOURNAMENT_STATISTICS_SUCCESS': 'REQUEST_TOURNAMENT_STATISTICS_SUCCESS',
'REHYDRATE': 'TOURNAMENTINFO_REHYDRATE',
'CLEAR': 'TOURNAMENTINFO_CLEAR'
};
export const defaultStateTournamentStatistics = {
code: '',
description: '',
id: -1,
name: '',
owner_username: '',
isPublic: '',
statistics_available: false,
most_dominant_team: {},
least_dominant_team: {},
group_phase_performances: []
};
export function transformTournamentInfoToStatistics(data) {
return {
code: data.code,
description: data.description,
id: data.id,
name: data.name,
ownerUsername: data.owner_username,
isPublic: data.public
};
}
export function transformTournamentStatsToStatistics(data) {
if (statisticsUnavailable(data)) {
return {
statistics_available: false,
most_dominant_team: {},
least_dominant_team: {},
group_phase_performances: []
};
}
const statistics = {
statistics_available: true,
most_dominant_team: {
points_made: data.most_dominant_score.scored_points,
points_received: data.most_dominant_score.received_points,
team_name: data.most_dominant_score.team.name
},
least_dominant_team: {
points_made: data.least_dominant_score.scored_points,
points_received: data.least_dominant_score.received_points,
team_name: data.least_dominant_score.team.name
},
group_phase_performances: []
};
for (let i = 0; i < data.group_scores.length; i++) {
const score = data.group_scores[i];
statistics.group_phase_performances[i] = {
win_loss_differential: score.group_points,
point_differential: score.scored_points - score.received_points,
rank: i + 1,
team_name: score.team.name
};
}
return statistics;
}
function statisticsUnavailable(data) {
return data === {} || data.most_dominant_score === null ||
data.least_dominant_score === null || data.group_scores === [];
}

4
js/utils/rangedmap.js Normal file
View File

@ -0,0 +1,4 @@
export function rangedmap(arr, func, start, end) {
return arr.slice(start, end).map((element, index) => func(element, start + index));
}

View File

@ -15,8 +15,8 @@ class TurniereApp extends App {
render() { render() {
const {Component, pageProps, reduxStore} = this.props; const {Component, pageProps, reduxStore} = this.props;
return (<Container> return (<Container>
<Notifications/> <Notifications />
<Favicon url="../static/icons/favicon.ico"/> <Favicon url="/static/icons/favicon.ico"/>
<Provider store={reduxStore}> <Provider store={reduxStore}>
<Component {...pageProps} /> <Component {...pageProps} />
</Provider> </Provider>

View File

@ -0,0 +1,88 @@
import Head from 'next/head';
import React from 'react';
import {connect} from 'react-redux';
import {Col, Container, Row} from 'reactstrap';
import {TurniereNavigation} from '../js/components/Navigation';
import {StandingsTable} from '../js/components/StandingsTable';
import {DominanceShower} from '../js/components/DominanceShower';
import {Footer} from '../js/components/Footer';
import {requestTournamentStatistics} from '../js/api';
import {EditButton, TournamentStatusBar, TournamentStatusBarButton} from '../js/components/TournamentStatusBar';
import Navbar from 'react-bootstrap/Navbar';
import {TournamentBigImage} from '../js/components/TournamentBigImage';
class StatisticsTournamentPage extends React.Component {
static async getInitialProps({query}) {
return {query};
}
componentDidMount() {
requestTournamentStatistics(this.props.query.code, () => {}, () => {});
}
render() {
const {tournamentStatistics} = this.props;
return (
<div>
<Head>
<title>{tournamentStatistics.name}: turnie.re</title>
</Head>
<TurniereNavigation/>
<TournamentBigImage {...tournamentStatistics}/>
<TournamentStatusBar>
<Navbar.Brand>
{tournamentStatistics.name}
<EditButton tournamentId={tournamentStatistics.id}
isOwner={this.props.username === tournamentStatistics.ownerUsername}
isSignedIn={this.props.isSignedIn}/>
<TournamentStatusBarButton href={'/t/' + tournamentStatistics.id}>
zurück zum Turnier
</TournamentStatusBarButton>
</Navbar.Brand>
</TournamentStatusBar>
<div className='pb-5'>
<StatisticsView tournamentStatistics={tournamentStatistics} />
</div>
<Footer/>
</div>
);
}
}
function StatisticsView(props) {
if (props.tournamentStatistics.statistics_available) {
return (<div>
<Container className="py-5">
<Row>
<Col xs="6">
<DominanceShower stats={props.tournamentStatistics.most_dominant_team}
title="Stärkstes Team"/>
</Col>
<Col xs="6">
<DominanceShower stats={props.tournamentStatistics.least_dominant_team}
title="Schwächstes Team"/>
</Col>
</Row>
</Container>
<Container className="pb-5">
<StandingsTable data={props.tournamentStatistics}/>
</Container>
</div>);
} else {
return (<Container className="py-5">
<h2 className="text-center">Statistiken sind für dieses Turnier leider nicht verfügbar.</h2>
</Container>);
}
}
function mapTournamentStatisticsToProps(state) {
const {tournamentStatistics} = state;
const {isSignedIn, username} = state.userinfo;
return {tournamentStatistics, isSignedIn, username};
}
export default connect(
mapTournamentStatisticsToProps
)(StatisticsTournamentPage);

View File

@ -1,7 +1,6 @@
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import {Container, ListGroup, ListGroupItem} from 'reactstrap';
import Navbar from 'react-bootstrap/Navbar'; import Navbar from 'react-bootstrap/Navbar';
@ -16,6 +15,8 @@ import '../static/css/tournament.css';
import {getTournament} from '../js/redux/tournamentApi'; import {getTournament} from '../js/redux/tournamentApi';
import {PlayoffStages} from '../js/components/PlayoffStages'; import {PlayoffStages} from '../js/components/PlayoffStages';
import GroupStage from '../js/components/GroupStage'; import GroupStage from '../js/components/GroupStage';
import {TournamentBigImage} from '../js/components/TournamentBigImage';
import {EditButton, TournamentStatusBar, TournamentStatusBarButton} from '../js/components/TournamentStatusBar';
class PrivateTournamentPage extends React.Component { class PrivateTournamentPage extends React.Component {
render() { render() {
@ -38,36 +39,21 @@ class PrivateTournamentPage extends React.Component {
} }
function StatusBar(props) { function StatusBar(props) {
return (<Navbar sticky='top' bg='light' className='border-bottom border-top'> return (<TournamentStatusBar>
<Container className='px-3'> <Navbar.Brand>
<Navbar.Brand> {props.tournament.name}
{props.tournament.name} <EditButton tournamentId={props.tournament.id} isOwner={props.isOwner} isSignedIn={props.isSignedIn}/>
<EditButton id={props.id} isOwner={props.isOwner} isSignedIn={props.isSignedIn}/> <StatisticsButton tournamentId={props.tournament.id}/>
</Navbar.Brand> </Navbar.Brand>
</Container> </TournamentStatusBar>);
</Navbar>);
} }
function StatisticsButton(props) {
function TournamentBigImage(props) { return (<TournamentStatusBarButton href={'/t/' + props.tournamentId + '/statistics'}>
return (<div className="big-image mb-0"> Statistiken
<h1 className="display-1">{props.name}</h1> </TournamentStatusBarButton>);
<Container>
<TournamentProperties {...props}/>
</Container>
</div>);
} }
function TournamentProperties(props) {
return (<ListGroup className='text-dark text-left shadow'>
{props.description && <ListGroupItem>{props.description}</ListGroupItem>}
<ListGroupItem>
{props.isPublic ? 'Das Turnier ist öffentlich.' : 'Das Turnier ist privat.'}
</ListGroupItem>
<ListGroupItem>Turnier-Code: <b>{props.code}</b></ListGroupItem>
<ListGroupItem>von <b>{props.ownerUsername}</b></ListGroupItem>
</ListGroup>);
}
function mapStateToTournamentPageProperties(state) { function mapStateToTournamentPageProperties(state) {
const {isSignedIn, username} = state.userinfo; const {isSignedIn, username} = state.userinfo;
@ -76,18 +62,6 @@ function mapStateToTournamentPageProperties(state) {
const TournamentPage = connect(mapStateToTournamentPageProperties)(PrivateTournamentPage); const TournamentPage = connect(mapStateToTournamentPageProperties)(PrivateTournamentPage);
function EditButton(props) {
const {id, isOwner, isSignedIn} = props;
if (isSignedIn && isOwner) {
return (<a href={'/t/' + id + '/edit'} className='ml-3 btn btn-outline-secondary default-font-family'>
Turnier bearbeiten
</a>);
} else {
return null;
}
}
class Main extends React.Component { class Main extends React.Component {
static async getInitialProps({query}) { static async getInitialProps({query}) {
return {query}; return {query};

View File

@ -27,6 +27,12 @@ app.prepare()
app.render(req, res, actualPage, queryParam); app.render(req, res, actualPage, queryParam);
}); });
server.get('/t/:code/statistics', (req, res) => {
const actualPage = '/tournament-statistics';
const queryParam = {code: req.params.code};
app.render(req, res, actualPage, queryParam);
});
server.get('*', (req, res) => { server.get('*', (req, res) => {
return handle(req, res); return handle(req, res);
}); });