diff --git a/bin/mongodb/recap-notif.js b/bin/mongodb/recap-notif.js new file mode 100644 index 0000000000000..bed89d6138316 --- /dev/null +++ b/bin/mongodb/recap-notif.js @@ -0,0 +1,77 @@ +const year = 2024; +const dry = false; + +let count = 0; + +const hasPuzzles = userId => db.user_perf.count({ _id: userId, 'puzzle.nb': { $gt: 0 } }); + +function sendToUser(user) { + if (!user.enabled) { + print('------------- ' + user._id + ' is closed'); + return; + } + const exists = db.notify.countDocuments({ notifies: user._id, 'content.type': 'recap', }, { limit: 1 }); + if (exists) { + print('------------- ' + user._id + ' already sent'); + return; + } + if (user.seenAt < new Date('2024-01-01')) { + print('------------- ' + user._id + ' not seen in 2024'); + return; + } + if (!user.count?.game && !hasPuzzles(user._id)) { + print('------------- ' + user._id + ' no games or puzzles'); + return; + } + if (!dry) db.notify.insertOne({ + _id: Math.random().toString(36).substring(2, 10), + notifies: user._id, + content: { + type: 'recap', + year: NumberInt(year), + }, + read: false, + createdAt: new Date(), + }); + count++; + print(count + ' ' + user._id); +} + +function sendToUserId(userId) { + const user = db.user4.findOne({ _id: userId }); + if (!user) { + print('------------- ' + userId + ' not found'); + return; + } + sendToUser(user); +} + +function sendToRoleOwners() { + db.user4.find({ enabled: true, roles: { $exists: 1, $ne: [] } }).forEach(user => { + roles = user.roles.filter(r => r != 'ROLE_COACH' && r != 'ROLE_TEACHER' && r != 'ROLE_VERIFIED' && r != 'ROLE_BETA'); + if (roles.length) { + sendTo(user); + } + }); +} + +function sendToTeamMembers(teamId) { + db.team_member.find({ team: teamId }, { user: 1, _id: 0 }).forEach(member => { + sendToUserId(member.user); + }); +} + +function sendToRandomOnlinePlayers() { + db.user4.find({ enabled: true, 'count.game': { $gt: 10 }, seenAt: { $gt: new Date(Date.now() - 1000 * 60 * 2) } }).sort({ seenAt: -1 }).limit(5_000).forEach(sendToUser); +} + +function sendToRandomOfflinePlayers() { + db.user4.find({ + enabled: true, 'count.game': { $gt: 10 }, seenAt: { + $gt: new Date(Date.now() - 1000 * 60 * 60 * 24), + $lt: new Date(Date.now() - 1000 * 60 * 60) + } + }).limit(25_000).forEach(sendToUser); +} + +sendToRandomOfflinePlayers(); diff --git a/modules/core/src/main/perm.scala b/modules/core/src/main/perm.scala index ec6b7c3702bb0..b76982a77a690 100644 --- a/modules/core/src/main/perm.scala +++ b/modules/core/src/main/perm.scala @@ -102,7 +102,7 @@ enum Permission(val key: String, val alsoGrants: List[Permission], val name: Str case StudyAdmin extends Permission("STUDY_ADMIN", List(Relay), "Study/Broadcast admin") case ApiHog extends Permission("API_HOG", "API hog") case ApiChallengeAdmin extends Permission("API_CHALLENGE_ADMIN", "API Challenge admin") - case LichessTeam extends Permission("LICHESS_TEAM", Nil, "Lichess team") + case LichessTeam extends Permission("LICHESS_TEAM", List(Beta), "Lichess team") case TimeoutMod extends Permission( "TIMEOUT_MOD", diff --git a/modules/memo/src/main/ParallelMongoQueue.scala b/modules/memo/src/main/ParallelMongoQueue.scala index 2a319ba64f11f..3a928a8204262 100644 --- a/modules/memo/src/main/ParallelMongoQueue.scala +++ b/modules/memo/src/main/ParallelMongoQueue.scala @@ -59,7 +59,6 @@ final class ParallelMongoQueue[A: BSONHandler]( /* Read the oldest entries from the queue * start new ones, expire old ones */ - // LilaScheduler(s"ParallelQueue($name).poll", _.Every(1 second), _.AtMost(5 seconds), _.Delay(33 seconds)): private val startAfter = if mode.isProd then 33.seconds else 3.seconds LilaScheduler(s"ParallelQueue($name).poll", _.Every(1 second), _.AtMost(5 seconds), _.Delay(startAfter)): diff --git a/modules/recap/src/main/Env.scala b/modules/recap/src/main/Env.scala index d5d6f17bf2c26..b0e8ad0b8334d 100644 --- a/modules/recap/src/main/Env.scala +++ b/modules/recap/src/main/Env.scala @@ -1,7 +1,6 @@ package lila.recap import com.softwaremill.macwire.* -import com.softwaremill.tagging.* import lila.core.config.CollName import lila.db.dsl.{ *, given } @@ -21,7 +20,7 @@ final class Env( "recapParallelism", default = 8, text = "Number of yearly recaps to build in parallel".some - ).taggedWith[Parallelism] + ) private val colls = RecapColls(db(CollName("recap_report")), db(CollName("recap_queue"))) @@ -41,5 +40,4 @@ final class Env( lazy val api = wire[RecapApi] -trait Parallelism final private class RecapColls(val recap: Coll, val queue: Coll) diff --git a/modules/recap/src/main/RecapBuilder.scala b/modules/recap/src/main/RecapBuilder.scala index d24c878fd9786..e8fff9ef67cd9 100644 --- a/modules/recap/src/main/RecapBuilder.scala +++ b/modules/recap/src/main/RecapBuilder.scala @@ -55,6 +55,7 @@ private final class RecapBuilder( nbs = NbWin(total = nb, win = wins - fixes), votes = PuzzleVotes(nb = votes, themes = themes) ) + .monSuccess(_.recap.puzzles) private def makeGameRecap(scan: GameScan): RecapGames = RecapGames( @@ -68,10 +69,7 @@ private final class RecapBuilder( timePlaying = scan.secondsPlaying.seconds, sources = scan.sources, opponents = scan.opponents.toList.sortBy(-_._2).take(5).map(Recap.Counted.apply), - perfs = scan.perfs.toList - .sortBy(-_._2) - .map: - case (key, games) => Recap.Perf(key, games) + perfs = scan.perfs.toList.sortBy(-_._2).map(Recap.Perf.apply) ) private def runGameScan(userId: UserId): Fu[GameScan] = diff --git a/modules/relay/src/main/RelayDelay.scala b/modules/relay/src/main/RelayDelay.scala index add0702b39259..9c380bd031260 100644 --- a/modules/relay/src/main/RelayDelay.scala +++ b/modules/relay/src/main/RelayDelay.scala @@ -41,7 +41,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): Option(v) match case Some(GamesSeenBy(games, seenBy)) if !seenBy(round.id) => lila.mon.relay.dedup.increment() - logger.info(s"Relay dedup cache hit ${round.id} ${round.name} ${url.toString}") + logger.debug(s"Relay dedup cache hit ${round.id} ${round.name} ${url.toString}") GamesSeenBy(games, seenBy + round.id) case _ => GamesSeenBy(doFetch(), Set(round.id)) diff --git a/modules/relay/src/main/RelayPgnStream.scala b/modules/relay/src/main/RelayPgnStream.scala index b6dd3980fea9d..46ba2dce32d2e 100644 --- a/modules/relay/src/main/RelayPgnStream.scala +++ b/modules/relay/src/main/RelayPgnStream.scala @@ -14,13 +14,11 @@ final class RelayPgnStream( )(using Executor): def exportFullTourAs(tour: RelayTour, me: Option[User]): Source[PgnStr, ?] = Source.futureSource: - roundRepo - .idsByTourOrdered(tour.id) - .flatMap: ids => - studyRepo.byOrderedIds(StudyId.from[List, RelayRoundId](ids)).map { studies => - val visible = studies.filter(_.canView(me.map(_.id))) - Source(visible).flatMapConcat { studyPgnDump.chaptersOf(_, flags) }.throttle(16, 1.second) - } + for + ids <- roundRepo.idsByTourOrdered(tour.id) + studies <- studyRepo.byOrderedIds(StudyId.from[List, RelayRoundId](ids)) + visible = studies.filter(_.canView(me.map(_.id))) + yield Source(visible).flatMapConcat { studyPgnDump.chaptersOf(_, flags) }.throttle(16, 1.second) private val flags = PgnDump.WithFlags( comments = false, diff --git a/modules/shutup/src/main/Dictionary.scala b/modules/shutup/src/main/Dictionary.scala index b67ec2af9aad1..a28bcc0039553 100644 --- a/modules/shutup/src/main/Dictionary.scala +++ b/modules/shutup/src/main/Dictionary.scala @@ -245,6 +245,7 @@ uebok """) def es = dict(""" +bolud[oa] cabr[oó]na? cag[oó]n ching(ue|a) diff --git a/public/flair/list.sh b/public/flair/list.sh index 92f6d8b2fc3a1..f2e34c84d8873 100755 --- a/public/flair/list.sh +++ b/public/flair/list.sh @@ -3,7 +3,7 @@ # Create list.txt from img/*.webp pushd "$(dirname "$0")" -ls img/*.webp | sed 's/.webp//g' | sed 's/img\///g' >list.txt +ls img/*.webp | grep -v 'symbols.cancel' | sed 's/.webp//g' | sed 's/img\///g' >list.txt popd echo "Done creating flair/list.txt." diff --git a/public/flair/list.txt b/public/flair/list.txt index ce079d99eee9a..f4634440f42e8 100644 --- a/public/flair/list.txt +++ b/public/flair/list.txt @@ -3058,7 +3058,6 @@ symbols.blue-heart symbols.bright-button symbols.broken-heart symbols.brown-heart -symbols.cancel symbols.cancer symbols.capricorn symbols.chequered-flag diff --git a/ui/recap/css/_recap.scss b/ui/recap/css/_recap.scss index 29cd890c4efbd..44e3129a00949 100644 --- a/ui/recap/css/_recap.scss +++ b/ui/recap/css/_recap.scss @@ -73,7 +73,7 @@ body { .recap { &__logo, .lpv { - width: 384px; + width: 45vh; max-width: 61vw; max-height: 50vh; } @@ -218,6 +218,11 @@ body { } } } + @media (min-width: at-least($xx-small)) { + .logo { + display: none; + } + } } .recap small { @@ -267,6 +272,7 @@ body { font-size: 0.8em; opacity: 0.8; min-width: 12ch; + padding-left: 1em; } } .recap__slide--opponents { diff --git a/ui/recap/src/interfaces.ts b/ui/recap/src/interfaces.ts index 791d901d37cdf..ac6d638a14a84 100644 --- a/ui/recap/src/interfaces.ts +++ b/ui/recap/src/interfaces.ts @@ -26,6 +26,7 @@ export interface Sources { simul: number; swiss: number; pool: number; + lobby: number; ai: number; arena: number; } diff --git a/ui/recap/src/slides.ts b/ui/recap/src/slides.ts index 0c19f7597edbd..c4cc0423902de 100644 --- a/ui/recap/src/slides.ts +++ b/ui/recap/src/slides.ts @@ -59,7 +59,10 @@ export const timeSpentPlaying = (r: Recap): VNode => { }; export const nbMoves = (r: Recap): VNode => { - return slideTag('moves')([ + return slideTag( + 'moves', + 6000, + )([ h('div.recap--massive', [h('strong', animateNumber(r.games.moves)), 'moves played']), h('div', [ h('p', ["That's ", h('strong', showGrams(r.games.moves * pieceGrams)), ' of wood pushed!']), @@ -120,8 +123,9 @@ export const firstMoves = (r: Recap, firstMove: Counted): VNode => { ]); }; -export const openingColor = (os: ByColor>, color: Color): VNode => { +export const openingColor = (os: ByColor>, color: Color): VNode | undefined => { const o = os[color]; + if (!o.count) return; return slideTag('openings')([ h('div.lpv.lpv--todo.lpv--moves-bottom.is2d', { hook: onInsert(el => loadOpeningLpv(el, color, o.value)), @@ -191,7 +195,8 @@ export const sources = (r: Recap): VNode => { ['arena', 'Arena tournaments'], ['swiss', 'Swiss tournaments'], ['simul', 'Simuls'], - ['pool', 'Lobby pairing'], + ['pool', 'Pool pairing'], + ['lobby', 'Lobby custom games'], ]; const best: [string, number][] = all.map(([k, n]) => [n, r.games.sources[k] || 0]); best.sort((a, b) => b[1] - a[1]); @@ -253,7 +258,7 @@ export const thanks = (): VNode => slideTag('thanks')([ h('div.recap--massive', 'Thank you for playing on Lichess!'), h('img.recap__logo', { attrs: { src: site.asset.url('logo/lichess-white.svg') } }), - h('div', "May your pieces find their way to your opponents' kings."), + h('div', "We're glad you're here. Have a great 2025!"), ]); const renderPerf = (perf: RecapPerf): VNode => { diff --git a/ui/recap/src/swiper.ts b/ui/recap/src/swiper.ts index 6da6131a91bf5..6c327084dd4c7 100644 --- a/ui/recap/src/swiper.ts +++ b/ui/recap/src/swiper.ts @@ -19,7 +19,6 @@ export const makeSwiper = ? parseInt(window.location.hash.slice(1)) : undefined; const autoplay = !defined(urlSlide); - let lpvTimer: number | undefined; const options: SwiperOptions = { modules: [ mod.Pagination, @@ -54,46 +53,58 @@ export const makeSwiper = : undefined, on: { autoplayTimeLeft(swiper, time, progress) { - if (swiper.isEnd) progressDiv.remove(); + if (swiper.isEnd) progressDiv.style.display = 'none'; + else progressDiv.style.display = 'flex'; progressCircle.style.setProperty('--progress', (1 - progress).toString()); progressContent.textContent = `${Math.ceil(time / 1000)}s`; }, - slideChange() { + slideChange(swiper) { setTimeout(() => { - element - .querySelectorAll('.swiper-slide-active .animated-number') - .forEach((counter: HTMLElement) => { - animateNumber(counter, {}); - }); - element - .querySelectorAll('.swiper-slide-active .animated-time') - .forEach((counter: HTMLElement) => { - animateNumber(counter, { duration: 1000, render: formatDuration }); - }); - element - .querySelectorAll('.swiper-slide-active .animated-pulse') - .forEach((counter: HTMLElement) => { - counter.classList.remove('animated-pulse'); - setTimeout(() => { - counter.classList.add('animated-pulse'); - }, 100); - }); - element.querySelectorAll('.swiper-slide-active .lpv').forEach((el: HTMLElement) => { - const lpv = get(el, 'lpv')!; - lpv.goTo('first'); - clearTimeout(lpvTimer); - const next = () => { - if (!lpv.canGoTo('next')) return; - lpvTimer = setTimeout(() => { - lpv.goTo('next'); - next(); - }, 500); - }; - next(); - }); + const slide = element.querySelector('.swiper-slide-active'); + if (slide) { + if (swiper.isEnd) swiper.autoplay?.stop(); + onSlideChange(slide as HTMLElement); + if (swiper.autoplay?.paused) swiper.autoplay?.resume(); + } }, 200); }, }, }; - new Swiper(element, options); + const swiper = ((window as any).s = new Swiper(element, options)); + $(element).on('click', () => { + if (swiper.autoplay && !swiper.isEnd) { + if (swiper.autoplay.paused) swiper.autoplay.resume(); + else swiper.autoplay.pause(); + } + }); }; + +let lpvTimer: number | undefined; + +function onSlideChange(slide: HTMLElement) { + slide.querySelectorAll('.animated-number').forEach((counter: HTMLElement) => { + animateNumber(counter, {}); + }); + slide.querySelectorAll('.animated-time').forEach((counter: HTMLElement) => { + animateNumber(counter, { duration: 1000, render: formatDuration }); + }); + slide.querySelectorAll('.animated-pulse').forEach((counter: HTMLElement) => { + counter.classList.remove('animated-pulse'); + setTimeout(() => { + counter.classList.add('animated-pulse'); + }, 100); + }); + slide.querySelectorAll('.lpv').forEach((el: HTMLElement) => { + const lpv = get(el, 'lpv')!; + lpv.goTo('first'); + clearTimeout(lpvTimer); + const next = () => { + if (!lpv.canGoTo('next')) return; + lpvTimer = setTimeout(() => { + lpv.goTo('next'); + next(); + }, 500); + }; + next(); + }); +} diff --git a/ui/site/src/domHandlers.ts b/ui/site/src/domHandlers.ts index 93d11b8ae45fa..966b699ffaf42 100644 --- a/ui/site/src/domHandlers.ts +++ b/ui/site/src/domHandlers.ts @@ -46,15 +46,19 @@ export function attachDomHandlers() { else $(this).one('focus', start); }); - $('.yes-no-confirm, .ok-cancel-confirm').on('click', async function (this: HTMLElement, e: Event) { - if (!e.isTrusted) return; - e.preventDefault(); - const [confirmText, cancelText] = this.classList.contains('yes-no-confirm') - ? [i18n.site.yes, i18n.site.no] - : [i18n.site.ok, i18n.site.cancel]; - if (await confirm(this.title || 'Confirm this action?', confirmText, cancelText)) - (e.target as HTMLElement)?.click(); - }); + $('#main-wrap').on( + 'click', + '.yes-no-confirm, .ok-cancel-confirm', + async function (this: HTMLElement, e: Event) { + if (!e.isTrusted) return; + e.preventDefault(); + const [confirmText, cancelText] = this.classList.contains('yes-no-confirm') + ? [i18n.site.yes, i18n.site.no] + : [i18n.site.ok, i18n.site.cancel]; + if (await confirm(this.title || 'Confirm this action?', confirmText, cancelText)) + (e.target as HTMLElement)?.click(); + }, + ); $('#main-wrap').on('click', 'a.bookmark', function (this: HTMLAnchorElement) { const t = $(this).toggleClass('bookmarked');