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"