mirror of
https://github.com/bitwarden/browser
synced 2026-02-11 22:13:32 +00:00
add progress tracking to achievement list
This commit is contained in:
@@ -7,4 +7,7 @@
|
||||
@if (earned()) {
|
||||
<h3 bitTypography="body2" class="tw-text-center">{{ date() }}</h3>
|
||||
}
|
||||
@if (goal() > 0) {
|
||||
<h3 bitTypography="body2" class="tw-text-center">Progress: {{ progress() }} of {{ goal() }}</h3>
|
||||
}
|
||||
</bit-card>
|
||||
|
||||
@@ -25,6 +25,7 @@ export class AchievementCard {
|
||||
|
||||
earned = input<boolean>(false);
|
||||
progress = input<number>(0);
|
||||
goal = input<number>(-1);
|
||||
date = input<Date>();
|
||||
|
||||
protected cardClass: string;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<span slot="secondary">{{ description() }}</span>
|
||||
@if (earned()) {
|
||||
<p slot="secondary">Earned: {{ date() | date: "medium" }}</p>
|
||||
} @else if (goal() > 0) {
|
||||
<p slot="secondary">Progress: {{ progress() }} of {{ goal() }}</p>
|
||||
} @else if (progress() > 0) {
|
||||
<p slot="secondary">Progress: {{ progress() }}</p>
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export class AchievementItem {
|
||||
|
||||
earned = input<boolean>(false);
|
||||
progress = input<number>(0);
|
||||
goal = input<number>(-1);
|
||||
date = input<Date>();
|
||||
|
||||
protected bgColorClass: string = "";
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[earned]="isEarned(achievement)"
|
||||
[date]="earnedDate(achievement)"
|
||||
[progress]="progress(achievement)"
|
||||
[goal]="goal(achievement)"
|
||||
></achievement-item>
|
||||
}
|
||||
</bit-item-group>
|
||||
|
||||
@@ -45,6 +45,7 @@ import { iconMap } from "./icons/icon-map";
|
||||
})
|
||||
export class AchievementsListComponent {
|
||||
protected achievements: Array<Achievement>;
|
||||
private _active: Set<AchievementId> = new Set();
|
||||
private _earned: Map<AchievementId, AchievementEarnedEvent> = new Map();
|
||||
private _progress: Map<MetricId, AchievementProgressEvent> = 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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
} from "./types";
|
||||
|
||||
export class AchievementHub {
|
||||
private readonly validators = new ReplaySubject<AchievementValidator[]>(1);
|
||||
private readonly achievements = new Subject<AchievementEvent>();
|
||||
private readonly achievementLog: ReplaySubject<AchievementEvent>;
|
||||
|
||||
/** 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<AchievementEvent>();
|
||||
this.achievementLog = new ReplaySubject<AchievementEvent>(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<AchievementEvent>;
|
||||
private readonly achievementLog: ReplaySubject<AchievementEvent>;
|
||||
active$(): Observable<Set<AchievementId>> {
|
||||
return this.validators.pipe(map((validators) => new Set(validators.map((v) => v.achievement))));
|
||||
}
|
||||
|
||||
/** emit all achievement events */
|
||||
all$(): Observable<AchievementEvent> {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from "./types";
|
||||
|
||||
export abstract class AchievementService {
|
||||
abstract active$: (account: Account) => Observable<Set<AchievementId>>;
|
||||
|
||||
abstract achievementMap: () => Map<AchievementId, Achievement>;
|
||||
|
||||
abstract earnedStream$: (account: Account, all?: boolean) => Observable<AchievementEarnedEvent>;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user