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 } };