puzzle replay WIP

puzzle-dashboard
Thibault Duplessis 2020-12-29 20:17:59 +01:00
parent da24960403
commit 97525249d6
16 changed files with 243 additions and 40 deletions

View File

@ -10,7 +10,7 @@ import lila.app._
import lila.common.ApiVersion
import lila.common.config.MaxPerSecond
import lila.puzzle.PuzzleTheme
import lila.puzzle.{ Result, PuzzleRound, PuzzleDifficulty, Puzzle => Puz }
import lila.puzzle.{ Result, PuzzleRound, PuzzleDifficulty, PuzzleReplay, Puzzle => Puz }
final class Puzzle(
env: Env,
@ -20,6 +20,7 @@ final class Puzzle(
private def renderJson(
puzzle: Puz,
theme: PuzzleTheme,
replay: Option[PuzzleReplay] = None,
newUser: Option[lila.user.User] = None,
apiVersion: Option[ApiVersion] = None
)(implicit
@ -28,10 +29,12 @@ final class Puzzle(
if (apiVersion.exists(!_.puzzleV2))
env.puzzle.jsonView.bc(puzzle = puzzle, theme = theme, user = newUser orElse ctx.me)
else
env.puzzle.jsonView(puzzle = puzzle, theme = theme, user = newUser orElse ctx.me)
env.puzzle.jsonView(puzzle = puzzle, theme = theme, replay = replay, user = newUser orElse ctx.me)
private def renderShow(puzzle: Puz, theme: PuzzleTheme)(implicit ctx: Context) =
renderJson(puzzle, theme) zip
private def renderShow(puzzle: Puz, theme: PuzzleTheme, replay: Option[PuzzleReplay] = None)(implicit
ctx: Context
) =
renderJson(puzzle, theme, replay) zip
ctx.me.??(u => env.puzzle.session.getDifficulty(u) dmap some) map { case (json, difficulty) =>
EnableSharedArrayBuffer(
Ok(views.html.puzzle.show(puzzle, json, env.puzzle.jsonView.pref(ctx.pref), difficulty))
@ -118,7 +121,7 @@ final class Puzzle(
else
for {
next <- nextPuzzleForMe(theme.key)
nextJson <- renderJson(next, theme, newUser.some)
nextJson <- renderJson(next, theme, none, newUser.some)
} yield Json.obj(
"round" -> env.puzzle.jsonView.roundJson(me, round, perf),
"next" -> nextJson
@ -253,6 +256,15 @@ final class Puzzle(
}
}
def replay(days: Int, themeKey: String) =
Auth { implicit ctx => me =>
val theme = PuzzleTheme.findOrAny(themeKey)
env.puzzle.replay(me, days, theme.key) flatMap {
case None => Redirect(routes.Puzzle.dashboard(days)).fuccess
case Some((puzzle, replay)) => renderShow(puzzle, theme, replay.some)
}
}
def mobileBcLoad(nid: Long) =
Open { implicit ctx =>
negotiate(

View File

@ -49,21 +49,21 @@ object dashboard {
case Some(dash) =>
frag(
div(cls := s"${baseClass}__global")(
metricsOf(dash.global)
metricsOf(days, PuzzleTheme.mix.key, dash.global)
),
div(cls := s"${baseClass}__themes")(
div(cls := s"${baseClass}__themes__title")(
h2("Your weaknesses"),
p("Train these to optimize your progress!")
),
themeSelection(dash weakestThemes 5)
themeSelection(days, dash weakestThemes 5)
),
div(cls := s"${baseClass}__themes")(
div(cls := s"${baseClass}__themes__title")(
h2("Your strengths"),
p("Congratulations, you did really well in these puzzles!")
),
themeSelection(dash strongestThemes 5)
themeSelection(days, dash strongestThemes 5)
)
)
}
@ -71,7 +71,9 @@ object dashboard {
)
}
private def themeSelection(themes: List[(PuzzleTheme.Key, PuzzleDashboard.Results)])(implicit lang: Lang) =
private def themeSelection(days: Int, themes: List[(PuzzleTheme.Key, PuzzleDashboard.Results)])(implicit
lang: Lang
) =
themes.map { case (key, results) =>
div(cls := themeClass)(
div(cls := s"${themeClass}__meta")(
@ -80,18 +82,20 @@ object dashboard {
),
p(cls := s"${themeClass}__description")(PuzzleTheme(key).description())
),
metricsOf(results)
metricsOf(days, key, results)
)
}
private def metricsOf(results: PuzzleDashboard.Results)(implicit lang: Lang) =
private def metricsOf(days: Int, theme: PuzzleTheme.Key, results: PuzzleDashboard.Results)(implicit
lang: Lang
) =
div(cls := s"${baseClass}__metrics")(
div(cls := s"$metricClass $metricClass--played")(
strong(results.nb.localize),
span("played")
),
div(cls := s"$metricClass $metricClass--perf")(
strong(results.performance),
strong(results.performance, results.unclear ?? "?"),
span("performance")
),
div(
@ -101,7 +105,7 @@ object dashboard {
strong(s"${results.winPercent}%"),
span("solved")
),
div(cls := s"$metricClass $metricClass--fix")(
a(cls := s"$metricClass $metricClass--fix", href := routes.Puzzle.replay(days, theme.value))(
strong(results.unfixed),
span("to replay")
)

View File

@ -1,5 +1,6 @@
package views.html.puzzle
import controllers.routes
import play.api.libs.json.{ JsObject, Json }
import lila.api.Context
@ -7,8 +8,6 @@ import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
import lila.common.String.html.safeJsonValue
import controllers.routes
object show {
def apply(

View File

@ -82,8 +82,9 @@ GET /training/daily controllers.Puzzle.daily
GET /training/frame controllers.Puzzle.frame
GET /training/export/gif/thumbnail/:id.gif controllers.Export.puzzleThumbnail(id: String)
GET /training/themes controllers.Puzzle.themes
GET /training/dashboard controllers.Puzzle.dashboard(days: Int = 7)
GET /training/dashboard controllers.Puzzle.dashboard(days: Int = 30)
GET /training/dashboard/$days<\d+> controllers.Puzzle.dashboard(days: Int)
GET /training/replay/$days<\d+>/:theme controllers.Puzzle.replay(days: Int, theme: String)
GET /training/batch controllers.Puzzle.mobileBcBatchSelect
POST /training/batch controllers.Puzzle.mobileBcBatchSolve
GET /training/new controllers.Puzzle.mobileBcNew

View File

@ -76,6 +76,8 @@ final class Env(
lazy val dashboard = wire[PuzzleDashboardApi]
lazy val replay = wire[PuzzleReplayApi]
def cli =
new lila.common.Cli {
def process = { case "puzzle" :: "delete" :: id :: Nil =>

View File

@ -17,7 +17,7 @@ final class JsonView(
import JsonView._
def apply(puzzle: Puzzle, theme: PuzzleTheme, user: Option[User])(implicit
def apply(puzzle: Puzzle, theme: PuzzleTheme, replay: Option[PuzzleReplay], user: Option[User])(implicit
lang: Lang
): Fu[JsObject] = {
gameJson(
@ -38,6 +38,7 @@ final class JsonView(
.add("chapter" -> PuzzleTheme.studyChapterIds.get(theme.key))
)
.add("user" -> user.map(userJson))
.add("replay" -> replay.map(replayJson))
}
}
@ -50,6 +51,9 @@ final class JsonView(
"provisional" -> u.perfs.puzzle.provisional
)
private def replayJson(r: PuzzleReplay) =
Json.obj("days" -> r.days, "i" -> r.i, "of" -> r.nb)
def roundJson(u: User, round: PuzzleRound, perf: Perf) =
Json
.obj(

View File

@ -123,7 +123,7 @@ final class PuzzleDashboardApi(
private def countField(field: String) = $doc("$cond" -> $arr("$" + field, 1, 0))
private val puzzleLookup =
private[puzzle] val puzzleLookup =
$doc(
"$lookup" -> $doc(
"from" -> colls.puzzle.name.value,

View File

@ -0,0 +1,117 @@
package lila.puzzle
import org.joda.time.DateTime
import reactivemongo.api.bson.BSONNull
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext
import scala.util.chaining._
import lila.db.dsl._
import lila.memo.CacheApi
import lila.user.User
case class PuzzleReplay(days: Int, theme: PuzzleTheme.Key, nb: Int, remaining: Vector[Puzzle.Id]) {
def i = nb - remaining.size
def step = remaining.headOption map { _ -> copy(remaining = remaining drop 1) }
}
final class PuzzleReplayApi(
colls: PuzzleColls,
cacheApi: CacheApi
)(implicit ec: ExecutionContext) {
import BsonHandlers._
private val maxPuzzles = 100
private val replays = cacheApi.notLoading[User.ID, PuzzleReplay](512, "puzzle.replay")(
_.expireAfterWrite(1 hour).buildAsync()
)
def apply(user: User, days: Int, theme: PuzzleTheme.Key): Fu[Option[(Puzzle, PuzzleReplay)]] =
replays.getFuture(user.id, _ => createReplayFor(user, days, theme)) flatMap { current =>
if (current.days == days && current.theme == theme) fuccess(current)
else createReplayFor(user, days, theme) tap { replays.put(user.id, _) }
} flatMap {
_.step ?? { case (puzzleId, newReplay) =>
replays.put(user.id, fuccess(newReplay))
colls.puzzle(_.byId[Puzzle](puzzleId.value)) map2 (_ -> newReplay)
}
}
private def createReplayFor(user: User, days: Int, theme: PuzzleTheme.Key): Fu[PuzzleReplay] =
colls
.round {
_.aggregateOne() { framework =>
import framework._
Match(
$doc(
"u" -> user.id,
"d" $gt DateTime.now.minusDays(days),
"w" $ne true
)
) -> List(
Sort(Ascending("d")),
PipelineOperator(
$doc(
"$lookup" -> $doc(
"from" -> colls.puzzle.name.value,
"as" -> "puzzle",
"let" -> $doc(
"pid" -> $doc("$arrayElemAt" -> $arr($doc("$split" -> $arr("$_id", ":")), 1))
),
"pipeline" -> $arr(
$doc(
"$match" -> $doc(
"$expr" -> {
if (theme == PuzzleTheme.mix.key) $doc("$eq" -> $arr("$_id", "$$pid"))
else
$doc(
"$and" -> $arr(
$doc("$eq" -> $arr("$_id", "$$pid")),
$doc("$in" -> $arr(theme, "$themes"))
)
)
}
)
),
$doc("$limit" -> maxPuzzles),
$doc("$project" -> $doc("_id" -> true))
)
)
)
),
Unwind("puzzle"),
Group(BSONNull)("ids" -> PushField("puzzle._id"))
)
}
}
.map {
~_.flatMap(_.getAsOpt[Vector[Puzzle.Id]]("ids"))
} map { ids =>
PuzzleReplay(days, theme, ids.size, ids)
}
private val puzzleLookup =
$doc(
"$lookup" -> $doc(
"from" -> colls.puzzle.name.value,
"as" -> "puzzle",
"let" -> $doc(
"pid" -> $doc("$arrayElemAt" -> $arr($doc("$split" -> $arr("$_id", ":")), 1))
),
"pipeline" -> $arr(
$doc(
"$match" -> $doc(
"$expr" -> $doc(
$doc("$eq" -> $arr("$_id", "$$pid"))
)
)
),
$doc("$project" -> $doc("_id" -> true))
)
)
)
}

View File

@ -29,11 +29,7 @@ final class PuzzleSessionApi(
pathApi: PuzzlePathApi,
cacheApi: CacheApi,
userRepo: UserRepo
)(implicit
ec: ExecutionContext,
system: akka.actor.ActorSystem,
mode: play.api.Mode
) {
)(implicit ec: ExecutionContext) {
import BsonHandlers._

View File

@ -70,7 +70,7 @@
&__global .puzzle-dashboard__metrics__metric {
padding: 1em 0 1.5em 0;
border-radius: 6px;
border-radius: 7px;
strong {
font-size: 5em;

View File

@ -54,25 +54,31 @@
@include breakpoint($mq-col3) {
grid-template-areas:
'side . board gauge tools'
'side . session . controls'
'side . . . .';
grid-template-areas: 'side . board gauge tools' 'side . session . controls' 'side . . . .';
grid-template-columns: $col3-uniboard-side $block-gap $col3-uniboard-width var(--gauge-gap) $col3-uniboard-table;
}
&__side {
display: grid;
grid-template-areas: 'user' 'theme' 'metas' 'config';
grid-gap: $block-gap;
grid-template-areas: 'user' 'theme' 'metas' 'config';
.puzzle-replay & {
grid-template-areas: 'replay' 'user' 'metas' 'config';
}
@include breakpoint($mq-xx-small) {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'metas user' 'metas theme' 'config theme' '. theme';
grid-template-rows: min-content min-content min-content;
.puzzle-replay & {
grid-template-areas: 'metas replay' 'metas user' 'config user';
}
}
@include breakpoint($mq-x-large) {
grid-template-columns: 1fr;
grid-template-areas: 'metas' 'user' 'theme' 'config';
@ -80,8 +86,13 @@
justify-self: end;
min-width: 250px;
max-width: 350px;
.puzzle-replay & {
grid-template-areas: 'metas' 'replay' 'user' 'config';
}
}
&__metas {
grid-area: metas;
}
@ -97,5 +108,9 @@
&__theme {
grid-area: theme;
}
&__replay {
grid-area: replay;
}
}
}

View File

@ -45,9 +45,7 @@
padding: 2vmin;
&__rating {
p {
margin: 0;
}
strong {
@extend %flex-center;
@ -101,6 +99,35 @@
}
}
&__replay {
@extend %box-neat;
background: $c-bg-box;
padding: 2vmin;
a {
@extend %roboto;
font-size: 1.3em;
margin-bottom: 1em;
display: block;
}
&__bar {
@extend %box-radius, %flex-center;
justify-content: center;
padding: 0 1em;
height: 2.5em;
$c-bar-bg: $c-bg-zebra2;
$c-bar-fg: mix($c-bg-page, $c-primary, 40%);
font-weight: bold;
background: linear-gradient(to right, $c-bar-fg 0%, $c-bar-fg var(--p), $c-bar-bg var(--p), $c-bar-bg 100%);
}
}
&__theme {
@extend %box-neat, %roboto;
@ -192,8 +219,8 @@
&__lock {
@extend %flex-center;
justify-content: center;
justify-content: center;
flex: 0 0 100%;
color: $c-font-dimmer;
opacity: .5;
@ -202,6 +229,7 @@
&__selector {
margin: 1em 0;
}
&__study {
display: block;
}

View File

@ -127,6 +127,13 @@ export interface PuzzleData {
theme: Theme;
game: PuzzleGame;
user: PuzzleUser | undefined;
replay?: PuzzleReplay;
}
export interface PuzzleReplay {
i: number;
of: number;
days: number;
}
export interface PuzzleGame {

View File

@ -75,7 +75,7 @@ export default function(ctrl: Controller): VNode {
if (!cevalShown) ctrl.vm.autoScrollNow = true;
cevalShown = showCeval;
}
return h('main.puzzle', {
return h(`main.puzzle.puzzle-${ctrl.getData().replay ? 'replay' : 'play'}`, {
class: { 'gauge-on': gaugeOn },
hook: {
postpatch(old, vnode) {
@ -91,6 +91,7 @@ export default function(ctrl: Controller): VNode {
}
}, [
h('aside.puzzle__side', [
side.replay(ctrl),
side.puzzleBox(ctrl),
side.userBox(ctrl),
side.config(ctrl),

View File

@ -56,7 +56,7 @@ export function userBox(ctrl: Controller): VNode {
]);
const diff = ctrl.vm.round?.ratingDiff;
return h('div.puzzle__side__user', [
h('p.puzzle__side__user__rating', ctrl.trans.vdom('yourPuzzleRatingX', h('strong', [
h('div.puzzle__side__user__rating', ctrl.trans.vdom('yourPuzzleRatingX', h('strong', [
data.user.rating - (diff || 0),
...(diff && diff > 0 ? [' ', h('good.rp', '+' + diff)] : []),
...(diff && diff < 0 ? [' ', h('bad.rp', '' + (-diff))] : [])
@ -66,8 +66,24 @@ export function userBox(ctrl: Controller): VNode {
const difficulties: PuzzleDifficulty[] = ['easiest', 'easier', 'normal', 'harder', 'hardest'];
export function config(ctrl: Controller): MaybeVNode {
export function replay(ctrl: Controller): MaybeVNode {
const replay = ctrl.getData().replay;
return replay ?
h('div.puzzle__side__replay', [
h('a', {
attrs: {
href: `/training/dashboard/${replay.days}`
}
}, ['« ', `Replaying ${ctrl.trans.noarg(ctrl.getData().theme.key)} puzzles`]),
h('div.puzzle__side__replay__bar', {
attrs: {
style: `--p:${Math.round(100 * replay.i / replay.of)}%`
},
}, `${replay.i} / ${replay.of}`)
]) : null;
}
export function config(ctrl: Controller): MaybeVNode {
const id = 'puzzle-toggle-autonext';
return h('div.puzzle__side__config', [
h('div.puzzle__side__config__jump', [
@ -86,7 +102,7 @@ export function config(ctrl: Controller): MaybeVNode {
]),
h('label', { attrs: { 'for': id } }, ctrl.trans.noarg('jumpToNextPuzzleImmediately'))
]),
ctrl.difficulty ? h('form.puzzle__side__config__difficulty', {
!ctrl.getData().replay && ctrl.difficulty ? h('form.puzzle__side__config__difficulty', {
attrs: {
action: `/training/difficulty/${ctrl.getData().theme.key}`,
method: 'post'
@ -107,5 +123,6 @@ export function config(ctrl: Controller): MaybeVNode {
}, ctrl.trans.noarg(diff))
))
]) : null
]);
}

View File

@ -1,13 +1,13 @@
import { bind, dataIcon } from '../util';
import { Controller } from '../interfaces';
import { Controller, MaybeVNode } from '../interfaces';
import { h } from 'snabbdom';
import { VNode } from 'snabbdom/vnode';
const studyUrl = 'https://lichess.org/study/viiWlKjv';
export default function theme(ctrl: Controller): VNode {
export default function theme(ctrl: Controller): MaybeVNode {
const t = ctrl.getData().theme;
return h('div.puzzle__side__theme', [
return ctrl.getData().replay ? null : h('div.puzzle__side__theme', [
h('a', { attrs: { href: '/training/themes' } }, h('h2', ['« ', t.name])),
h('p', [
t.desc,