1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

factor measurement and achievement earning into separate phases

This commit is contained in:
✨ Audrey ✨
2025-03-11 18:23:35 -04:00
parent 9fca08ac20
commit 9a9e72f483
4 changed files with 52 additions and 48 deletions

View File

@@ -10,6 +10,12 @@ import {
import { isEarnedEvent, isProgressEvent } from "./meta";
import { AchievementEvent, AchievementValidator } from "./types";
function mapProgressByName(status: AchievementEvent[]) {
return new Map(
status.filter(isProgressEvent).map((e) => [e.achievement.name, e.achievement.value] as const),
);
}
// OPTIMIZATION: compute the list of active monitors from trigger criteria
function active(
status$: Observable<AchievementEvent[]>,
@@ -18,9 +24,7 @@ function active(
withLatestFrom(status$),
map(([monitors, log]) => {
// partition the log into progress and earned achievements
const progressByName = new Map(
log.filter(isProgressEvent).map((e) => [e.achievement.name, e.achievement.value]),
);
const progressByName = mapProgressByName(log);
const earnedByName = new Set(
log.filter((e) => isEarnedEvent(e)).map((e) => e.achievement.name),
);
@@ -36,7 +40,7 @@ function active(
}
// monitor disabled if outside of threshold
const progress = progressByName.get(m.progress) ?? 0;
const progress = (m.metric && progressByName.get(m.metric)) || 0;
if (progress > (m.trigger.high ?? Number.POSITIVE_INFINITY)) {
return false;
} else if (progress < (m.trigger.low ?? 0)) {
@@ -70,10 +74,17 @@ function validate(
// process achievement monitors sequentially, accumulating result records
for (const monitor of monitors) {
const statusEntry = status.find((s) => s.achievement.name === monitor.achievement);
const progress = isProgressEvent(statusEntry) ? statusEntry : undefined;
const progress = mapProgressByName(status);
results.push(...monitor.action(action, progress));
const measurements = monitor.measure(action, progress);
results.push(...measurements);
// modify copy produced by filter to avoid reallocation
for (const m of measurements) {
progress.set(m.achievement.name, m.achievement.value);
}
results.push(...monitor.earn(progress));
}
// deliver results as a stream containing individual records to maintain

View File

@@ -1,16 +1,8 @@
import { UserId } from "../../types/guid";
import {
AchievementEarnedEvent,
AchievementId,
AchievementProgressEvent,
AchievementProgressId,
} from "./types";
import { AchievementEarnedEvent, AchievementId, AchievementProgressEvent, MetricId } from "./types";
export function progressEvent(
name: AchievementProgressId,
value: number = 1,
): AchievementProgressEvent {
export function progressEvent(name: MetricId, value: number = 1): AchievementProgressEvent {
return {
"@timestamp": Date.now(),
event: {

View File

@@ -1,8 +1,8 @@
import { Type } from "./data";
import { earnedEvent, progressEvent } from "./events";
import { AchievementId, AchievementProgressId, AchievementValidator } from "./types";
import { AchievementId, MetricId, AchievementValidator } from "./types";
const ItemCreatedProgress = "item-created-progress" as AchievementProgressId;
const ItemCreatedProgress = "item-quantity" as MetricId;
const ItemCreatedAchievement = "item-created" as AchievementId;
const ThreeItemsCreatedAchievement = "three-vault-items-created" as AchievementId;
@@ -10,58 +10,57 @@ const FiveItemsCreatedAchievement = "five-vault-items-created" as AchievementId;
const ItemCreatedValidator = {
achievement: ItemCreatedAchievement,
progress: ItemCreatedProgress,
metric: ItemCreatedProgress,
evaluator: Type.Threshold,
trigger: "once",
hidden: false,
filter(item) {
return item.action === "vault-item-added";
},
action(item, progress) {
return [progressEvent(ItemCreatedProgress), earnedEvent(ItemCreatedAchievement)];
measure(item, progress) {
return [progressEvent(ItemCreatedProgress)];
},
earn(progress) {
return [earnedEvent(ItemCreatedAchievement)];
},
} satisfies AchievementValidator;
const ThreeItemsCreatedValidator = {
achievement: ThreeItemsCreatedAchievement,
progress: ItemCreatedProgress,
metric: ItemCreatedProgress,
evaluator: Type.Threshold,
trigger: { low: 2, high: 3 },
hidden: false,
filter(item) {
return item.action === "vault-item-added";
},
action(item, progress) {
if (!progress) {
return [progressEvent(ItemCreatedProgress)];
}
const value = progress.achievement.value + 1;
if (value >= 3) {
return [earnedEvent(ThreeItemsCreatedAchievement)];
}
measure(_item, progress) {
const value = 1 + (progress.get(ItemCreatedProgress) ?? 0);
return [progressEvent(ItemCreatedProgress, value)];
},
earn(progress) {
const value = progress.get(ItemCreatedProgress) ?? 0;
return value >= 3 ? [earnedEvent(ItemCreatedAchievement)] : [];
},
} satisfies AchievementValidator;
const FiveItemsCreatedValidator = {
achievement: ThreeItemsCreatedAchievement,
progress: ItemCreatedProgress,
metric: ItemCreatedProgress,
evaluator: Type.Threshold,
trigger: { low: 4, high: 5 },
hidden: false,
filter(item) {
return item.action === "vault-item-added";
},
action(item, progress) {
const value = 1 + (progress?.achievement?.value ?? 0);
if (value >= 3) {
return [earnedEvent(FiveItemsCreatedAchievement)];
}
measure(_item, progress) {
const value = 1 + (progress.get(ItemCreatedProgress) ?? 0);
return [progressEvent(ItemCreatedProgress, value)];
},
earn(progress) {
const value = progress.get(ItemCreatedProgress) ?? 0;
return value >= 5 ? [earnedEvent(ItemCreatedAchievement)] : [];
},
} satisfies AchievementValidator;
export {

View File

@@ -7,11 +7,11 @@ import { Type } from "./data";
export type EvaluatorType = keyof typeof Type;
export type AchievementId = string & Tagged<"achievement">;
export type AchievementProgressId = string & Tagged<"achievement-progress">;
export type MetricId = string & Tagged<"metric-id">;
export type AchievementProgressEvent = EventFormat &
ServiceFormat &
UserFormat & { achievement: { type: "progress"; name: AchievementProgressId; value: number } };
UserFormat & { achievement: { type: "progress"; name: MetricId; value: number } };
export type AchievementEarnedEvent = EventFormat &
ServiceFormat &
UserFormat & { achievement: { type: "earned"; name: AchievementId } };
@@ -20,7 +20,9 @@ export type AchievementEvent = AchievementProgressEvent | AchievementEarnedEvent
// consumed by validator and achievement list (should this include a "toast-alerter"?)
export type Achievement = {
achievement: AchievementId;
progress: AchievementProgressId;
metric?: MetricId;
evaluator: EvaluatorType;
// pre-filter that disables the rule if it's met
@@ -34,9 +36,9 @@ export type AchievementValidator = Achievement & {
// when the watch triggers on incoming user events
filter: (item: EventFormat) => boolean;
// what to do when an incoming event is triggered
action: (
item: EventFormat,
progress?: AchievementProgressEvent,
) => [AchievementEvent] | [AchievementEvent, AchievementEvent];
// observe data from the event stream and produces measurements
measure: (item: EventFormat, progress: Map<MetricId, number>) => AchievementProgressEvent[];
// monitors achievement progress and emits earned achievements
earn: (progress: Map<MetricId, number>) => AchievementEarnedEvent[];
};