tournament calendar WIP

pull/3881/head
Thibault Duplessis 2017-12-11 10:51:09 -05:00
parent 90b7f3938c
commit dc0fa84519
15 changed files with 387 additions and 10 deletions

View File

@ -237,4 +237,10 @@ object Tournament extends LilaController {
_ <- Env.user.lightUserApi preloadMany history.userIds
} yield html.tournament.shields(history)
}
def calendar = Open { implicit ctx =>
env.api.calendar map { tours =>
Ok(html.tournament.calendar(env.scheduleJsonView calendar tours))
}
}
}

View File

@ -0,0 +1,25 @@
@(json: play.api.libs.json.JsObject)(implicit ctx: Context)
@moreCss = {
@cssTag("tournament_calendar.css")
}
@moreJs = {
@jsAt(s"compiled/lichess.tournament-calendar${isProd??(".min")}.js")
@embedJs {
var app = LichessTournamentCalendar.app(document.getElementById('tournament_calendar'), {
data: @safeJson(json),
i18n: @jsI18n()
});
}
}
@base.layout(
title = "Tournament calendar",
moreJs = moreJs,
moreCss = moreCss) {
<div class="content_box no_padding tournament_calendar">
<h1>Tournament calendar</h1>
<div id="tournament_calendar"></div>
</div>
}

View File

