Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
4d571dc
tutor dates WIP
ornicar Feb 15, 2026
49951c2
Merge branch 'master' into tutor-dates
ornicar Feb 16, 2026
d08ed70
tutor dates WIP
ornicar Feb 16, 2026
014678d
tutor dates WIP
ornicar Feb 16, 2026
72c7337
tutor controller simplification
ornicar Feb 16, 2026
ced574c
Merge branch 'master' into tutor-dates
ornicar Feb 16, 2026
a3917d8
Merge branch 'master' into tutor-dates
ornicar Feb 16, 2026
90aeb80
tutor date range WIP
ornicar Feb 16, 2026
4b53094
Merge branch 'master' into tutor-dates
ornicar Feb 17, 2026
6090f8e
tutor date ranges WIP
ornicar Feb 17, 2026
76089d0
tutor reports list and form
ornicar Feb 17, 2026
686cb5e
tutor date range WIP
ornicar Feb 17, 2026
ab8b4a6
Merge branch 'master' into tutor-dates
ornicar Feb 18, 2026
5b4e433
bump insights max games to 15k
ornicar Feb 18, 2026
295b8f9
Merge branch 'master' into tutor-dates
ornicar Feb 18, 2026
e3468b6
Merge branch 'tutor-dates' of github.com:lichess-org/lila into tutor-…
ornicar Feb 18, 2026
c58fde5
tutor multi reports WIP
ornicar Feb 18, 2026
0585bb6
tutor wip
ornicar Feb 18, 2026
e8d2355
Merge branch 'master' into tutor-dates
ornicar Feb 20, 2026
8cd60e1
tutor home UI
ornicar Feb 20, 2026
c020def
tuto reports list WIP
ornicar Feb 20, 2026
e0815db
Merge branch 'master' into tutor-dates
ornicar Feb 21, 2026
c751612
tutor reports list WIP
ornicar Feb 21, 2026
dfed446
tutor tweaks
ornicar Feb 21, 2026
1da069c
Merge branch 'master' into tutor-dates
ornicar Feb 21, 2026
d15bf7b
wait 1s for tutor analysis to make it into insights documents
ornicar Feb 21, 2026
4e2ee36
add vibe-coding rules to CONTRIBUTING.md
ornicar Feb 21, 2026
12d0a5c
tweak wording
ornicar Feb 21, 2026
f5dacad
add pull request guidelines
ornicar Feb 21, 2026
cf45102
Merge branch 'master' into tutor-dates
ornicar Feb 22, 2026
7b7c10d
Merge branch 'master' into tutor-dates
ornicar Feb 22, 2026
5a5e657
show tutor config date range on wait page
ornicar Feb 22, 2026
e982ceb
redirect to tutor report when ready
ornicar Feb 22, 2026
bc6b349
tutor ready message
ornicar Feb 22, 2026
5c83eeb
Merge branch 'master' into tutor-dates
ornicar Feb 22, 2026
5bd0600
tutor menu wip
ornicar Feb 22, 2026
3b4da24
Merge branch 'master' into tutor-dates
ornicar Feb 23, 2026
6fe68fb
tutor side menu
ornicar Feb 23, 2026
e25a334
empty tutor report UI
ornicar Feb 23, 2026
f9af55e
update insights rating distribution
ornicar Feb 23, 2026
09984f0
delete tutor reports
ornicar Feb 23, 2026
aa2c94a
tutor UI
ornicar Feb 23, 2026
8bce195
Merge branch 'master' into tutor-dates
ornicar Feb 24, 2026
37c60b4
tutor badges
ornicar Feb 24, 2026
90a5c63
make var(---box-padding) available outside of .box
ornicar Feb 24, 2026
594a7b1
more tutor UI
ornicar Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 83 additions & 58 deletions app/controllers/Tutor.scala
Original file line number Diff line number Diff line change
@@ -1,94 +1,119 @@
package controllers

import play.api.data.Form
import play.api.mvc.*

import lila.app.{ *, given }
import lila.common.LilaOpeningFamily
import lila.core.perf.UserWithPerfs
import lila.rating.PerfType
import lila.tutor.{ TutorFullReport, TutorPerfReport, TutorQueue }
import lila.tutor.{ TutorFullReport, TutorPerfReport, TutorConfig, TutorAvailability }

final class Tutor(env: Env) extends LilaController(env):

