From dc0fa845192fa1307e3f3720b11b2bc99c727386 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Mon, 11 Dec 2017 10:51:09 -0500 Subject: [PATCH] tournament calendar WIP --- app/controllers/Tournament.scala | 6 ++ app/views/tournament/calendar.scala.html | 25 ++++++ conf/routes | 1 + .../src/main/ScheduleJsonView.scala | 28 +++--- .../tournament/src/main/TournamentApi.scala | 6 ++ .../tournament/src/main/TournamentRepo.scala | 6 ++ package.json | 1 + public/stylesheets/tournament_calendar.css | 89 +++++++++++++++++++ ui/tournamentCalendar/gulpfile.js | 53 +++++++++++ ui/tournamentCalendar/package.json | 39 ++++++++ ui/tournamentCalendar/src/interfaces.ts | 11 +++ ui/tournamentCalendar/src/main.ts | 24 +++++ ui/tournamentCalendar/src/view.ts | 89 +++++++++++++++++++ ui/tournamentCalendar/tsconfig.json | 15 ++++ yarn.lock | 4 + 15 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 app/views/tournament/calendar.scala.html create mode 100644 public/stylesheets/tournament_calendar.css create mode 100644 ui/tournamentCalendar/gulpfile.js create mode 100644 ui/tournamentCalendar/package.json create mode 100644 ui/tournamentCalendar/src/interfaces.ts create mode 100644 ui/tournamentCalendar/src/main.ts create mode 100644 ui/tournamentCalendar/src/view.ts create mode 100644 ui/tournamentCalendar/tsconfig.json diff --git a/app/controllers/Tournament.scala b/app/controllers/Tournament.scala index 0f838a50e1..a99c2bdbc9 100644 --- a/app/controllers/Tournament.scala +++ b/app/controllers/Tournament.scala @@ -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)) + } + } } diff --git a/app/views/tournament/calendar.scala.html b/app/views/tournament/calendar.scala.html new file mode 100644 index 0000000000..f688152859 --- /dev/null +++ b/app/views/tournament/calendar.scala.html @@ -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) { +
+

Tournament calendar

+
+
+} diff --git a/conf/routes b/conf/routes index c1dbe7e27c..7ae4c75c8a 100644 --- a/conf/routes +++ b/conf/routes @@ -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) diff --git a/modules/tournament/src/main/ScheduleJsonView.scala b/modules/tournament/src/main/ScheduleJsonView.scala index cec55781b9..708987afc3 100644 --- a/modules/tournament/src/main/ScheduleJsonView.scala +++ b/modules/tournament/src/main/ScheduleJsonView.scala @@ -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, diff --git a/modules/tournament/src/main/TournamentApi.scala b/modules/tournament/src/main/TournamentApi.scala index 22a5edbb4e..2a55c31936 100644 --- a/modules/tournament/src/main/TournamentApi.scala +++ b/modules/tournament/src/main/TournamentApi.scala @@ -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 diff --git a/modules/tournament/src/main/TournamentRepo.scala b/modules/tournament/src/main/TournamentRepo.scala index 6dcfcd8d15..c2833deb5b 100644 --- a/modules/tournament/src/main/TournamentRepo.scala +++ b/modules/tournament/src/main/TournamentRepo.scala @@ -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) } diff --git a/package.json b/package.json index f0453c3db7..6cf10925ae 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "ui/site", "ui/tournament", "ui/tournamentSchedule", + "ui/tournamentCalendar", "ui/tree", "ui/@types/lichess" ] diff --git a/public/stylesheets/tournament_calendar.css b/public/stylesheets/tournament_calendar.css new file mode 100644 index 0000000000..0867722c35 --- /dev/null +++ b/public/stylesheets/tournament_calendar.css @@ -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; +} diff --git a/ui/tournamentCalendar/gulpfile.js b/ui/tournamentCalendar/gulpfile.js new file mode 100644 index 0000000000..7c298ecfa8 --- /dev/null +++ b/ui/tournamentCalendar/gulpfile.js @@ -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)); +}); diff --git a/ui/tournamentCalendar/package.json b/ui/tournamentCalendar/package.json new file mode 100644 index 0000000000..6e1cdf74c6 --- /dev/null +++ b/ui/tournamentCalendar/package.json @@ -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" + } +} diff --git a/ui/tournamentCalendar/src/interfaces.ts b/ui/tournamentCalendar/src/interfaces.ts new file mode 100644 index 0000000000..dacf398990 --- /dev/null +++ b/ui/tournamentCalendar/src/interfaces.ts @@ -0,0 +1,11 @@ +export interface Tournament { + [key: string]: any; +} +export interface Ctrl { + trans: Trans; + data: { + since: number; + to: number; + tournaments: Tournament[]; + } +} diff --git a/ui/tournamentCalendar/src/main.ts b/ui/tournamentCalendar/src/main.ts new file mode 100644 index 0000000000..b61c2030ec --- /dev/null +++ b/ui/tournamentCalendar/src/main.ts @@ -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(); +}; diff --git a/ui/tournamentCalendar/src/view.ts b/ui/tournamentCalendar/src/view.ts new file mode 100644 index 0000000000..ca7bc803ac --- /dev/null +++ b/ui/tournamentCalendar/src/view.ts @@ -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)) + ); +} diff --git a/ui/tournamentCalendar/tsconfig.json b/ui/tournamentCalendar/tsconfig.json new file mode 100644 index 0000000000..81014cc4c7 --- /dev/null +++ b/ui/tournamentCalendar/tsconfig.json @@ -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"] + } +} diff --git a/yarn.lock b/yarn.lock index 3118b513ac..7584b522ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"