diff --git a/libs/angular/src/tools/achievements/achievement-card.component.html b/libs/angular/src/tools/achievements/achievement-card.component.html index 0aadfd08473..1a41bc5f160 100644 --- a/libs/angular/src/tools/achievements/achievement-card.component.html +++ b/libs/angular/src/tools/achievements/achievement-card.component.html @@ -7,4 +7,7 @@ @if (earned()) {

{{ date() }}

} + @if (goal() > 0) { +

Progress: {{ progress() }} of {{ goal() }}

+ } diff --git a/libs/angular/src/tools/achievements/achievement-card.component.ts b/libs/angular/src/tools/achievements/achievement-card.component.ts index 21427223f13..88ac2762464 100644 --- a/libs/angular/src/tools/achievements/achievement-card.component.ts +++ b/libs/angular/src/tools/achievements/achievement-card.component.ts @@ -25,6 +25,7 @@ export class AchievementCard { earned = input(false); progress = input(0); + goal = input(-1); date = input(); protected cardClass: string; diff --git a/libs/angular/src/tools/achievements/achievement-item.component.html b/libs/angular/src/tools/achievements/achievement-item.component.html index 8f2ec4e0957..e197580f046 100644 --- a/libs/angular/src/tools/achievements/achievement-item.component.html +++ b/libs/angular/src/tools/achievements/achievement-item.component.html @@ -7,6 +7,8 @@ {{ description() }} @if (earned()) {

Earned: {{ date() | date: "medium" }}

+ } @else if (goal() > 0) { +

Progress: {{ progress() }} of {{ goal() }}

} @else if (progress() > 0) {

Progress: {{ progress() }}

} diff --git a/libs/angular/src/tools/achievements/achievement-item.component.ts b/libs/angular/src/tools/achievements/achievement-item.component.ts index 46743fe2bc0..4c5bc33ad76 100644 --- a/libs/angular/src/tools/achievements/achievement-item.component.ts +++ b/libs/angular/src/tools/achievements/achievement-item.component.ts @@ -26,6 +26,7 @@ export class AchievementItem { earned = input(false); progress = input(0); + goal = input(-1); date = input(); protected bgColorClass: string = ""; diff --git a/libs/angular/src/tools/achievements/achievements-list.component.html b/libs/angular/src/tools/achievements/achievements-list.component.html index 1715e8d4836..3eed9b204da 100644 --- a/libs/angular/src/tools/achievements/achievements-list.component.html +++ b/libs/angular/src/tools/achievements/achievements-list.component.html @@ -15,6 +15,7 @@ [earned]="isEarned(achievement)" [date]="earnedDate(achievement)" [progress]="progress(achievement)" + [goal]="goal(achievement)" > } diff --git a/libs/angular/src/tools/achievements/achievements-list.component.ts b/libs/angular/src/tools/achievements/achievements-list.component.ts index becfe59a477..0767a461c64 100644 --- a/libs/angular/src/tools/achievements/achievements-list.component.ts +++ b/libs/angular/src/tools/achievements/achievements-list.component.ts @@ -45,6 +45,7 @@ import { iconMap } from "./icons/icon-map"; }) export class AchievementsListComponent { protected achievements: Array; + private _active: Set = new Set(); private _earned: Map = new Map(); private _progress: Map = new Map(); @@ -55,21 +56,30 @@ export class AchievementsListComponent { ) { this.achievements = Array.from(achievementService.achievementMap().values()); - this.accountService.activeAccount$ + const account$ = this.accountService.activeAccount$.pipe( + filter((account): account is Account => !!account), + ); + + account$ .pipe( - filter((account): account is Account => !!account), switchMap((account) => this.achievementService.earnedMap$(account)), takeUntilDestroyed(), ) .subscribe((earned) => zone.run(() => (this._earned = earned))); - this.accountService.activeAccount$ + account$ .pipe( - filter((account): account is Account => !!account), switchMap((account) => this.achievementService.metricsMap$(account)), takeUntilDestroyed(), ) .subscribe((progress) => zone.run(() => (this._progress = progress))); + + account$ + .pipe( + switchMap((account) => this.achievementService.active$(account)), + takeUntilDestroyed(), + ) + .subscribe((active) => zone.run(() => (this._active = active))); } protected isEarned(achievement: Achievement) { @@ -81,13 +91,21 @@ export class AchievementsListComponent { } protected progress(achievement: Achievement) { - if (achievement.active === "until-earned") { + if (achievement.active === "until-earned" || this._earned.has(achievement.achievement)) { return -1; } return this._progress.get(achievement.active.metric)?.achievement?.value ?? -1; } + protected goal(achievement: Achievement) { + if (achievement.active === "until-earned" || !this._active.has(achievement.achievement)) { + return -1; + } + + return this._progress.get(achievement.active.metric)?.achievement?.goal ?? -1; + } + protected icon(achievement: Achievement): Icon { return (iconMap[achievement.achievement] as Icon) ?? AchievementIcon; } diff --git a/libs/common/src/tools/achievements/achievement-events.ts b/libs/common/src/tools/achievements/achievement-events.ts index 20ab5b08745..159f100630c 100644 --- a/libs/common/src/tools/achievements/achievement-events.ts +++ b/libs/common/src/tools/achievements/achievement-events.ts @@ -3,14 +3,18 @@ import { UserId } from "../../types/guid"; import { AchievementEarnedEvent, AchievementId, AchievementProgressEvent, MetricId } from "./types"; // FIXME: see <./types.ts> AchievementValidator -export function progressEvent(name: MetricId, value: number = 1): AchievementProgressEvent { +export function progressEvent( + name: MetricId, + value: number = 1, + goal: number | undefined = undefined, +): AchievementProgressEvent { return { "@timestamp": Date.now(), event: { kind: "metric", category: "session", }, - achievement: { type: "progress", name, value }, + achievement: { type: "progress", name, value, goal }, service: { name: "extension", type: "client", diff --git a/libs/common/src/tools/achievements/achievement-hub.ts b/libs/common/src/tools/achievements/achievement-hub.ts index c34baa7e113..837b55646d0 100644 --- a/libs/common/src/tools/achievements/achievement-hub.ts +++ b/libs/common/src/tools/achievements/achievement-hub.ts @@ -27,6 +27,10 @@ import { } from "./types"; export class AchievementHub { + private readonly validators = new ReplaySubject(1); + private readonly achievements = new Subject(); + private readonly achievementLog: ReplaySubject; + /** Instantiates the achievement hub. A new achievement hub should be created * per-user, and streams should be partitioned by user. * @param validators$ emits the most recent achievement validator list and @@ -48,7 +52,6 @@ export class AchievementHub { bufferSize: number = 1000, private log: SemanticLogger = disabledSemanticLoggerProvider({}), ) { - this.achievements = new Subject(); this.achievementLog = new ReplaySubject(bufferSize); this.achievements.subscribe(this.achievementLog); @@ -57,18 +60,19 @@ export class AchievementHub { shareReplay({ bufferSize: 1, refCount: true }), ); const earned$ = this.earned$().pipe(map((m) => new Set(m.keys()))); - const active$ = validators$.pipe(active(metrics$, earned$)); + validators$.pipe(active(metrics$, earned$)).subscribe(this.validators); // TODO: figure out how to to unsubscribe from the event stream; // this likely requires accepting an account-bound observable, which // would also let the hub maintain it's "one user" invariant. - concat(achievements$, events$.pipe(achievements(active$, metrics$))).subscribe( + concat(achievements$, events$.pipe(achievements(this.validators, metrics$))).subscribe( this.achievements, ); } - private readonly achievements: Subject; - private readonly achievementLog: ReplaySubject; + active$(): Observable> { + return this.validators.pipe(map((validators) => new Set(validators.map((v) => v.achievement)))); + } /** emit all achievement events */ all$(): Observable { diff --git a/libs/common/src/tools/achievements/achievement.service.abstraction.ts b/libs/common/src/tools/achievements/achievement.service.abstraction.ts index ab0bf09df32..4c44f46d9f2 100644 --- a/libs/common/src/tools/achievements/achievement.service.abstraction.ts +++ b/libs/common/src/tools/achievements/achievement.service.abstraction.ts @@ -11,6 +11,8 @@ import { } from "./types"; export abstract class AchievementService { + abstract active$: (account: Account) => Observable>; + abstract achievementMap: () => Map; abstract earnedStream$: (account: Account, all?: boolean) => Observable; diff --git a/libs/common/src/tools/achievements/default-achievement.service.ts b/libs/common/src/tools/achievements/default-achievement.service.ts index bf54a479779..e95aa1fa45a 100644 --- a/libs/common/src/tools/achievements/default-achievement.service.ts +++ b/libs/common/src/tools/achievements/default-achievement.service.ts @@ -40,6 +40,10 @@ export class DefaultAchievementService implements AchievementService { ...ItemCreatedCountConfig.AllConfigs, ]; + active$(account: Account) { + return this.getHub(account).active$(); + } + achievementMap() { return new Map(this._achievements.map((a) => [a.achievement, a] as const)); } diff --git a/libs/common/src/tools/achievements/examples/example-validators.ts b/libs/common/src/tools/achievements/examples/example-validators.ts index 82319bc5a71..ec61588bdca 100644 --- a/libs/common/src/tools/achievements/examples/example-validators.ts +++ b/libs/common/src/tools/achievements/examples/example-validators.ts @@ -96,7 +96,7 @@ const ThreeItemsCreatedValidator = { }, measure(_item, progress) { const value = 1 + (progress.get(ItemCreatedProgress) ?? 0); - return [progressEvent(ItemCreatedProgress, value)]; + return [progressEvent(ItemCreatedProgress, value, 3)]; }, award(_measured, progress) { const value = progress.get(ItemCreatedProgress); @@ -116,7 +116,7 @@ const FiveItemsCreatedValidator = { }, measure(_item, progress) { const value = 1 + (progress.get(ItemCreatedProgress) ?? 0); - return [progressEvent(ItemCreatedProgress, value)]; + return [progressEvent(ItemCreatedProgress, value, 5)]; }, award(_measured, progress) { const value = progress.get(ItemCreatedProgress); diff --git a/libs/common/src/tools/achievements/types.ts b/libs/common/src/tools/achievements/types.ts index 97f5815d71f..875bce111e8 100644 --- a/libs/common/src/tools/achievements/types.ts +++ b/libs/common/src/tools/achievements/types.ts @@ -14,7 +14,7 @@ export type UserActionEvent = EventFormat & UserFormat & ServiceFormat; export type AchievementProgressEvent = EventFormat & ServiceFormat & - UserFormat & { achievement: { type: "progress"; name: MetricId; value: number } }; + UserFormat & { achievement: { type: "progress"; name: MetricId; value: number; goal?: number } }; export type AchievementEarnedEvent = EventFormat & ServiceFormat & UserFormat & { achievement: { type: "earned"; name: AchievementId } };