def home = Auth { _ ?=> me ?=>
Redirect(routes.Tutor.user(me.username))
}

def user(username: UserStr) = TutorPage(username) { _ ?=> user => av =>
Ok.page(views.tutor.home(av, user))
def user(username: UserStr) = Auth { _ ?=> _ ?=>
WithUser(username): user =>
renderUser(user, TutorConfig.form.default)
}

def perf(username: UserStr, perf: PerfKey) = TutorPerfPage(username, perf) { _ ?=> user => full => perf =>
Ok.page(views.tutor.perf(full.report, perf, user))
private def renderUser(user: UserModel, form: Form[?])(using Context) =
for
withPerfs <- env.user.api.withPerfs(user)
av <- env.tutor.api.availability(withPerfs)
res <- av match
case TutorAvailability.InsufficientGames =>
Ok.page(views.tutor.home.insufficientGames(user.id))
case TutorAvailability.Available(home) =>
home.previews.headOption.ifTrue(getBool("waiting") && home.awaiting.isEmpty) match
case Some(done) => Redirect(done.config.url.root).toFuccess
case None => Ok.page(views.tutor.home(home, form))
yield res

def report(username: UserStr, range: String) = TutorReport(username, range) { _ ?=> full =>
Ok.page(views.tutor.report(full))
}

def perf(username: UserStr, range: String, perf: PerfKey) = TutorPerfPage(username, range, perf) {
_ ?=> full => perf =>
Ok.page(views.tutor.perf(full, perf))
}

def angle(username: UserStr, perf: PerfKey, angle: String) = TutorPerfPage(username, perf) {
_ ?=> user => full => perf =>
def angle(username: UserStr, range: String, perf: PerfKey, angle: String) =
TutorPerfPage(username, range, perf) { _ ?=> full => perf =>
angle match
case "skills" => Ok.page(views.tutor.perf.skills(full.report, perf, user))
case "phases" => Ok.page(views.tutor.perf.phases(full.report, perf, user))
case "time" => Ok.page(views.tutor.perf.time(full.report, perf, user))
case "pieces" => Ok.page(views.tutor.perf.pieces(full.report, perf, user))
case "opening" => Ok.page(views.tutor.openingUi.openings(full.report, perf, user))
case "skills" => Ok.page(views.tutor.perf.skills(full, perf))
case "phases" => Ok.page(views.tutor.perf.phases(full, perf))
case "time" => Ok.page(views.tutor.perf.time(full, perf))
case "pieces" => Ok.page(views.tutor.perf.pieces(full, perf))
case "opening" => Ok.page(views.tutor.openingUi.openings(full, perf))
case _ => notFound
}
}

def opening(username: UserStr, perf: PerfKey, color: Color, opName: String) =
TutorPerfPage(username, perf) { _ ?=> user => full => perf =>
def opening(username: UserStr, range: String, perf: PerfKey, color: Color, opName: String) =
TutorPerfPage(username, range, perf) { _ ?=> full => perf =>
LilaOpeningFamily
.find(opName)
.flatMap(perf.openings(color).find)
.fold(Redirect(routes.Tutor.angle(user.username, perf.perf.key, "openings")).toFuccess): family =>
.fold(Redirect(full.url.angle(perf.perf, "opening")).toFuccess): family =>
env.puzzle.opening.find(family.family.key).flatMap { puzzle =>
Ok.page(views.tutor.opening(full.report, perf, family, color, user, puzzle))
Ok.page(views.tutor.opening(full, perf, family, color, puzzle))
}
}

def refresh(username: UserStr) = TutorPageAvailability(username) { _ ?=> user => availability =>
env.tutor.api.request(user, availability).inject(redirHome(user))
def compute(username: UserStr) = AuthBody { _ ?=> _ ?=>
WithUser(username): user =>
bindForm(TutorConfig.form.dates)(
err => renderUser(user, err),
dates =>
val config = dates.config(user.id)
env.tutor.api
.get(config)
.flatMap:
case Some(report) => Redirect(report.url.root).toFuccess
case None =>
for _ <- env.tutor.queue.enqueue(config)
yield Redirect(routes.Tutor.user(user.username))
)
}

private def TutorPageAvailability(
username: UserStr
)(f: Context ?=> UserModel => TutorFullReport.Availability => Fu[Result]): EssentialAction =
Auth { _ ?=> me ?=>
def proceed(user: UserWithPerfs) = env.tutor.api.availability(user).flatMap(f(user.user))
if me.is(username) then env.user.api.withPerfs(me.value).flatMap(proceed)
def delete(username: UserStr, range: String) = TutorReport(username, range) { _ ?=> full =>
for _ <- env.tutor.api.delete(full.config)
yield Redirect(routes.Tutor.user(username))
}

private def WithUser(username: UserStr)(f: UserModel => Fu[Result])(using
me: Me
)(using Context): Fu[Result] =
val user: Fu[Option[UserModel]] =
if me.is(username) then fuccess(me.some)
else
Found(env.user.api.withPerfs(username)): user =>
if isGranted(_.SeeInsight) then proceed(user)
else
user.enabled.yes
.so(env.clas.api.clas.isTeacherOf(me, user.id))
.flatMap:
if _ then proceed(user) else notFound
}
env.user.api
.byId(username.id)
.flatMapz: user =>
for canSee <- fuccess(isGranted(_.SeeInsight)) >>|
user.enabled.yes.so(env.clas.api.clas.isTeacherOf(me, user.id))
yield Option.when(canSee)(user)
Found(user)(f)

private def TutorPage(
username: UserStr
)(f: Context ?=> UserModel => TutorFullReport.Available => Fu[Result]): EssentialAction =
TutorPageAvailability(username) { _ ?=> user => availability =>
availability match
case TutorFullReport.InsufficientGames =>
BadRequest.page(views.tutor.home.empty.insufficientGames(user))
case TutorFullReport.Empty(in: TutorQueue.InQueue) =>
for
waitGames <- env.tutor.queue.waitingGames(user)
user <- env.user.api.withPerfs(user)
page <- renderPage(views.tutor.home.empty.queued(in, user, waitGames))
yield Accepted(page)
case TutorFullReport.Empty(_) => Accepted.page(views.tutor.home.empty.start(user))
case available: TutorFullReport.Available => f(user)(available)
private def TutorReport(username: UserStr, range: String)(
f: Context ?=> TutorFullReport => Fu[Result]
): EssentialAction =
Auth { _ ?=> _ ?=>
WithUser(username): _ =>
TutorConfig
.parse(username.id, range)
.so(env.tutor.api.get)
.flatMap:
case None => Redirect(routes.Tutor.user(username)).toFuccess
case Some(full) => f(full)
}

private def TutorPerfPage(username: UserStr, perf: PerfKey)(
f: Context ?=> UserModel => TutorFullReport.Available => TutorPerfReport => Fu[Result]
private def TutorPerfPage(username: UserStr, range: String, perf: PerfKey)(
f: Context ?=> TutorFullReport => TutorPerfReport => Fu[Result]
): EssentialAction =
TutorPage(username) { _ ?=> user => availability =>
availability match
case full @ TutorFullReport.Available(report, _) =>
report(perf).fold(redirHome(user).toFuccess):
f(user)(full)
TutorReport(username, range) { _ ?=> full =>
full(perf).fold(Redirect(full.url.root).toFuccess)(f(full))
}

private def redirHome(user: UserModel) = Redirect(routes.Tutor.user(user.username))
8 changes: 5 additions & 3 deletions app/views/tutor.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import lila.app.UiEnv.{ *, given }

val bits = lila.tutor.ui.TutorBits(helpers)(views.opening.bits.openingUrl)
val perf = lila.tutor.ui.TutorPerfUi(helpers, bits)
val home = lila.tutor.ui.TutorHome(helpers, bits, perf)
val queue = lila.tutor.ui.TutorQueueUi(helpers, bits)
val reports = lila.tutor.ui.TutorReportsUi(helpers, bits)
val report = lila.tutor.ui.TutorReportUi(helpers, bits, perf)
val home = lila.tutor.ui.TutorHomeUi(helpers, bits, queue, reports)
val openingUi = lila.tutor.ui.TutorOpening(helpers, bits, perf)

def opening(
full: lila.tutor.TutorFullReport,
perfReport: lila.tutor.TutorPerfReport,
report: lila.tutor.TutorOpeningFamily,
as: Color,
user: User,
puzzle: Option[lila.puzzle.PuzzleOpening.FamilyWithCount]
)(using Context) =
val puzzleFrag = puzzle.map: p =>
Expand All @@ -21,4 +23,4 @@ def opening(
dataIcon := Icon.ArcheryTarget,
href := routes.Puzzle.angleAndColor(p.family.key.value, as.name)
)("Train with puzzles")
openingUi.opening(full, perfReport, report, as, user, puzzleFrag)
openingUi.opening(full, perfReport, report, as, puzzleFrag)
2 changes: 1 addition & 1 deletion bin/mongodb/indexes.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ db.kaladin_queue.createIndex(
{ partialFilterExpression: { 'response.at': { $exists: true } } },
);
db.tutor_queue.createIndex({ requestedAt: 1 });
db.tutor_report.createIndex({ at: -1 });
db.tutor_report.createIndex({ 'config.user': 1, at: -1 });

// you may want to run these on the puzzle database
db.puzzle2_round.createIndex({ p: 1 }, { partialFilterExpression: { t: { $exists: true } } });
Expand Down
43 changes: 0 additions & 43 deletions bin/mongodb/insight-tutor-test.js

This file was deleted.

22 changes: 22 additions & 0 deletions bin/mongodb/tutor-dates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const printDate = d => d.toISOString().split('T')[0];
db.tutor_report.find({ config: { $exists: 0 } }).forEach(old => {
let minDate = new Date();
let maxDate = new Date('2023-01-01');
old.perfs.forEach(perf => {
const dates = perf.stats.dates;
if (dates[0] < minDate) minDate = dates[0];
if (dates[1] > maxDate) maxDate = dates[1];
});
const report = {
...old,
_id: `${old.user}:${printDate(minDate)}_${printDate(maxDate)}`,
config: {
user: old.user,
from: minDate,
to: maxDate,
},
};
delete report.user;
db.tutor_report.insertOne(report);
db.tutor_report.deleteOne({ _id: old._id });
});
12 changes: 7 additions & 5 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -854,11 +854,13 @@ POST /account/signout/:sessionId controllers.Account.signout(sessionId)
GET /account/now-playing controllers.Account.nowPlaying

GET /tutor controllers.Tutor.home()
GET /tutor/:username controllers.Tutor.user(username: UserStr)
POST /tutor/:username/refresh controllers.Tutor.refresh(username: UserStr)
GET /tutor/:username/:perf controllers.Tutor.perf(username: UserStr, perf: PerfKey)
GET /tutor/:username/:perf/:angle controllers.Tutor.angle(username: UserStr, perf: PerfKey, angle)
GET /tutor/:username/:perf/opening/:color/:opening controllers.Tutor.opening(username: UserStr, perf: PerfKey, color: Color, opening)
GET /tutor/:user controllers.Tutor.user(user: UserStr)
POST /tutor/:user/compute controllers.Tutor.compute(user: UserStr)
GET /tutor/:user/:range controllers.Tutor.report(user: UserStr, range)
POST /tutor/:user/delete controllers.Tutor.delete(user: UserStr, range)
GET /tutor/:user/:range/:perf controllers.Tutor.perf(user: UserStr, range, perf: PerfKey)
GET /tutor/:user/:range/:perf/:angle controllers.Tutor.angle(user: UserStr, range, perf: PerfKey, angle)
GET /tutor/:user/:range/:perf/opening/:color/:opening controllers.Tutor.opening(user: UserStr, range, perf: PerfKey, color: Color, opening: String)

# Recap
GET /recap controllers.Recap.home
Expand Down
2 changes: 1 addition & 1 deletion modules/activity/src/main/Activity.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ case class Activity(
stream: Boolean = false
):

def date = id.day.toDate
def date = id.day.toInstant

def interval = TimeInterval(date, date.plusDays(1))

Expand Down
2 changes: 1 addition & 1 deletion modules/common/src/main/LichessDay.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ opaque type LichessDay = Int

object LichessDay extends OpaqueInt[LichessDay]:

extension (day: LichessDay) def toDate = genesis.plusDays(day).withTimeAtStartOfDay
extension (day: LichessDay) def toInstant: Instant = genesis.plusDays(day).withTimeAtStartOfDay

val genesis: Instant = instantOf(2010, 1, 1, 0, 0).withTimeAtStartOfDay

Expand Down
7 changes: 1 addition & 6 deletions modules/core/src/main/perf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,7 @@ object perf:
trait PerfStatApi:
def highestRating(user: UserId, perfKey: PerfKey): Fu[Option[IntRating]]

case class Perf(
glicko: Glicko,
nb: Int,
recent: List[IntRating],
latest: Option[Instant]
):
case class Perf(glicko: Glicko, nb: Int, recent: List[IntRating], latest: Option[Instant]):
export glicko.{ intRating, intDeviation, provisional }
export latest.{ isEmpty, nonEmpty }

Expand Down
2 changes: 1 addition & 1 deletion modules/game/src/main/GameRepo.scala
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ final class GameRepo(c: Coll)(using Executor) extends lila.core.game.GameRepo(c)
.dmap:
_.flatMap(Pov(_, user))

def recentPovsByUserFromSecondary(user: User, nb: Int, select: Bdoc = $empty): Fu[List[Pov]] =
def recentPovsByUserFromSecondary[U: UserIdOf](user: U, nb: Int, select: Bdoc = $empty): Fu[List[Pov]] =
recentGamesFromSecondaryCursor(Query.user(user) ++ select)
.list(nb)
.map { _.flatMap(Pov(_, user)) }
Expand Down
6 changes: 5 additions & 1 deletion modules/insight/src/main/InsightPerfStatsApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ final class InsightPerfStatsApi(

def apply(
user: User,
period: PairOf[Instant],
perfTypes: List[PerfType],
gameIdsPerPerf: Max
): Fu[Map[PerfType, InsightPerfStats.WithGameIds]] =
Expand All @@ -34,7 +35,10 @@ final class InsightPerfStatsApi(
import framework.*
import InsightEntry.BSONFields as F
val filters = List(lila.insight.Filter(InsightDimension.Perf, perfTypes))
Match(InsightStorage.selectUserId(user.id) ++ pipeline.gameMatcher(filters)) -> List(
val gameSelector = InsightStorage.selectUserId(user.id) ++
pipeline.gameMatcher(filters) ++
dateBetween(F.date, period._1.some, period._2.some)
Match(gameSelector) -> List(
Sort(Descending(F.date)),
Limit(maxGames.value),
Project(
Expand Down
6 changes: 2 additions & 4 deletions modules/insight/src/main/PeersRatingRange.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@ object PeersRatingRange extends TotalWrapper[PeersRatingRange, PairOf[IntRating]

private val distribution =
// > db.insight.estimatedDocumentCount()
// 2853219252
// 2301257568
// > db.insight.aggregate([{$sample:{size:50000000}},{$project:{_id:0,mr:1}},{$match:{mr:{$exists:1}}},{$group:{_id:null,ratings:{$avg:'$mr'}}}])
// { "_id" : null, "ratings" : 1823.3090773968 }
// > db.insight.aggregate([{$sample:{size:50000000}},{$project:{_id:0,mr:1}},{$match:{mr:{$exists:1}}},{$group:{_id:null,ratings:{$stdDevSamp:'$mr'}}}])
// { "_id" : null, "ratings" : 364.74285981482296 }
lila.rating.Gaussian(1823.31, 364.74d)
lila.rating.Gaussian(1804.45, 368.24d)

private val minRating = Glicko.minRating
private val maxRating = IntRating(3200)
Expand Down
2 changes: 1 addition & 1 deletion modules/insight/src/main/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export lila.common.extensions.*

private val logger = lila.log("insight")

val maxGames = Max(10_000)
val maxGames = Max(15_000)
val minDate = instantOf(2023, 1, 1, 0, 0)
2 changes: 1 addition & 1 deletion modules/storm/src/main/StormJson.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ object StormJson:
private val dateFormatter = java.time.format.DateTimeFormatter.ofPattern("Y/M/d")

given Writes[StormDay.Id] = Writes { id =>
JsString(dateFormatter.print(id.day.toDate))
JsString(dateFormatter.print(id.day.toInstant))
}
given OWrites[StormDay] = Json.writes

Expand Down
2 changes: 1 addition & 1 deletion modules/storm/src/main/ui/StormUi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ final class StormUi(helpers: Helpers):
tbody(cls := "infinite-scroll")(
history.currentPageResults.map { day =>
tr(
td(showDate(day._id.day.toDate)),
td(showDate(day._id.day.toInstant)),
td(numberTag(cls := "score")(day.score)),
td(numberTag(day.moves)),
td(numberTag(f"${day.accuracyPercent}%1.1f"), "%"),
Expand Down
Loading