@ -229,6 +229,7 @@ GET /tournament controllers.Tournament.home(page:
GET /tournament/featured controllers.Tournament.featured
GET /tournament/new controllers.Tournament.form
POST /tournament/new controllers.Tournament.create
GET /tournament/calendar controllers.Tournament.calendar
GET /tournament/$id<\w{8}> controllers.Tournament.show(id: String)
GET /tournament/$id<\w{8}>/standing/:page controllers.Tournament.standing(id: String, page: Int)
GET /tournament/$id<\w{8}>/socket/v:apiVersion controllers.Tournament.websocket(id: String, apiVersion: Int)

View File

@ -10,9 +10,9 @@ final class ScheduleJsonView(lightUser: LightUser.Getter) {
import JsonView._
def apply(tournaments: VisibleTournaments): Fu[JsObject] = for {
created <- tournaments.created.map(tournamentJson).sequenceFu
started <- tournaments.started.map(tournamentJson).sequenceFu
finished <- tournaments.finished.map(tournamentJson).sequenceFu
created <- tournaments.created.map(fullJson).sequenceFu
started <- tournaments.started.map(fullJson).sequenceFu
finished <- tournaments.finished.map(fullJson).sequenceFu
} yield Json.obj(
"created" -> created,
"started" -> started,
@ -20,14 +20,17 @@ final class ScheduleJsonView(lightUser: LightUser.Getter) {
)
def featured(tournaments: List[Tournament]): Fu[JsObject] =
tournaments.map(tournamentJson).sequenceFu map { objs =>
tournaments.map(fullJson).sequenceFu map { objs =>
Json.obj("featured" -> objs)
}
private def tournamentJson(tour: Tournament): Fu[JsObject] = for {
owner <- tour.nonLichessCreatedBy ?? lightUser
winner <- tour.winnerId ?? lightUser
} yield Json.obj(
def calendar(tournaments: List[Tournament]): JsObject = Json.obj(
"since" -> tournaments.headOption.map(_.startsAt.withTimeAtStartOfDay),
"to" -> tournaments.lastOption.map(_.finishesAt.withTimeAtStartOfDay plusDays 1),
"tournaments" -> JsArray(tournaments.map(baseJson))
)
private def baseJson(tour: Tournament): JsObject = Json.obj(
"id" -> tour.id,
"createdBy" -> tour.createdBy,
"system" -> tour.system.toString.toLowerCase,
@ -45,14 +48,19 @@ final class ScheduleJsonView(lightUser: LightUser.Getter) {
"startsAt" -> tour.startsAt,
"finishesAt" -> tour.finishesAt,
"status" -> tour.status.id,
"winner" -> winner.map(userJson),
"perf" -> tour.perfType.map(perfJson)
).add("hasMaxRating", tour.conditions.maxRating.isDefined)
.add("major", owner.exists(_.title.isDefined))
.add("private", tour.`private`)
.add("position", tour.position.some.filterNot(_.initial) map positionJson)
.add("schedule", tour.schedule map scheduleJson)
private def fullJson(tour: Tournament): Fu[JsObject] = for {
owner <- tour.nonLichessCreatedBy ?? lightUser
winner <- tour.winnerId ?? lightUser
} yield baseJson(tour) ++ Json.obj(
"winner" -> winner.map(userJson)
).add("major", owner.exists(_.title.isDefined))
private def userJson(u: LightUser) = Json.obj(
"id" -> u.id,
"name" -> u.name,

View File

@ -2,6 +2,7 @@ package lila.tournament
import akka.actor.{ Props, ActorRef, ActorSelection, ActorSystem }
import akka.pattern.{ ask, pipe }
import org.joda.time.DateTime
import play.api.libs.json._
import scala.concurrent.duration._
@ -399,6 +400,11 @@ final class TournamentApi(
}.sequenceFu.map(_.toMap)
}
def calendar: Fu[List[Tournament]] = {
val from = DateTime.now.minusDays(1)
TournamentRepo.calendar(from = from, to = from plusYears 1)
}
private def fetchGames(tour: Tournament, ids: Seq[String]) =
if (tour.isFinished) GameRepo gamesFromSecondary ids
else GameRepo gamesFromPrimary ids

View File

@ -233,4 +233,10 @@ object TournamentRepo {
)
).list[Tournament](none, ReadPreference.secondaryPreferred)
}
def calendar(from: DateTime, to: DateTime): Fu[List[Tournament]] =
coll.find($doc(
"startsAt" $gte from $lte to,
"schedule.freq" $in Schedule.Freq.all.filter(_.isWeeklyOrBetter)
)).sort($sort asc "startsAt").list[Tournament](none, ReadPreference.secondaryPreferred)
}

View File

@ -46,6 +46,7 @@
"ui/site",
"ui/tournament",
"ui/tournamentSchedule",
"ui/tournamentCalendar",
"ui/tree",
"ui/@types/lichess"
]

View File

@ -0,0 +1,89 @@
div.day {
height: 30px;
display: flex;
margin-bottom: 5px;
}
div.day:nth-child(even) {
background: rgba(128,128,128,0.05);
}
day {
flex: 0 1 auto;
padding: 0 5px;
}
tours {
flex: 1 5 auto;
position: relative;
overflow: hidden;
}
#tournament_calendar .tournament {
position: absolute;
top: 0;
box-sizing: border-box;
padding: 4px 0;
background-color: #303E43;
box-shadow: 0 5px 15px rgba(0,0,0,0.5);
border-radius: 2px;
transition: filter 0.13s;
color: #fff;
white-space: nowrap;
font-size: 11px;
}
#tournament_calendar .tournament:hover {
filter: brightness(1.08);
}
#tournament_calendar .tournament.hourly {
background-color: #3D9333;
}
#tournament_calendar .tournament.daily,
#tournament_calendar .tournament.eastern {
background-color: #0072B2;
}
@keyframes animatedBackground {
from { background-position: 0 0; }
to { background-position: 0 1000%; }
}
#tournament_calendar .tournament.weekly,
#tournament_calendar .tournament.weekend,
#tournament_calendar .tournament.monthly,
#tournament_calendar .tournament.marathon,
#tournament_calendar .tournament.yearly {
text-shadow: 0 0 2px rgba(0,0,0,0.7);
letter-spacing: 1px;
background-image: url(../images/grain.png);
animation: animatedBackground 50s linear infinite;
}
#tournament_calendar .tournament.weekly {
background-color: #D55E00;
}
#tournament_calendar .tournament.monthly {
background-color: #C93D3D;
}
#tournament_calendar .tournament.yearly,
#tournament_calendar .tournament.weekend {
background-color: #d59120;
}
#tournament_calendar .tournament.marathon {
background-color: #66558C;
}
#tournament_calendar .tournament.unique {
background-color: #d59120;
}
#tournament_calendar .tournament.max-rating {
background-color: #8572ff;
}
#tournament_calendar .tournament {
display: flex;
}
#tournament_calendar .tournament span {
margin-right: 4px;
}
#tournament_calendar .icon {
font-size: 1.3em;
margin: -4px 2px -1px 4px;
}
#tournament_calendar .tournament .body {
flex: 1 0;
margin-right: 0;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,53 @@
const gulp = require('gulp');
const gutil = require('gulp-util');
const watchify = require('watchify');
const browserify = require('browserify');
const uglify = require('gulp-uglify');
const source = require('vinyl-source-stream');
const buffer = require('vinyl-buffer');
const tsify = require('tsify');
const destination = '../../public/compiled/';
function onError(error) {
gutil.log(gutil.colors.red(error.message));
}
function build() {
return browserify('src/main.ts', {
standalone: 'LichessTournamentCalendar',
debug: true
})
.plugin(tsify);
}
const watchedBrowserify = watchify(build());
function bundle() {
return watchedBrowserify
.bundle()
.on('error', onError)
.pipe(source('lichess.tournament-calendar.js'))
.pipe(buffer())
.pipe(gulp.dest(destination));
}
gulp.task('default', bundle);
watchedBrowserify.on('update', bundle);
watchedBrowserify.on('log', gutil.log);
gulp.task('dev', function() {
return build()
.bundle()
.pipe(source('lichess.tournament-calendar.js'))
.pipe(gulp.dest(destination));
});
gulp.task('prod', function() {
return build()
.bundle()
.pipe(source('lichess.tournament-calendar.min.js'))
.pipe(buffer())
.pipe(uglify())
.pipe(gulp.dest(destination));
});

View File

@ -0,0 +1,39 @@
{
"name": "tournamentCalendar",
"version": "1.0.0",
"description": "lichess.org tournament calendar",
"main": "src/main.js",
"repository": {
"type": "git",
"url": "https://github.com/ornicar/lila"
},
"keywords": [
"chess",
"lichess",
"tournament",
"calendar"
],
"author": "Thibault Duplessis",
"license": "AGPL-3.0",
"bugs": {
"url": "https://github.com/ornicar/lila/issues"
},
"homepage": "https://github.com/ornicar/lila",
"devDependencies": {
"@types/jquery": "^2.0",
"browserify": "^14",
"gulp": "^3",
"gulp-uglify": "^3",
"gulp-util": "^3",
"tsify": "^3",
"@types/lichess": "1.0.0",
"typescript": "^2",
"vinyl-buffer": "^1",
"vinyl-source-stream": "^1",
"watchify": "^3"
},
"dependencies": {
"snabbdom": "ornicar/snabbdom#lichess",
"date-fns": "^1"
}
}

View File

@ -0,0 +1,11 @@
export interface Tournament {
[key: string]: any;
}
export interface Ctrl {
trans: Trans;
data: {
since: number;
to: number;
tournaments: Tournament[];
}
}

View File

@ -0,0 +1,24 @@
import view from './view';
import { init } from 'snabbdom';
import { VNode } from 'snabbdom/vnode'
import klass from 'snabbdom/modules/class';
import attributes from 'snabbdom/modules/attributes';
import { Tournament, Ctrl } from './interfaces'
const patch = init([klass, attributes]);
export function app(element: HTMLElement, env: any) {
let vnode: VNode, ctrl: Ctrl = {
data: env.data,
trans: window.lichess.trans(env.i18n)
};
function redraw() {
vnode = patch(vnode || element, view(ctrl));
}
redraw();
};

View File

@ -0,0 +1,89 @@
import { h } from 'snabbdom'
import { VNode } from 'snabbdom/vnode'
import { eachDay, addYears, addDays, format, getHours, getMinutes } from 'date-fns'
import { Tournament, Ctrl } from './interfaces'
function displayClockLimit(limit) {
switch (limit) {
case 15:
return '¼';
case 30:
return '½';
case 45:
return '¾';
case 90:
return '1.5';
default:
return limit / 60;
}
}
function displayClock(clock) {
return displayClockLimit(clock.limit) + "+" + clock.increment;
}
function tournamentClass(tour: Tournament) {
const classes = {
rated: tour.rated,
casual: !tour.rated,
'max-rating': tour.hasMaxRating
};
if (tour.schedule) classes[tour.schedule.freq] = true;
return classes;
}
function iconOf(tour, perfIcon) {
return (tour.schedule && tour.schedule.freq === 'shield') ? '5' : perfIcon;
}
function renderTournament(ctrl: Ctrl, tour: Tournament) {
// moves content into viewport, for long tourneys and marathons
// const paddingLeft = tour.minutes < 90 ? 0 : Math.max(0,
// Math.min(width - 250, // max padding, reserved text space
// leftPos(now) - left - 380)); // distance from Now
// // cut right overflow to fit viewport and not widen it, for marathons
// width = Math.min(width, leftPos(stopTime) - left);
const paddingLeft = 0;
const date = tour.startsAt;
const left = (getHours(date) + getMinutes(date) / 60) / 24 * 100;
const width = tour.minutes / 60 / 24 * 100;
return h('a.tournament', {
class: tournamentClass(tour),
attrs: {
href: '/tournament/' + tour.id,
style: 'width: ' + width + '%; left: ' + left + '%'
},
}, [
h('span.icon', tour.perf ? {
attrs: {
'data-icon': iconOf(tour, tour.perf.icon),
title: tour.perf.name
}
} : {}),
h('span.body', [ tour.fullName ])
]);
}
function endsAt(tour: Tournament): Date {
return new Date(tour.startsAt + tour.minutes * 60 * 1000);
}
function renderDay(ctrl: Ctrl) {
return function(day: Date): VNode | undefined {
const dayEnd = addDays(day, 1);
const tours = ctrl.data.tournaments.filter(t =>
t.startsAt < dayEnd.getTime() && endsAt(t) > day
);
return h('div.day', [
h('day', [format(day, 'DD/MM')]),
h('tours', tours.map(t => renderTournament(ctrl, t)))
]);
}
}
export default function(ctrl) {
return h('div#tournament_calendar',
eachDay(new Date(ctrl.data.since), new Date(ctrl.data.to)).map(renderDay(ctrl))
);
}

View File

@ -0,0 +1,15 @@
{
"include": ["src/*.ts", "src/*.js"],
"exclude": [],
"compilerOptions": {
"allowJs": true,
"strictNullChecks": true,
"noUnusedLocals": false,
"noEmitOnError": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedParameters": false,
"target": "es5",
"lib": ["DOM", "ES5"]
}
}

View File

@ -878,6 +878,10 @@ dashdash@^1.12.0:
dependencies:
assert-plus "^1.0.0"
date-fns@^1:
version "1.29.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6"
date-now@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"