Merge pull request #22 from turniere/ticket/TURNIERE-148
Create statistics page for a tournament
This commit is contained in:
commit
f95e85991f
73
js/api.js
73
js/api.js
|
|
@ -8,6 +8,11 @@ import {actionTypesUserinfo, defaultStateUserinfo} from './redux/userInfo';
|
|||
import {actionTypesTournamentinfo, defaultStateTournamentinfo} from './redux/tournamentInfo';
|
||||
import {actionTypesTournamentlist, defaultStateTournamentlist} from './redux/tournamentList';
|
||||
import {deleteRequest, getRequest, patchRequest, postRequest, putRequest} from './redux/backendApi';
|
||||
import {
|
||||
actionTypesTournamentStatistics,
|
||||
defaultStateTournamentStatistics,
|
||||
transformTournamentInfoToStatistics, transformTournamentStatsToStatistics
|
||||
} from './redux/tournamentStatistics';
|
||||
|
||||
|
||||
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 = {
|
||||
userinfo: reducerUserinfo,
|
||||
tournamentinfo: reducerTournamentinfo,
|
||||
tournamentlist: reducerTournamentlist
|
||||
tournamentlist: reducerTournamentlist,
|
||||
tournamentStatistics: reducerTournamentStatistics
|
||||
};
|
||||
|
||||
const defaultApplicationState = {
|
||||
userinfo: defaultStateUserinfo,
|
||||
tournamentinfo: defaultStateTournamentinfo,
|
||||
tournamentlist: defaultStateTournamentlist
|
||||
tournamentlist: defaultStateTournamentlist,
|
||||
tournamentStatistics: defaultStateTournamentStatistics
|
||||
};
|
||||
|
||||
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() {
|
||||
const persistedState = localStorage.getItem('reduxState') ?
|
||||
JSON.parse(localStorage.getItem('reduxState')) :
|
||||
|
|
@ -517,6 +582,10 @@ function rehydrateApplicationState() {
|
|||
type: actionTypesTournamentlist.REHYDRATE,
|
||||
parameters: Object.assign({}, persistedState.tournamentlist)
|
||||
});
|
||||
__store.dispatch({
|
||||
type: actionTypesTournamentStatistics.REHYDRATE,
|
||||
parameters: Object.assign({}, persistedState.tournamentstatistics)
|
||||
});
|
||||
}
|
||||
applicationHydrated = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 === [];
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
export function rangedmap(arr, func, start, end) {
|
||||
return arr.slice(start, end).map((element, index) => func(element, start + index));
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ class TurniereApp extends App {
|
|||
const {Component, pageProps, reduxStore} = this.props;
|
||||
return (<Container>
|
||||
<Notifications />
|
||||
<Favicon url="../static/icons/favicon.ico"/>
|
||||
<Favicon url="/static/icons/favicon.ico"/>
|
||||
<Provider store={reduxStore}>
|
||||
<Component {...pageProps} />
|
||||
</Provider>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {Container, ListGroup, ListGroupItem} from 'reactstrap';
|
||||
import Navbar from 'react-bootstrap/Navbar';
|
||||
|
||||
|
||||
|
|
@ -16,6 +15,8 @@ import '../static/css/tournament.css';
|
|||
import {getTournament} from '../js/redux/tournamentApi';
|
||||
import {PlayoffStages} from '../js/components/PlayoffStages';
|
||||
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 {
|
||||
render() {
|
||||
|
|
@ -38,36 +39,21 @@ class PrivateTournamentPage extends React.Component {
|
|||
}
|
||||
|
||||
function StatusBar(props) {
|
||||
return (<Navbar sticky='top' bg='light' className='border-bottom border-top'>
|
||||
<Container className='px-3'>
|
||||
return (<TournamentStatusBar>
|
||||
<Navbar.Brand>
|
||||
{props.tournament.name}
|
||||
<EditButton id={props.id} isOwner={props.isOwner} isSignedIn={props.isSignedIn}/>
|
||||
<EditButton tournamentId={props.tournament.id} isOwner={props.isOwner} isSignedIn={props.isSignedIn}/>
|
||||
<StatisticsButton tournamentId={props.tournament.id}/>
|
||||
</Navbar.Brand>
|
||||
</Container>
|
||||
</Navbar>);
|
||||
</TournamentStatusBar>);
|
||||
}
|
||||
|
||||
|
||||
function TournamentBigImage(props) {
|
||||
return (<div className="big-image mb-0">
|
||||
<h1 className="display-1">{props.name}</h1>
|
||||
<Container>
|
||||
<TournamentProperties {...props}/>
|
||||
</Container>
|
||||
</div>);
|
||||
function StatisticsButton(props) {
|
||||
return (<TournamentStatusBarButton href={'/t/' + props.tournamentId + '/statistics'}>
|
||||
Statistiken
|
||||
</TournamentStatusBarButton>);
|
||||
}
|
||||
|
||||
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) {
|
||||
const {isSignedIn, username} = state.userinfo;
|
||||
|
|
@ -76,18 +62,6 @@ function mapStateToTournamentPageProperties(state) {
|
|||
|
||||
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 {
|
||||
static async getInitialProps({query}) {
|
||||
return {query};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ app.prepare()
|
|||
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) => {
|
||||
return handle(req, res);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue