tournament calendar WIP
parent
90b7f3938c
commit
dc0fa84519
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
"ui/site",
|
||||
"ui/tournament",
|
||||
"ui/tournamentSchedule",
|
||||
"ui/tournamentCalendar",
|
||||
"ui/tree",
|
||||
"ui/@types/lichess"
|
||||
]
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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));
|
||||
});
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export interface Tournament {
|
||||
[key: string]: any;
|
||||
}
|
||||
export interface Ctrl {
|
||||
trans: Trans;
|
||||
data: {
|
||||
since: number;
|
||||
to: number;
|
||||
tournaments: Tournament[];
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
};
|
|
@ -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))
|
||||
);
|
||||
}
|
|
@ -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"]
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue