diff --git a/docs/adaptive-gamification/indicators-engine.md b/docs/adaptive-gamification/indicators-engine.md
index 90eb313..1de450d 100644
--- a/docs/adaptive-gamification/indicators-engine.md
+++ b/docs/adaptive-gamification/indicators-engine.md
@@ -46,8 +46,8 @@ flowchart TB
end
subgraph Ingestion["Aggregation & Context Layer"]
- GIS["GamificationIndicatorsService
(Date parsing & period partitioning)"]
- CTX["IndicatorComputationContext
(badges, players, checkins, periods)"]
+ GIS["GamificationIndicatorsService
(Date & threshold validation)"]
+ CTX["IndicatorComputationContext
(badges, players, checkins, threshold)"]
end
subgraph Strategy["Pluggable Strategy Engine"]
@@ -55,13 +55,13 @@ flowchart TB
VBS["VanishingBadgesStrategy
(Pure Mathematical Implementation)"]
end
- subgraph Metrics["Calculated Indicators"]
- ET["ET_b: Estimated Awarding Time"]
- I3["i_3: Individual Player Recency"]
- CII["CII: Community Interest Indicator"]
- PMI["PMI / relPMI: Player Motivation"]
- CMI["CMI: Community Motivation Indicator"]
- CAND["Adaptation Candidate (Lowest CII)"]
+ subgraph Metrics["Calculated Indicators (Sept 18 Revision)"]
+ AB["AB(p): Achievable Badges [Def 3.1]"]
+ I3["i_3(p, b): Individual Player Interest [Def 3.2]"]
+ IGN["ignored_by(p): Ignored Badges [Def 3.3]"]
+ CII["CII(b): Community Interest Indicator [Def 3.4]"]
+ FILT["Candidate Pool Filtering [§4.2.1]"]
+ TRIG["Adaptation Trigger & Candidate [§4.1-4.2]"]
end
subgraph Output["Output Response DTOs"]
@@ -85,30 +85,29 @@ flowchart TB
| Rayuela Domain Entity / DAO | Input Role in Engine | Output Indicator |
| :--- | :--- | :--- |
| **`Project`** | Establishes the bounded community and context identifier. | `projectId` |
-| **`BadgeRule` (`GamificationDao`)** | Defines target requirements, current badge status (`active`, `faded`, `expired`), and prerequisite DAG relationships. | $ep(b)$ (eligible pool), Prerequisite chains |
+| **`BadgeRule` (`GamificationDao`)** | Defines target requirements, current badge status (`active`, `faded`, `expired`), and prerequisite DAG relationships. | $AB(p)$, $ep(b)$ (eligible pool), Candidate filtering (`allPlayerBadges`, `unreachableBadges`, `candidateBadges`) |
| **`User` (`UserDao`)** | Supplies player join dates ($t_{\text{join}}$) and registered account profiles. | $t_0(p, b)$ (baseline date for root badges) |
-| **`Checkin` (`CheckInDao`)** | Provides timestamped citizen science contributions, partitioned into consecutive calendar periods $s_1, s_2, \dots, s_n$. | $cnum(p, s)$, $PMI(p)$, $relPMI(p)$, $CMI$ |
-| **`Move` (`MoveDao`)** | Records historical award events, tracking the exact contributions and timestamps at which players unlocked badges. | $U_b$ (earners set), $ET_b$, $t_0(p, b)$ for child badges |
+| **`Checkin` (`CheckInDao`)** | Provides timestamped citizen science contributions. | `totalContributions`, `activePlayers` |
+| **`Move` (`MoveDao`)** | Records historical award events, tracking the exact timestamps at which players unlocked badges. | $B_p$, $U_b$ (earners set), $t_0(p, b)$ for child badges |
---
-## 3. Mathematical Indicators Summary
+## 3. Mathematical Indicators Summary (Sept 18, 2026 Revision)
-The default implementation (`VanishingBadgesStrategy`) evaluates:
+The default implementation (`VanishingBadgesStrategy`) evaluates Definitions 3.1–3.4 and Section 4.1–4.2 of the Vanishing Badges specification:
-1. **Estimated Awarding Time ($ET_b$)**: Historical average check-ins required by players who earned badge $b$. Falls back to immediate prerequisite thresholds during cold starts.
-2. **Individual Recency Interest ($i_3(p, b)$)**: Measures how recently player $p$ became eligible for badge $b$:
- $$\text{recency}(p, b) = \text{asOfDate} - t_0(p, b)$$
- $$i_3(p, b) = \frac{1}{\text{recency}(p, b) + 1}$$
- If badge $b$ is unachievable for player $p$ (unmet prerequisites), $i_3(p, b) = 1.0$.
-3. **Community Interest Indicator ($CII(b)$)**: The median individual interest across all players eligible to earn badge $b$ ($ep(b)$):
- $$CII(b) = \text{median}(\{i_3(p, b) : p \in ep(b)\})$$
- A declining $CII$ signals that eligible players are stalling and losing interest in badge $b$.
-4. **Player Motivation Indicator ($PMI(p)$)**: The number of periods where player $p$ sustained or increased contributions compared to the previous period:
- $$cnum(p, s) \ge cnum(p, \text{prev}(s)) \land (cnum(p, s) + cnum(p, \text{prev}(s)) > 0)$$
-5. **Community Motivation Indicator ($CMI$)**: The median relative motivation across all active contributors:
- $$\overline{PMI} = \frac{1}{|P|} \sum_{p \in P} PMI(p), \quad relPMI(p) = \frac{PMI(p)}{\overline{PMI}}, \quad CMI = \text{median}(\{relPMI(p) : p \in P\})$$
-6. **Adaptation Candidate Badge ($b^*$)**: Identifies the active badge with the lowest $CII$, representing the primary bottleneck where community interest has waned the most.
+1. **Achievable Badges ($AB(p)$ — Def 3.1)**: A badge is achievable to a player when all prerequisite badges (`previousBadges`) have been earned:
+ $$AB(p) = \{\, b \in \text{Badges} \mid \forall prev \in \text{prerequisites}(b),\, prev \in B_p \,\}$$
+2. **Individual Interest Indicator ($i_3(p, b)$ — Def 3.2)**: Measures the inverse of the elapsed time (in days) since player $p$ met the prerequisites for badge $b$ ($t_0(p, b)$):
+ $$i_3(p, b) = \begin{cases} \dfrac{1}{\text{now} - t_0(p, b)} & : b \in AB(p) \\ 1.0 & : b \notin AB(p) \end{cases}$$
+3. **Ignored Badges ($\text{ignored\_by}(p)$ — Def 3.3)**: The subset of achievable, unearned badges for player $p$ whose individual interest has fallen below reference threshold $x$ (default $x = 0.20$, equivalent to $> 5$ days elapsed):
+ $$\text{ignored\_by}(p) = \{\, b \in AB(p) \setminus B_p \mid i_3(p, b) < x \,\}$$
+4. **Community Interest Indicator ($CII(b)$ — Def 3.4)**: The median individual interest across all players eligible to earn badge $b$ ($ep(b) = \{\, p \in P \mid b \in AB(p) \setminus B_p \,\}$):
+ $$CII(b) = \text{median}(\{\, i_3(p, b) \mid p \in ep(b) \,\})$$
+5. **Candidate Filtering (§4.2.1)**: Excludes badges already earned by everyone in the community (`allPlayerBadges`), badges unreachable by anyone (`unreachableBadges`), and expired badges to yield `candidateBadges`.
+6. **Adaptation Trigger & Selection (§4.1 & §4.2.2–4.2.3)**:
+ $$\text{Trigger Adaptation} \iff \exists b \in \text{Candidates} : CII(b) < x$$
+ Candidate badges are sorted ascending by $CII(b)$ to identify the primary `adaptationCandidateBadge`.
---
@@ -117,20 +116,20 @@ The default implementation (`VanishingBadgesStrategy`) evaluates:
### Current State: On-Demand REST Endpoint
The engine is currently exposed via an authenticated HTTP endpoint:
```http
-GET /v1/gamification-indicators/:projectId?startDate=01-07-2026&asOfDate=20-07-2026&daysPerPeriod=7
+GET /v1/gamification-indicators/:projectId?asOfDate=20-07-2026&threshold=0.20
Authorization: Bearer
```
This allows:
-- Real-time exploration and parameter tuning (`daysPerPeriod`, custom `startDate`, point-in-time `asOfDate`).
+- Real-time exploration and parameter tuning (`threshold`, point-in-time `asOfDate`, `minActiveCheckins`).
- Visual debugging through documentation tools and simulators.
-- Empirical verification of community health before enabling automated rule mutations.
+- Empirical verification of community interest before enabling automated rule mutations.
### Future State: Automated Badge Fading Adaptation Loop
In upcoming phases, this engine will directly drive the **Badge Fading** adaptation strategy:
-1. A recurring cron worker periodically runs `calculateIndicators` for active projects.
-2. If community motivation drops below threshold ($CMI < \tau_{\text{trigger}}$), the adaptation pipeline is triggered.
+1. Periodically or upon check-in registration, the system runs `calculateIndicators` for active projects.
+2. If any candidate badge's community interest drops below threshold ($\exists b \in \text{Candidates} : CII(b) < x$), `isTriggered` becomes `true`.
3. The engine selects the candidate badge with lowest interest ($b^* = \arg\min CII(b)$).
-4. The system automatically transitions badge $b^*$ to `faded` status with an expiration timestamp (`expiresAt`), broadcasting notifications to prompt community participation.
+4. The system automatically transitions badge $b^*$ to `faded` status with an expiration timestamp (`expiresAt`), broadcasting notifications to prompt community participation during the Step 5 running impact window.
---
@@ -142,35 +141,39 @@ In upcoming phases, this engine will directly drive the **Badge Fading** adaptat
| Parameter | Type | Required | Default | Description |
| :--- | :--- | :--- | :--- | :--- |
-| `startDate` | string | No | Earliest checkin | Timeline origin for Period 1. Accepts ISO-8601, `DD-MM-YYYY`, or `DD/MM/YYYY`. |
| `asOfDate` | string | No | Now | Horizon timestamp. Accepts ISO-8601, `DD-MM-YYYY`, or `DD/MM/YYYY`. |
-| `daysPerPeriod` | number | No | `7` | Duration of each period $s$ in days (default: weekly). |
-| `minActiveCheckins` | number | No | `1` | Minimum contributions required to qualify as an active player. |
+| `threshold` | number | No | `0.2` | Reference threshold $x$ for ignored badges (Def 3.3) and adaptation trigger (§4.1). |
+| `minActiveCheckins` | number | No | `0` | Optional minimum contributions required to include a player in the evaluated pool $P$. |
#### Sample Response (`200 OK`)
```json
{
"projectId": "67702f23258db9ef444b0e8b",
- "currentPeriod": 3,
- "startDate": "2026-07-01T00:00:00.000Z",
"asOfDate": "2026-07-20T23:59:59.999Z",
- "daysPerPeriod": 7,
- "totalPlayers": 12,
+ "threshold": 0.2,
+ "totalPlayers": 10,
"activePlayers": 8,
"totalContributions": 45,
- "avgPMI": 1.5,
- "CMI": 1.0,
+ "isTriggered": true,
+ "triggerBadges": ["b-expert-mapper"],
+ "communityIgnoredCount": 1,
+ "totalPlayerIgnored": 4,
+ "allPlayerBadges": ["b-first-checkin"],
+ "unreachableBadges": ["b-master-scientist"],
+ "candidateBadges": ["b-expert-mapper"],
+ "lowestCII": 0.1429,
"adaptationCandidateBadge": {
"badgeId": "b-expert-mapper",
"badgeName": "Mapeador Experto",
"status": "active",
"earnedCount": 1,
"earnedUsers": ["user_101"],
- "ET_b": 15.0,
"eligibleCount": 4,
"eligibleUsers": ["user_102", "user_103", "user_104", "user_105"],
- "CII": 0.0526,
+ "CII": 0.1429,
+ "isCommunityIgnored": true,
+ "isCandidate": true,
"isLowestCII": true
},
"badges": [
@@ -180,10 +183,11 @@ In upcoming phases, this engine will directly drive the **Badge Fading** adaptat
"status": "active",
"earnedCount": 1,
"earnedUsers": ["user_101"],
- "ET_b": 15.0,
"eligibleCount": 4,
"eligibleUsers": ["user_102", "user_103", "user_104", "user_105"],
- "CII": 0.0526,
+ "CII": 0.1429,
+ "isCommunityIgnored": true,
+ "isCandidate": true,
"isLowestCII": true
}
],
@@ -191,13 +195,14 @@ In upcoming phases, this engine will directly drive the **Badge Fading** adaptat
{
"playerId": "user_102",
"totalContributions": 7,
- "periodContributions": {
- "1": 2,
- "2": 2,
- "3": 3
- },
- "PMI": 2,
- "relPMI": 1.333
+ "earnedBadges": ["b-first-checkin"],
+ "achievableBadges": ["b-first-checkin", "b-expert-mapper"],
+ "ignoredBadges": ["b-expert-mapper"],
+ "individualInterest": {
+ "b-first-checkin": 1.0,
+ "b-expert-mapper": 0.1429,
+ "b-master-scientist": 1.0
+ }
}
]
}
diff --git a/src/module/gamification-indicators/domain/formula-strategy.interface.ts b/src/module/gamification-indicators/domain/formula-strategy.interface.ts
index b763c6b..9216e0c 100644
--- a/src/module/gamification-indicators/domain/formula-strategy.interface.ts
+++ b/src/module/gamification-indicators/domain/formula-strategy.interface.ts
@@ -22,16 +22,7 @@ export interface IndicatorFormulaStrategy {
): CommunityIndicatorResult;
/**
- * Definition 3.1: Estimated Awarding Time (ET_b).
- * Historical average of contributions at award time for players who earned badge b.
- */
- computeEstimatedAwardingTime(
- badge: BadgeDefinition,
- allBadges: BadgeDefinition[],
- earnedPlayers: { playerId: string; contribsAtEarn: number }[],
- ): number;
-
- /**
+ * Definition 3.1: Achievable Badges AB(p) & t_0(p, b).
* Evaluates if badge b is achievable by player p (all prerequisites met)
* and calculates t_0(p, b), the timestamp when badge b became achievable.
*/
@@ -43,7 +34,7 @@ export interface IndicatorFormulaStrategy {
): { isAchievable: boolean; t0Date: Date };
/**
- * Definition 3.2: Individual Interest Indicator i3(p, b).
+ * Definition 3.2: Individual Interest Indicator i_3(p, b).
* 1 / (now - t0) for achievable badges; 1.0 if not achievable.
*/
computeIndividualInterest(
@@ -55,29 +46,20 @@ export interface IndicatorFormulaStrategy {
): number;
/**
- * Definition 3.3: Community Interest Indicator CII(b).
- * Median of i3(p, b) across all eligible players ep(b).
- * Returns null if eligible pool is empty.
- */
- computeCommunityInterest(eligibleI3Values: number[]): number | null;
-
- /**
- * Definition 3.4: Player Motivation Indicator PMI(p).
- * Counts periods s in S where contributions grew or held steady with non-zero activity.
+ * Definition 3.3: Ignored Badges ignored_by(p).
+ * ignored_by(p) = { b in AB(p) - B_p : i_3(p, b) < x }
*/
- computePlayerMotivation(
- periodContributions: Record,
- currentPeriod: number,
- ): number;
+ computeIgnoredBadges(
+ achievableBadgeIds: string[],
+ earnedBadgeIds: Set,
+ playerI3Map: Record,
+ threshold: number,
+ ): string[];
/**
- * Definitions 3.4 & 3.5: Relative PMI and Community Motivation Indicator (CMI).
- * relPMI(p) = PMI(p) / avgPMI
- * CMI = median({ relPMI(p) : p in P })
+ * Definition 3.4: Community Interest Indicator CII(b).
+ * Median of i_3(p, b) across all eligible players ep(b) = { p in P : b in AB(p) - B_p }.
+ * Returns null if eligible pool is empty.
*/
- computeCommunityMotivation(playerPMIs: { playerId: string; pmi: number }[]): {
- avgPMI: number;
- playerRelPMIs: Record;
- CMI: number;
- };
+ computeCommunityInterest(eligibleI3Values: number[]): number | null;
}
diff --git a/src/module/gamification-indicators/domain/indicator.types.ts b/src/module/gamification-indicators/domain/indicator.types.ts
index f706471..84ba5c2 100644
--- a/src/module/gamification-indicators/domain/indicator.types.ts
+++ b/src/module/gamification-indicators/domain/indicator.types.ts
@@ -40,9 +40,10 @@ export interface IndicatorComputationContext {
badges: BadgeDefinition[];
players: PlayerProfile[];
checkins: CheckinRecord[];
- startDate?: Date;
asOfDate: Date;
- daysPerPeriod: number;
+ /** Threshold x for ignored badges (Def 3.3) and adaptation trigger (§4.1), default 0.20 */
+ threshold?: number;
+ /** Optional minimum check-ins required to include a player in the evaluated pool P */
minActiveCheckins?: number;
}
@@ -52,41 +53,57 @@ export interface BadgeIndicatorResult {
status: string;
earnedCount: number;
earnedUsers: string[];
- /** ET_b: Estimated Awarding Time (historical average contributions at award time) */
- ET_b: number;
- /** ep(b): Eligible players who can achieve b and have not yet earned it */
+ /** ep(b): Eligible players who can achieve b (b in AB(p)) and have not yet earned it */
eligibleCount: number;
eligibleUsers: string[];
- /** CII(b): Community Interest Indicator (median of i3 across eligible players) */
+ /** CII(b): Community Interest Indicator [Def 3.4] (median of i3 across eligible players) */
CII: number | null;
+ /** True when CII(b) < threshold x (§4.1) */
+ isCommunityIgnored: boolean;
+ /** True when badge is in the filtered candidate pool (§4.2.1) */
+ isCandidate: boolean;
isLowestCII: boolean;
}
export interface PlayerIndicatorResult {
playerId: string;
totalContributions: number;
- periodContributions: Record;
- /** PMI(p): Player Motivation Indicator (count of periods with positive/non-declining activity) */
- PMI: number;
- /** relPMI(p): Relative Player Motivation normalized to community average */
- relPMI: number;
+ /** B_p: Badge IDs already earned by player p */
+ earnedBadges: string[];
+ /** AB(p): Achievable badges for player p whose prerequisites are met [Def 3.1] */
+ achievableBadges: string[];
+ /** ignored_by(p): Achievable unearned badges with i3(p, b) < threshold x [Def 3.3] */
+ ignoredBadges: string[];
+ /** i_3(p, b): Individual interest score per badge ID [Def 3.2] */
+ individualInterest: Record;
}
export interface CommunityIndicatorResult {
projectId: string;
- currentPeriod: number;
- startDate: string;
asOfDate: string;
- daysPerPeriod: number;
+ /** Reference threshold x used for Def 3.3 and §4.1 */
+ threshold: number;
totalPlayers: number;
activePlayers: number;
totalContributions: number;
- /** Community average PMI across players */
- avgPMI: number;
- /** CMI: Community Motivation Indicator (median relPMI across players) */
- CMI: number;
+ /** §4.1 Trigger status: true if there exists a candidate badge b with CII(b) < x */
+ isTriggered: boolean;
+ /** Candidate badge IDs whose CII(b) < x (§4.1 / §4.2.3) */
+ triggerBadges: string[];
+ /** Number of badges ignored at the community level (CII(b) < x) */
+ communityIgnoredCount: number;
+ /** Sum of |ignored_by(p)| across all evaluated players */
+ totalPlayerIgnored: number;
+ /** §4.2.1 Badges earned by all evaluated players (assigned_badges) */
+ allPlayerBadges: string[];
+ /** §4.2.1 Badges unreachable by any evaluated player (unreachable_badges) */
+ unreachableBadges: string[];
+ /** §4.2.1 Filtered candidate badge IDs eligible for vanishing selection */
+ candidateBadges: string[];
+ /** Lowest CII value across badges with non-empty eligible pools */
+ lowestCII: number | null;
badges: BadgeIndicatorResult[];
players: PlayerIndicatorResult[];
- /** Lowest CII candidate badge recommended for fading if adaptation is triggered */
+ /** Lowest CII active candidate badge recommended for fading if adaptation is triggered */
adaptationCandidateBadge: BadgeIndicatorResult | null;
}
diff --git a/src/module/gamification-indicators/domain/vanishing-badges.strategy.spec.ts b/src/module/gamification-indicators/domain/vanishing-badges.strategy.spec.ts
index c4331c7..a625a20 100644
--- a/src/module/gamification-indicators/domain/vanishing-badges.strategy.spec.ts
+++ b/src/module/gamification-indicators/domain/vanishing-badges.strategy.spec.ts
@@ -50,7 +50,6 @@ describe('VanishingBadgesStrategy', () => {
players,
checkins: [],
asOfDate: baseDate,
- daysPerPeriod: 7,
};
const res = strategy.calculateIndicators(ctx);
@@ -58,54 +57,72 @@ describe('VanishingBadgesStrategy', () => {
expect(res.totalPlayers).toBe(2);
expect(res.activePlayers).toBe(0);
expect(res.totalContributions).toBe(0);
- expect(res.avgPMI).toBe(0);
- expect(res.CMI).toBe(0);
+ expect(res.threshold).toBe(0.2);
+ expect(res.isTriggered).toBe(false);
+ expect(res.triggerBadges).toEqual([]);
+ expect(res.allPlayerBadges).toEqual([]);
+ expect(res.unreachableBadges).toEqual(['b2', 'b3']);
+ expect(res.candidateBadges).toEqual(['b1']);
// Root badge b1 should be achievable for both players
const b1Metric = res.badges.find((b) => b.badgeId === 'b1')!;
expect(b1Metric.eligibleCount).toBe(2);
expect(b1Metric.earnedCount).toBe(0);
- // Cold start ET_b fallback: reqCheckins (5)
- expect(b1Metric.ET_b).toBe(5);
// Both players joined on baseDate so elapsed days is 1 -> i3 = 1.0 -> median CII = 1.0
expect(b1Metric.CII).toBe(1.0);
+ expect(b1Metric.isCandidate).toBe(true);
+ expect(b1Metric.isCommunityIgnored).toBe(false);
// b2 has prereq b1 which is unearned, so eligibleCount = 0 and CII = null
const b2Metric = res.badges.find((b) => b.badgeId === 'b2')!;
expect(b2Metric.eligibleCount).toBe(0);
expect(b2Metric.CII).toBeNull();
- // Fallback ET_b: reqCheckins(b2) + reqCheckins(b1) = 10 + 5 = 15
- expect(b2Metric.ET_b).toBe(15);
+ expect(b2Metric.isCandidate).toBe(false);
+
+ // Player p1 should have b1 in achievableBadges AB(p1) and empty ignoredBadges
+ const p1Res = res.players.find((p) => p.playerId === 'p1')!;
+ expect(p1Res.earnedBadges).toEqual([]);
+ expect(p1Res.achievableBadges).toEqual(['b1']);
+ expect(p1Res.ignoredBadges).toEqual([]);
+ expect(p1Res.individualInterest).toEqual({
+ b1: 1.0,
+ b2: 1.0,
+ b3: 1.0,
+ });
});
- it('should accurately compute PMI and CMI across periods with growing activity', () => {
+ it('should compute AB(p), ignored_by(p) [Def 3.3], and trigger adaptation [§4.1] when CII(b) < threshold x', () => {
const p1Join = new Date('2026-06-01T00:00:00.000Z');
- const players: PlayerProfile[] = [
- { id: 'p1', joinDate: p1Join, earnedBadges: new Map() },
- { id: 'p2', joinDate: p1Join, earnedBadges: new Map() },
- ];
+ const earnedBadgesP1 = new Map();
+ earnedBadgesP1.set('b1', {
+ badgeId: 'b1',
+ earnedAt: new Date('2026-06-01T00:00:00.000Z'),
+ contribsAtEarn: 5,
+ });
- // Period 1 (Day 1-7): p1 has 2 checkins, p2 has 1
- // Period 2 (Day 8-14): p1 has 4 checkins, p2 has 0
- const checkins = [
- { userId: 'p1', datetime: new Date('2026-06-02T10:00:00.000Z') },
- { userId: 'p1', datetime: new Date('2026-06-03T10:00:00.000Z') },
- { userId: 'p2', datetime: new Date('2026-06-02T10:00:00.000Z') },
- { userId: 'p1', datetime: new Date('2026-06-09T10:00:00.000Z') },
- { userId: 'p1', datetime: new Date('2026-06-10T10:00:00.000Z') },
- { userId: 'p1', datetime: new Date('2026-06-11T10:00:00.000Z') },
- { userId: 'p1', datetime: new Date('2026-06-12T10:00:00.000Z') },
+ const players: PlayerProfile[] = [
+ {
+ id: 'p1',
+ joinDate: p1Join,
+ earnedBadges: earnedBadgesP1,
+ },
+ {
+ id: 'p2',
+ joinDate: p1Join,
+ earnedBadges: new Map(),
+ },
];
- const asOfDate = new Date('2026-06-14T23:59:59.000Z');
+ // 7 days later (now - t0 = 6 days -> i3 = 1/6 = 0.1667 < 0.20)
+ const asOfDate = new Date('2026-06-07T00:00:00.000Z');
const ctx: IndicatorComputationContext = {
projectId: 'proj1',
badges: sampleBadges,
players,
- checkins,
+ checkins: [],
asOfDate,
- daysPerPeriod: 7,
+ threshold: 0.2,
};
const res = strategy.calculateIndicators(ctx);
@@ -113,13 +130,25 @@ describe('VanishingBadgesStrategy', () => {
const p1Res = res.players.find((p) => p.playerId === 'p1')!;
const p2Res = res.players.find((p) => p.playerId === 'p2')!;
- expect(p1Res.PMI).toBe(2);
- expect(p2Res.PMI).toBe(1);
-
- expect(res.avgPMI).toBe(1.5);
- expect(p1Res.relPMI).toBe(1.333);
- expect(p2Res.relPMI).toBe(0.667);
- expect(res.CMI).toBe(1.0);
+ // p1 earned b1, so AB(p1) = [b1, b2], unearned achievable = [b2] with i3 = 0.1667 < 0.20
+ expect(p1Res.earnedBadges).toEqual(['b1']);
+ expect(p1Res.achievableBadges).toEqual(['b1', 'b2']);
+ expect(p1Res.ignoredBadges).toEqual(['b2']);
+ expect(p1Res.individualInterest.b2).toBe(0.1667);
+ expect(p1Res.individualInterest.b3).toBe(1.0);
+
+ // p2 earned nothing, so AB(p2) = [b1] with i3 = 0.1667 < 0.20
+ expect(p2Res.earnedBadges).toEqual([]);
+ expect(p2Res.achievableBadges).toEqual(['b1']);
+ expect(p2Res.ignoredBadges).toEqual(['b1']);
+
+ // Both b1 and b2 have CII = 0.1667 < 0.20 -> §4.1 Triggered!
+ expect(res.isTriggered).toBe(true);
+ expect(res.triggerBadges).toEqual(['b1', 'b2']);
+ expect(res.communityIgnoredCount).toBe(2);
+ expect(res.totalPlayerIgnored).toBe(2);
+ expect(res.candidateBadges).toEqual(['b1', 'b2']);
+ expect(res.unreachableBadges).toEqual(['b3']);
});
it('should identify lowest CII candidate badge when interest decays', () => {
@@ -146,7 +175,6 @@ describe('VanishingBadgesStrategy', () => {
players,
checkins: [],
asOfDate,
- daysPerPeriod: 7,
};
const res = strategy.calculateIndicators(ctx);
@@ -154,6 +182,13 @@ describe('VanishingBadgesStrategy', () => {
const b2Metric = res.badges.find((b) => b.badgeId === 'b2')!;
expect(b2Metric.eligibleCount).toBe(1);
expect(b2Metric.CII).toBe(0.05);
+ expect(b2Metric.isCommunityIgnored).toBe(true);
+ expect(b2Metric.isCandidate).toBe(true);
+
+ // b1 is earned by all players (p1), so it is in allPlayerBadges and not a candidate (§4.2.1)
+ expect(res.allPlayerBadges).toEqual(['b1']);
+ expect(res.unreachableBadges).toEqual(['b3']);
+ expect(res.candidateBadges).toEqual(['b2']);
expect(res.adaptationCandidateBadge).not.toBeNull();
expect(res.adaptationCandidateBadge?.badgeId).toBe('b2');
@@ -182,7 +217,6 @@ describe('VanishingBadgesStrategy', () => {
players,
checkins: [],
asOfDate: baseDate,
- daysPerPeriod: 7,
};
const res = strategy.calculateIndicators(ctx);
@@ -191,40 +225,20 @@ describe('VanishingBadgesStrategy', () => {
expect(b1Metric.earnedCount).toBe(1);
expect(b1Metric.eligibleCount).toBe(0);
expect(b1Metric.CII).toBeNull();
+ expect(b1Metric.isCandidate).toBe(false);
+ expect(res.allPlayerBadges).toEqual(['b1']);
+ expect(res.isTriggered).toBe(false);
});
- it('should anchor timeline to explicit startDate when provided', () => {
- const explicitStart = new Date('2026-06-01T00:00:00.000Z');
- const asOf = new Date('2026-06-22T00:00:00.000Z'); // 21 days later = 3 periods of 7 days
-
- const ctx: IndicatorComputationContext = {
- projectId: 'proj1',
- badges: sampleBadges,
- players: [{ id: 'p1', joinDate: explicitStart, earnedBadges: new Map() }],
- checkins: [
- // check-in 2 days after start -> Period 1
- { userId: 'p1', datetime: new Date('2026-06-03T10:00:00.000Z') },
- // check-in 10 days after start -> Period 2
- { userId: 'p1', datetime: new Date('2026-06-11T10:00:00.000Z') },
- ],
- startDate: explicitStart,
- asOfDate: asOf,
- daysPerPeriod: 7,
- };
-
- const res = strategy.calculateIndicators(ctx);
- expect(res.startDate).toBe(explicitStart.toISOString());
- expect(res.currentPeriod).toBe(4);
- const p1 = res.players.find((p) => p.playerId === 'p1')!;
- expect(p1.periodContributions[1]).toBe(1);
- expect(p1.periodContributions[2]).toBe(1);
- });
-
- it('should filter active players and evaluate community motivation based on minActiveCheckins', () => {
+ it('should restrict the evaluated player pool P based on minActiveCheckins before computing AB(p), ep(b), and CII(b)', () => {
const p1Join = new Date('2026-06-01T00:00:00.000Z');
const players: PlayerProfile[] = [
{ id: 'p1', joinDate: p1Join, earnedBadges: new Map() },
- { id: 'p2', joinDate: p1Join, earnedBadges: new Map() },
+ {
+ id: 'p2',
+ joinDate: new Date('2026-05-01T00:00:00.000Z'),
+ earnedBadges: new Map(),
+ },
];
// p1 has 3 checkins; p2 has only 1 checkin
@@ -240,22 +254,20 @@ describe('VanishingBadgesStrategy', () => {
badges: sampleBadges,
players,
checkins,
- asOfDate: new Date('2026-06-14T23:59:59.000Z'),
- daysPerPeriod: 7,
- minActiveCheckins: 2, // p1 qualifies (3 >= 2), p2 does not (1 < 2)
+ asOfDate: new Date('2026-06-03T23:59:59.000Z'),
+ minActiveCheckins: 2, // p1 qualifies (2 checkins up to June 3 >= 2), p2 does not (1 < 2)
};
const res = strategy.calculateIndicators(ctx);
expect(res.activePlayers).toBe(1);
expect(res.totalPlayers).toBe(2);
+ expect(res.players.map((p) => p.playerId)).toEqual(['p1']);
- const p1Res = res.players.find((p) => p.playerId === 'p1')!;
- const p2Res = res.players.find((p) => p.playerId === 'p2')!;
-
- // p1 is active and sole contributor to community motivation
- expect(p1Res.relPMI).toBe(1.0);
- // p2 is below minActiveCheckins threshold so relPMI is 0.0
- expect(p2Res.relPMI).toBe(0.0);
+ // Only p1 is in the eligible pool for b1 (elapsed 3 days -> i3 = 0.3333)
+ const b1Metric = res.badges.find((b) => b.badgeId === 'b1')!;
+ expect(b1Metric.eligibleCount).toBe(1);
+ expect(b1Metric.eligibleUsers).toEqual(['p1']);
+ expect(b1Metric.CII).toBe(0.3333);
});
describe('computeMedian', () => {
diff --git a/src/module/gamification-indicators/domain/vanishing-badges.strategy.ts b/src/module/gamification-indicators/domain/vanishing-badges.strategy.ts
index 0abdd3d..c2ac85b 100644
--- a/src/module/gamification-indicators/domain/vanishing-badges.strategy.ts
+++ b/src/module/gamification-indicators/domain/vanishing-badges.strategy.ts
@@ -11,38 +11,46 @@ import {
} from './indicator.types';
/**
- * Standard implementation of the vanishing badges indicator framework:
- * Adaptive Gamification Mechanism for Citizen Science platforms.
+ * Standard implementation of the Vanishing Badges indicator framework (Sept 18, 2026 Revision):
+ * Adaptive Gamification Mechanism for Citizen Science platforms (Torres & Dalponte Ayastuy).
*
- * Encapsulates Definitions 3.1 through 3.5 as pure, stateless mathematical operations.
+ * Encapsulates Definitions 3.1 through 3.4 and Section 4.1–4.2 heuristic pipeline
+ * as pure, stateless mathematical operations:
+ * - Def 3.1: Achievable Badges AB(p)
+ * - Def 3.2: Individual Interest Indicator i_3(p, b)
+ * - Def 3.3: Ignored Badges ignored_by(p)
+ * - Def 3.4: Community Interest Indicator CII(b)
+ * - §4.1: Adaptation Trigger (exists b in Candidates : CII(b) < x)
+ * - §4.2: Candidate Badge Filtering, Sorting & Selection
*/
@Injectable()
export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
+ /** Default reference threshold x (e.g., 0.20 <=> 5 days elapsed without earning) */
+ private static readonly DEFAULT_THRESHOLD_X = 0.2;
+
/**
* Orchestrates the complete indicator calculation pipeline.
*
- * 1. Sorts and segments checkins into periods s in S based on `daysPerPeriod`.
- * 2. Resolves earned badge state and historical contributions at time of award.
- * 3. Calculates t_0(p, b) and achievability for each player and badge.
- * 4. Calculates individual interest i_3(p, b) [Def 3.2].
- * 5. Computes badge-level metrics ET_b [Def 3.1] and CII(b) [Def 3.3].
- * 6. Computes player-level motivation PMI(p), relPMI(p) [Def 3.4], and CMI [Def 3.5].
- * 7. Identifies the lowest CII candidate badge for adaptation recommendation.
+ * 1. Filters and sorts check-ins up to `asOfDate`, resolving cumulative contributions
+ * and earned badges B_p per player.
+ * 2. Determines the evaluated player pool P (optionally filtered by `minActiveCheckins`).
+ * 3. Computes Achievable Badges AB(p) and t_0(p, b) [Def 3.1].
+ * 4. Computes Individual Interest i_3(p, b) [Def 3.2] and Ignored Badges ignored_by(p) [Def 3.3].
+ * 5. Computes Eligible Players ep(b) and Community Interest CII(b) [Def 3.4].
+ * 6. Applies Candidate Badge Filtering (§4.2.1: excluding all-earned, unreachable, and expired badges).
+ * 7. Evaluates Adaptation Trigger (§4.1: exists b : CII(b) < x) and ranks candidate badges (§4.2.2).
*/
calculateIndicators(
ctx: IndicatorComputationContext,
): CommunityIndicatorResult {
- const {
- projectId,
- badges,
- players,
- checkins,
- startDate,
- asOfDate,
- daysPerPeriod,
- } = ctx;
-
- // Filter checkins up to asOfDate
+ const { projectId, badges, players, checkins, asOfDate } = ctx;
+
+ const threshold =
+ ctx.threshold !== undefined && Number(ctx.threshold) > 0
+ ? Number(ctx.threshold)
+ : VanishingBadgesStrategy.DEFAULT_THRESHOLD_X;
+
+ // 1. Filter check-ins up to asOfDate and sort chronologically
const validCheckins = checkins
.filter((c) => new Date(c.datetime).getTime() <= asOfDate.getTime())
.sort(
@@ -50,72 +58,38 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
new Date(a.datetime).getTime() - new Date(b.datetime).getTime(),
);
- /**
- * Timeline Origin Determination for Period Partitioning (Period 1, 2, ...):
- *
- * - IF `startDate` IS PROVIDED:
- * The timeline explicitly anchors to `startDate`. Period 1 begins on `startDate`.
- * Contributions occurring prior to `startDate` are excluded from period-over-period
- * motivation (PMI) evaluation.
- *
- * - IF `startDate` IS NOT PROVIDED (Default Behavior):
- * The engine dynamically detects the earliest recorded check-in across the project
- * (`validCheckins[0].datetime`). Period 1 begins at the exact moment the community
- * started contributing. If no check-ins exist yet (cold start), it defaults to `asOfDate`.
- */
- const timelineStart = startDate
- ? new Date(startDate).getTime()
- : validCheckins.length > 0
- ? new Date(validCheckins[0].datetime).getTime()
- : asOfDate.getTime();
-
- const periodMs = Math.max(1, daysPerPeriod) * 24 * 60 * 60 * 1000;
- const currentPeriod = Math.max(
- 1,
- Math.ceil((asOfDate.getTime() - timelineStart + 1) / periodMs),
- );
-
- // 1. Group contributions per player and per period
const playerTotalContribs: Record = {};
- const playerPeriodContribs: Record> = {};
const playerEarnedBadgesMap: Map<
string,
Map
> = new Map();
players.forEach((p) => {
- playerTotalContribs[p.id] = 0;
- playerPeriodContribs[p.id] = {};
- playerEarnedBadgesMap.set(p.id, new Map(p.earnedBadges || []));
+ const pId = String(p.id);
+ playerTotalContribs[pId] = 0;
+ playerEarnedBadgesMap.set(pId, new Map(p.earnedBadges || []));
});
validCheckins.forEach((c) => {
- const pId = c.userId;
- if (!playerPeriodContribs[pId]) {
+ const pId = String(c.userId);
+ if (playerTotalContribs[pId] === undefined) {
playerTotalContribs[pId] = 0;
- playerPeriodContribs[pId] = {};
+ }
+ if (!playerEarnedBadgesMap.has(pId)) {
playerEarnedBadgesMap.set(pId, new Map());
}
playerTotalContribs[pId] = (playerTotalContribs[pId] || 0) + 1;
- const checkinTime = new Date(c.datetime).getTime();
- if (checkinTime >= timelineStart) {
- const s = Math.max(
- 1,
- Math.ceil((checkinTime - timelineStart + 1) / periodMs),
- );
- playerPeriodContribs[pId][s] = (playerPeriodContribs[pId][s] || 0) + 1;
- }
- // Track newly awarded badges if present on checkin
+ // Track newly awarded badges if present on check-in
if (c.newBadges && c.newBadges.length > 0) {
c.newBadges.forEach((badgeRef) => {
const matchedBadge = badges.find(
(b) => b.id === badgeRef || b.name === badgeRef,
);
if (matchedBadge) {
- const playerBadgeMap = playerEarnedBadgesMap.get(pId)!;
- if (!playerBadgeMap.has(matchedBadge.id)) {
+ const playerBadgeMap = playerEarnedBadgesMap.get(pId);
+ if (playerBadgeMap && !playerBadgeMap.has(matchedBadge.id)) {
playerBadgeMap.set(matchedBadge.id, {
badgeId: matchedBadge.id,
earnedAt: new Date(c.datetime),
@@ -127,17 +101,41 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
}
});
- // 2. Compute achievability and t_0(p, b) for every player and badge
+ // 2. Determine evaluated player pool P based on optional minActiveCheckins filter
+ const minCheckins =
+ ctx.minActiveCheckins !== undefined && Number(ctx.minActiveCheckins) > 0
+ ? Number(ctx.minActiveCheckins)
+ : 0;
+
+ const evaluatedPlayers =
+ minCheckins > 0
+ ? players.filter(
+ (p) => (playerTotalContribs[String(p.id)] || 0) >= minCheckins,
+ )
+ : players;
+
+ const activePlayersCount =
+ minCheckins > 0
+ ? evaluatedPlayers.length
+ : players.filter((p) => (playerTotalContribs[String(p.id)] || 0) >= 1)
+ .length;
+
+ // 3. Compute AB(p) [Def 3.1], t_0(p, b), i_3(p, b) [Def 3.2], and ignored_by(p) [Def 3.3]
const playerBadgeT0: Record> = {};
const playerBadgeAchievable: Record> = {};
const playerBadgeI3: Record> = {};
+ const playerABList: Record = {};
+ const playerIgnoredList: Record = {};
players.forEach((p) => {
- playerBadgeT0[p.id] = {};
- playerBadgeAchievable[p.id] = {};
- playerBadgeI3[p.id] = {};
+ const pId = String(p.id);
+ playerBadgeT0[pId] = {};
+ playerBadgeAchievable[pId] = {};
+ playerBadgeI3[pId] = {};
+ playerABList[pId] = [];
- const earned = playerEarnedBadgesMap.get(p.id) || new Map();
+ const earned = playerEarnedBadgesMap.get(pId) || new Map();
+ const earnedIds = new Set(earned.keys());
badges.forEach((b) => {
const { isAchievable, t0Date } = this.computeAchievabilityAndT0(
@@ -146,13 +144,17 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
badges,
earned,
);
- playerBadgeAchievable[p.id][b.id] = isAchievable;
- playerBadgeT0[p.id][b.id] = t0Date;
+ playerBadgeAchievable[pId][b.id] = isAchievable;
+ playerBadgeT0[pId][b.id] = t0Date;
+
+ if (isAchievable) {
+ playerABList[pId].push(b.id);
+ }
const isEarned = earned.has(b.id);
const earnedDate = isEarned ? earned.get(b.id)!.earnedAt : null;
- playerBadgeI3[p.id][b.id] = this.computeIndividualInterest(
+ playerBadgeI3[pId][b.id] = this.computeIndividualInterest(
isAchievable,
isEarned,
t0Date,
@@ -160,84 +162,101 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
asOfDate,
);
});
+
+ playerIgnoredList[pId] = this.computeIgnoredBadges(
+ playerABList[pId],
+ earnedIds,
+ playerBadgeI3[pId],
+ threshold,
+ );
});
- // 3. Compute badge metrics: ET_b, eligible pool ep(b), and CII(b)
- const badgeMetrics: BadgeIndicatorResult[] = badges.map((b) => {
- // U_b: Players who have earned badge b
- const UbPlayers: { playerId: string; contribsAtEarn: number }[] = [];
- players.forEach((p) => {
- const earned = playerEarnedBadgesMap.get(p.id);
- if (earned && earned.has(b.id)) {
- UbPlayers.push({
- playerId: p.id,
- contribsAtEarn: earned.get(b.id)!.contribsAtEarn,
- });
- }
- });
+ // 4. Section 4.2.1 Candidate Filtering (over evaluatedPlayers pool P)
+ // all_player_badges = assigned_badges(players, badges) (earned by everyone in P)
+ // unreachable_badges = unreachable_badges(players, badges) (no player in P has b in AB(p))
+ const allPlayerBadges: string[] = [];
+ const unreachableBadges: string[] = [];
+ const candidateBadges: string[] = [];
- const ET_b = this.computeEstimatedAwardingTime(b, badges, UbPlayers);
+ // 5. Compute badge metrics: U_b, eligible pool ep(b), and CII(b) [Def 3.4]
+ let lowestCII: number | null = null;
+ let communityIgnoredCount = 0;
- // ep(b): Eligible players who can achieve b but have NOT earned it yet
- const eligiblePlayers = players.filter((p) => {
- const isAchievable = playerBadgeAchievable[p.id]?.[b.id] ?? false;
- const hasEarned = playerEarnedBadgesMap.get(p.id)?.has(b.id) ?? false;
+ const badgeMetrics: BadgeIndicatorResult[] = badges.map((b) => {
+ // U_b: Players in P who have earned badge b
+ const UbPlayers = evaluatedPlayers.filter((p) =>
+ playerEarnedBadgesMap.get(String(p.id))?.has(b.id),
+ );
+
+ // ep(b) = { p in P : b in AB(p) - B_p }
+ const eligiblePlayers = evaluatedPlayers.filter((p) => {
+ const pId = String(p.id);
+ const isAchievable = playerBadgeAchievable[pId]?.[b.id] ?? false;
+ const hasEarned = playerEarnedBadgesMap.get(pId)?.has(b.id) ?? false;
return isAchievable && !hasEarned;
});
const eligibleI3Values = eligiblePlayers.map(
- (p) => playerBadgeI3[p.id][b.id],
+ (p) => playerBadgeI3[String(p.id)][b.id],
);
const CII = this.computeCommunityInterest(eligibleI3Values);
+ if (CII !== null && (lowestCII === null || CII < lowestCII)) {
+ lowestCII = CII;
+ }
+
+ const isCommunityIgnored = CII !== null && CII < threshold;
+ if (isCommunityIgnored) {
+ communityIgnoredCount++;
+ }
+
+ const isAllEarned =
+ evaluatedPlayers.length > 0 &&
+ UbPlayers.length === evaluatedPlayers.length;
+ const canAnyoneAchieve = evaluatedPlayers.some(
+ (p) => playerBadgeAchievable[String(p.id)]?.[b.id] ?? false,
+ );
+ const isUnreachable = !canAnyoneAchieve;
+ const isExpired = b.status === 'expired';
+
+ if (isAllEarned) {
+ allPlayerBadges.push(b.id);
+ }
+ if (isUnreachable) {
+ unreachableBadges.push(b.id);
+ }
+
+ const isCandidate = !isAllEarned && !isUnreachable && !isExpired;
+ if (isCandidate) {
+ candidateBadges.push(b.id);
+ }
+
return {
badgeId: b.id,
badgeName: b.name,
status: b.status,
earnedCount: UbPlayers.length,
- earnedUsers: UbPlayers.map((u) => u.playerId),
- ET_b,
+ earnedUsers: UbPlayers.map((u) => String(u.id)),
eligibleCount: eligiblePlayers.length,
- eligibleUsers: eligiblePlayers.map((p) => p.id),
+ eligibleUsers: eligiblePlayers.map((p) => String(p.id)),
CII,
+ isCommunityIgnored,
+ isCandidate,
isLowestCII: false,
};
});
- const minCheckins =
- ctx.minActiveCheckins !== undefined && Number(ctx.minActiveCheckins) > 0
- ? Number(ctx.minActiveCheckins)
- : 1;
+ // 6. Section 4.1 Trigger Condition:
+ // Adaptation is triggered when a candidate badge has CII(b) < x
+ const triggerBadges = badgeMetrics
+ .filter((b) => b.isCandidate && b.CII !== null && b.CII < threshold)
+ .map((b) => b.badgeId);
+ const isTriggered = triggerBadges.length > 0;
- // 4. Compute Player Motivation Indicator PMI(p) and relative relPMI(p)
- const allPlayerPMIs = players.map((p) => {
- const pmi = this.computePlayerMotivation(
- playerPeriodContribs[p.id] || {},
- currentPeriod,
- );
- return { playerId: p.id, pmi };
- });
-
- // P: set of active players meeting the minActiveCheckins threshold
- const activePlayerPMIs = allPlayerPMIs.filter(
- (item) => (playerTotalContribs[item.playerId] || 0) >= minCheckins,
- );
-
- const { avgPMI, playerRelPMIs, CMI } =
- this.computeCommunityMotivation(activePlayerPMIs);
-
- const playerResults: PlayerIndicatorResult[] = players.map((p) => ({
- playerId: p.id,
- totalContributions: playerTotalContribs[p.id] || 0,
- periodContributions: playerPeriodContribs[p.id] || {},
- PMI: allPlayerPMIs.find((item) => item.playerId === p.id)?.pmi || 0,
- relPMI: playerRelPMIs[p.id] || 0.0,
- }));
-
- // 5. Rank candidate badges by ascending CII to determine adaptation candidate
- // Candidate badges must be 'active' and have an active eligible pool (CII !== null)
+ // 7. Section 4.2.2 Sorting & Selection:
+ // Rank active candidate badges by ascending CII(b) to identify adaptation candidate
const rankedCandidates = badgeMetrics
- .filter((b) => b.status === 'active' && b.CII !== null)
+ .filter((b) => b.status === 'active' && b.isCandidate && b.CII !== null)
.sort((a, b) => (a.CII ?? 999) - (b.CII ?? 999));
const topCandidate =
@@ -251,19 +270,39 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
}
}
- const activePlayersCount = activePlayerPMIs.length;
+ // Build player indicator results for the evaluated player pool P
+ let totalPlayerIgnored = 0;
+ const playerResults: PlayerIndicatorResult[] = evaluatedPlayers.map((p) => {
+ const pId = String(p.id);
+ const earned = playerEarnedBadgesMap.get(pId) || new Map();
+ const ignored = playerIgnoredList[pId] || [];
+ totalPlayerIgnored += ignored.length;
+
+ return {
+ playerId: pId,
+ totalContributions: playerTotalContribs[pId] || 0,
+ earnedBadges: Array.from(earned.keys()),
+ achievableBadges: playerABList[pId] || [],
+ ignoredBadges: ignored,
+ individualInterest: playerBadgeI3[pId] || {},
+ };
+ });
return {
projectId,
- currentPeriod,
- startDate: new Date(timelineStart).toISOString(),
asOfDate: asOfDate.toISOString(),
- daysPerPeriod,
+ threshold,
totalPlayers: players.length,
activePlayers: activePlayersCount,
totalContributions: validCheckins.length,
- avgPMI,
- CMI,
+ isTriggered,
+ triggerBadges,
+ communityIgnoredCount,
+ totalPlayerIgnored,
+ allPlayerBadges,
+ unreachableBadges,
+ candidateBadges,
+ lowestCII,
badges: badgeMetrics,
players: playerResults,
adaptationCandidateBadge: topCandidate,
@@ -271,56 +310,17 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
}
/**
- * Definition 3.1: Estimated Awarding Time (ET_b)
+ * Definition 3.1: Achievable Badges AB(p) and t_0(p, b).
*
* Formula:
- * ET_b = (1 / |U_b|) * SUM_{p in U_b} (cnum(p, t_earned(p, b)))
+ * AB(p) = { b in Badges | forall prev in prerequisites(b), prev in B_p }
*
* Explanation:
- * Represents the historical average number of check-ins/contributions a player
- * accumulated at the exact moment they earned badge b.
- *
- * Fallback:
- * When |U_b| = 0 (cold start / no player has unlocked the badge yet),
- * the estimate defaults to the sum of the badge's required check-ins plus
- * the required check-ins of all its direct prerequisites.
- */
- computeEstimatedAwardingTime(
- badge: BadgeDefinition,
- allBadges: BadgeDefinition[],
- earnedPlayers: { playerId: string; contribsAtEarn: number }[],
- ): number {
- if (earnedPlayers.length > 0) {
- const sum = earnedPlayers.reduce((acc, p) => acc + p.contribsAtEarn, 0);
- return parseFloat((sum / earnedPlayers.length).toFixed(2));
- }
-
- // Cold-start fallback: required checkins + direct prerequisite requirements
- let fallback = badge.reqCheckins;
- (badge.previousBadges || []).forEach((prevRef) => {
- const prevBadge = allBadges.find(
- (b) => b.id === prevRef || b.name === prevRef,
- );
- if (prevBadge) {
- fallback += prevBadge.reqCheckins;
- }
- });
-
- return parseFloat(fallback.toFixed(2));
- }
-
- /**
- * Computes Achievability and t_0(p, b) for a player and badge.
- *
- * Definition:
- * t_0(p, b) represents the moment in time when badge b became available/achievable
- * for player p:
+ * A badge is achievable to a player when all the previous badges (prerequisites)
+ * have been earned.
+ * t_0(p, b) represents the timestamp when badge b became achievable for player p:
* - Root badges (no prerequisites): t_0 = player's join date.
* - Child badges: t_0 = MAX(earnedAt(prereq)) across all direct prerequisites.
- *
- * Achievability:
- * A badge is achievable for player p if and only if all prerequisite badges
- * specified in `previousBadges` have been completed.
*/
computeAchievabilityAndT0(
player: PlayerProfile,
@@ -338,7 +338,6 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
// Check if every prerequisite badge has been earned
let maxEarnedDate = player.joinDate;
for (const prereqRef of prereqs) {
- // Resolve prerequisite badge by id or name
const prereqDef = allBadges.find(
(b) => b.id === prereqRef || b.name === prereqRef,
);
@@ -346,7 +345,7 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
const earnedInfo = earnedBadges.get(prereqId);
if (!earnedInfo) {
- // Prerequisite missing: badge is locked/unachievable
+ // Prerequisite missing: badge is locked/unachievable (b not in AB(p))
return { isAchievable: false, t0Date: player.joinDate };
}
@@ -362,13 +361,13 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
* Definition 3.2: Individual Interest Indicator i_3(p, b)
*
* Formula:
- * i_3(p, b) = 1.0 / (now - t_0(p, b)) if badge b is achievable
- * i_3(p, b) = 1.0 if badge b is NOT achievable (locked)
+ * i_3(p, b) = 1.0 / (now - t_0(p, b)) if b in AB(p) (b is achievable by p)
+ * i_3(p, b) = 1.0 if b not in AB(p) (b is not achievable by p)
*
* Explanation:
- * Captures the urgency/momentum of player p towards badge b. As elapsed days
- * grow without earning the badge, i_3 decays towards 0, reflecting waning interest
- * or a bottleneck. If already earned, elapsed days is fixed to (earnedAt - t_0).
+ * Captures the inverse of the time elapsed since the prerequisites for badge b
+ * were met by player p (t_0). As elapsed days grow without earning the badge,
+ * i_3 decays towards 0, reflecting waning interest.
*
* Note on Units:
* Elapsed time is measured in whole days (minimum 1 day to prevent division by zero).
@@ -401,111 +400,51 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy {
}
/**
- * Definition 3.3: Community Interest Indicator CII(b)
- *
- * Formula:
- * CII(b) = median({ i_3(p, b) : p in ep(b) })
- * where ep(b) = { p in P : b is achievable for p AND p has not earned b }
- *
- * Explanation:
- * Aggregates the median interest across all players who actively could earn badge b
- * right now. Low CII(b) identifies badges that players have unlocked but are neglecting.
- *
- * Edge Cases:
- * If ep(b) is empty (no player currently eligible), returns null.
- */
- computeCommunityInterest(eligibleI3Values: number[]): number | null {
- if (eligibleI3Values.length === 0) {
- return null;
- }
-
- const medianVal = this.computeMedian(eligibleI3Values);
- return parseFloat(medianVal.toFixed(4));
- }
-
- /**
- * Definition 3.4: Player Motivation Indicator PMI(p)
+ * Definition 3.3: Ignored Badges ignored_by(p)
*
* Formula:
- * PMI(p) = SUM_{s in S} [ cnum(p, s) >= cnum(p, prev(s)) AND (cnum(p, s) + cnum(p, prev(s)) > 0) ]
+ * ignored_by(p) = { b in AB(p) - B_p : i_3(p, b) < x }
*
* Explanation:
- * Measures consistency of player engagement across periods s in S.
- * A period awards +1 point if the player made contributions and their volume
- * did not drop compared to the preceding period (prev(s)).
+ * The set of ignored badges by a player p is a subset of AB(p) that have not
+ * yet been earned (AB(p) - B_p) whose individual interest i_3 is below threshold x.
*/
- computePlayerMotivation(
- periodContributions: Record,
- currentPeriod: number,
- ): number {
- let pmiCount = 0;
-
- for (let s = 1; s <= currentPeriod; s++) {
- const currC = periodContributions[s] || 0;
- const prevC = s > 1 ? periodContributions[s - 1] || 0 : 0;
-
- // Both zero -> inactive period, no point
- if (currC + prevC > 0) {
- if (currC >= prevC) {
- pmiCount++;
- }
+ computeIgnoredBadges(
+ achievableBadgeIds: string[],
+ earnedBadgeIds: Set,
+ playerI3Map: Record,
+ threshold: number,
+ ): string[] {
+ return achievableBadgeIds.filter((badgeId) => {
+ if (earnedBadgeIds.has(badgeId)) {
+ return false;
}
- }
-
- return pmiCount;
+ const i3 = playerI3Map[badgeId] ?? 1.0;
+ return i3 < threshold;
+ });
}
/**
- * Definitions 3.4 & 3.5: Relative Motivation relPMI(p) and Community Motivation Indicator CMI
+ * Definition 3.4: Community Interest Indicator CII(b)
*
- * Formulas:
- * avgPMI = (1 / |P|) * SUM_{p in P} PMI(p)
- * relPMI(p) = PMI(p) / avgPMI
- * CMI = median({ relPMI(p) : p in P })
+ * Formula:
+ * CII(b) = median({ i_3(p, b) : p in ep(b) })
+ * where ep(b) = { p in P : b in AB(p) - B_p }
*
* Explanation:
- * relPMI normalizes individual motivation against the community standard.
- * CMI takes the community median. A drop in CMI below 1.0 indicates community-wide
- * motivation decay, triggering the vanishing badge adaptation heuristic (§4).
+ * Aggregates the median individual interest across all players who are currently
+ * eligible for badge b. Low CII(b) (< x) identifies badges ignored by the community (§4.1).
*
- * Edge Case Handling:
- * If avgPMI = 0 (community cold start or complete inactivity), relPMI evaluates
- * to 0.0 for inactive players, preventing division-by-zero or NaN values.
+ * Edge Cases:
+ * If ep(b) is empty (no player currently eligible), returns null.
*/
- computeCommunityMotivation(playerPMIs: { playerId: string; pmi: number }[]): {
- avgPMI: number;
- playerRelPMIs: Record;
- CMI: number;
- } {
- const totalPlayers = playerPMIs.length;
- if (totalPlayers === 0) {
- return { avgPMI: 0, playerRelPMIs: {}, CMI: 0.0 };
+ computeCommunityInterest(eligibleI3Values: number[]): number | null {
+ if (eligibleI3Values.length === 0) {
+ return null;
}
- const totalPMI = playerPMIs.reduce((acc, item) => acc + item.pmi, 0);
- const avgPMI = totalPMI / totalPlayers;
-
- const playerRelPMIs: Record = {};
-
- playerPMIs.forEach((item) => {
- if (avgPMI > 0) {
- playerRelPMIs[item.playerId] = parseFloat(
- (item.pmi / avgPMI).toFixed(3),
- );
- } else {
- // Cold start fallback: 1.0 if player alone has activity, else 0.0
- playerRelPMIs[item.playerId] = item.pmi > 0 ? 1.0 : 0.0;
- }
- });
-
- const relValues = playerPMIs.map((item) => playerRelPMIs[item.playerId]);
- const medianCMI = this.computeMedian(relValues);
-
- return {
- avgPMI: parseFloat(avgPMI.toFixed(2)),
- playerRelPMIs,
- CMI: isNaN(medianCMI) ? 0.0 : parseFloat(medianCMI.toFixed(3)),
- };
+ const medianVal = this.computeMedian(eligibleI3Values);
+ return parseFloat(medianVal.toFixed(4));
}
/**
diff --git a/src/module/gamification-indicators/dto/get-indicators-query.dto.ts b/src/module/gamification-indicators/dto/get-indicators-query.dto.ts
index 34c80b3..c2192b9 100644
--- a/src/module/gamification-indicators/dto/get-indicators-query.dto.ts
+++ b/src/module/gamification-indicators/dto/get-indicators-query.dto.ts
@@ -5,44 +5,33 @@ import { ApiPropertyOptional } from '@nestjs/swagger';
*/
export class GetIndicatorsQueryDto {
/**
- * Number of days per evaluation period s in S (default: 7).
- */
- @ApiPropertyOptional({
- description: 'Number of days per period s in S',
- default: 7,
- example: 7,
- })
- daysPerPeriod?: number | string;
-
- /**
- * Optional timeline origin for period partitioning (Period 1 Day 1).
- * Accepts DD-MM-YYYY (e.g. "01-06-2026") or ISO-8601.
- * If omitted, defaults to the timestamp of the project's earliest recorded check-in
- * (or asOfDate if no check-ins exist).
+ * Point-in-time calculation horizon. Accepts DD-MM-YYYY (e.g. "20-07-2026") or ISO-8601 (default: current server time).
*/
@ApiPropertyOptional({
description:
- 'Timeline origin for period partitioning. Accepts DD-MM-YYYY or ISO-8601 format. If omitted, defaults to earliest check-in timestamp.',
- example: '01-06-2026',
+ 'Point-in-time calculation horizon. Accepts DD-MM-YYYY or ISO-8601 format',
+ example: '20-07-2026',
})
- startDate?: string;
+ asOfDate?: string;
/**
- * Point-in-time calculation horizon. Accepts DD-MM-YYYY (e.g. "20-07-2026") or ISO-8601 (default: current server time).
+ * Reference threshold x for Ignored Badges (Def 3.3: i3 < x) and Adaptation Trigger (§4.1: CII < x).
+ * Default: 0.20 (corresponding to > 5 days elapsed since badge became achievable).
*/
@ApiPropertyOptional({
description:
- 'Point-in-time calculation horizon. Accepts DD-MM-YYYY or ISO-8601 format',
- example: '20-07-2026',
+ 'Reference threshold x for ignored badges (Def 3.3) and adaptation trigger (§4.1)',
+ default: 0.2,
+ example: 0.2,
})
- asOfDate?: string;
+ threshold?: number | string;
/**
- * Optional minimum check-ins to filter active players.
+ * Optional minimum check-ins to restrict the evaluated player pool P.
*/
@ApiPropertyOptional({
description:
- 'Minimum contributions required for player to be considered active',
+ 'Minimum contributions required for a player to be included in the evaluated pool P',
default: 0,
example: 1,
})
diff --git a/src/module/gamification-indicators/dto/indicators-response.dto.ts b/src/module/gamification-indicators/dto/indicators-response.dto.ts
index 004f058..e96b569 100644
--- a/src/module/gamification-indicators/dto/indicators-response.dto.ts
+++ b/src/module/gamification-indicators/dto/indicators-response.dto.ts
@@ -17,35 +17,45 @@ export class BadgeIndicatorDto {
earnedCount: number;
@ApiProperty({
- description: 'User IDs who have earned this badge',
+ description: 'User IDs who have earned this badge (U_b)',
type: [String],
})
earnedUsers: string[];
@ApiProperty({
description:
- 'Estimated Awarding Time (historical average contributions at award)',
+ 'Number of eligible players who unlocked prerequisites (b in AB(p)) but have not yet earned it',
})
- ET_b: number;
+ eligibleCount: number;
@ApiProperty({
- description:
- 'Number of eligible players who unlocked prerequisites but not yet earned',
+ description: 'User IDs of eligible players ep(b)',
+ type: [String],
})
- eligibleCount: number;
-
- @ApiProperty({ description: 'User IDs of eligible players', type: [String] })
eligibleUsers: string[];
@ApiProperty({
description:
- 'Community Interest Indicator (median individual interest across eligible players)',
+ 'Community Interest Indicator [Def 3.4] (median individual interest i3 across eligible players)',
nullable: true,
})
CII: number | null;
@ApiProperty({
- description: 'True if this badge has the lowest CII in the community',
+ description:
+ 'True if CII(b) is below reference threshold x (§4.1 ignored by community)',
+ })
+ isCommunityIgnored: boolean;
+
+ @ApiProperty({
+ description:
+ 'True if badge belongs to the filtered candidate pool (§4.2.1: not all-earned, not unreachable, not expired)',
+ })
+ isCandidate: boolean;
+
+ @ApiProperty({
+ description:
+ 'True if this badge has the lowest CII among active candidates',
})
isLowestCII: boolean;
}
@@ -57,67 +67,118 @@ export class PlayerIndicatorDto {
@ApiProperty({ description: 'Total lifetime check-in contributions' })
totalContributions: number;
- @ApiProperty({ description: 'Contributions count per period key' })
- periodContributions: Record;
+ @ApiProperty({
+ description: 'Badge IDs already earned by the player (B_p)',
+ type: [String],
+ })
+ earnedBadges: string[];
+
+ @ApiProperty({
+ description:
+ 'Achievable Badges AB(p) [Def 3.1]: badge IDs whose prerequisites have been met',
+ type: [String],
+ })
+ achievableBadges: string[];
@ApiProperty({
description:
- 'Player Motivation Indicator (periods with growth/sustained activity)',
+ 'Ignored Badges ignored_by(p) [Def 3.3]: achievable unearned badges with i3(p, b) < threshold x',
+ type: [String],
})
- PMI: number;
+ ignoredBadges: string[];
@ApiProperty({
- description: 'Relative PMI normalized against community average',
+ description:
+ 'Individual Interest Indicator i3(p, b) [Def 3.2] mapped by badgeId',
})
- relPMI: number;
+ individualInterest: Record;
}
export class CommunityIndicatorsResponseDto {
@ApiProperty({ description: 'Project unique identifier' })
projectId: string;
- @ApiProperty({ description: 'Current evaluated period number s' })
- currentPeriod: number;
-
- @ApiProperty({
- description: 'Timeline origin timestamp used for Period 1 (ISO-8601)',
- })
- startDate: string;
-
@ApiProperty({
description: 'Horizon timestamp used for evaluation (ISO-8601)',
})
asOfDate: string;
- @ApiProperty({ description: 'Period length in days' })
- daysPerPeriod: number;
+ @ApiProperty({
+ description:
+ 'Reference threshold x used for ignored badges (Def 3.3) and adaptation trigger (§4.1)',
+ })
+ threshold: number;
@ApiProperty({ description: 'Total registered players in project' })
totalPlayers: number;
- @ApiProperty({ description: 'Players with at least one contribution' })
+ @ApiProperty({
+ description: 'Active players meeting the contribution threshold',
+ })
activePlayers: number;
@ApiProperty({ description: 'Total valid contributions recorded' })
totalContributions: number;
- @ApiProperty({ description: 'Average PMI across community players' })
- avgPMI: number;
+ @ApiProperty({
+ description:
+ 'Section 4.1 Trigger status: true if there exists a candidate badge b with CII(b) < threshold x',
+ })
+ isTriggered: boolean;
+
+ @ApiProperty({
+ description: 'Candidate badge IDs triggering adaptation (CII(b) < x)',
+ type: [String],
+ })
+ triggerBadges: string[];
+
+ @ApiProperty({
+ description: 'Count of badges ignored by the community (CII(b) < x)',
+ })
+ communityIgnoredCount: number;
+
+ @ApiProperty({
+ description:
+ 'Sum of |ignored_by(p)| across all players in the evaluated pool',
+ })
+ totalPlayerIgnored: number;
@ApiProperty({
description:
- 'Community Motivation Indicator (median relPMI across players)',
+ 'Section 4.2.1: Badge IDs earned by all evaluated players (assigned_badges)',
+ type: [String],
})
- CMI: number;
+ allPlayerBadges: string[];
@ApiProperty({
- description: 'Badge indicators list sorted by CII ascending',
+ description:
+ 'Section 4.2.1: Badge IDs unreachable by any evaluated player (unreachable_badges)',
+ type: [String],
+ })
+ unreachableBadges: string[];
+
+ @ApiProperty({
+ description:
+ 'Section 4.2.1: Filtered candidate badge IDs eligible for vanishing selection',
+ type: [String],
+ })
+ candidateBadges: string[];
+
+ @ApiProperty({
+ description: 'Lowest CII value across badges with non-empty eligible pools',
+ nullable: true,
+ })
+ lowestCII: number | null;
+
+ @ApiProperty({
+ description: 'Badge indicators list',
type: [BadgeIndicatorDto],
})
badges: BadgeIndicatorDto[];
@ApiProperty({
- description: 'Player motivation indicators',
+ description:
+ 'Player indicators (AB(p), ignored_by(p), and individual interest i3)',
type: [PlayerIndicatorDto],
})
players: PlayerIndicatorDto[];
diff --git a/src/module/gamification-indicators/gamification-indicators.controller.spec.ts b/src/module/gamification-indicators/gamification-indicators.controller.spec.ts
index 6b49342..4ecb5a5 100644
--- a/src/module/gamification-indicators/gamification-indicators.controller.spec.ts
+++ b/src/module/gamification-indicators/gamification-indicators.controller.spec.ts
@@ -12,15 +12,19 @@ describe('GamificationIndicatorsController', () => {
const mockResponse: CommunityIndicatorsResponseDto = {
projectId: 'proj1',
- currentPeriod: 2,
- startDate: '2026-06-01T00:00:00.000Z',
asOfDate: '2026-06-15T00:00:00.000Z',
- daysPerPeriod: 7,
+ threshold: 0.2,
totalPlayers: 10,
activePlayers: 5,
totalContributions: 25,
- avgPMI: 1.8,
- CMI: 1.1,
+ isTriggered: false,
+ triggerBadges: [],
+ communityIgnoredCount: 0,
+ totalPlayerIgnored: 0,
+ allPlayerBadges: [],
+ unreachableBadges: [],
+ candidateBadges: [],
+ lowestCII: null,
badges: [],
players: [],
adaptationCandidateBadge: null,
@@ -57,12 +61,12 @@ describe('GamificationIndicatorsController', () => {
it('should return indicators from service', async () => {
const result = await controller.getIndicators('proj1', {
- daysPerPeriod: 7,
+ threshold: 0.2,
asOfDate: '2026-06-15T00:00:00.000Z',
});
expect(service.computeIndicators).toHaveBeenCalledWith('proj1', {
- daysPerPeriod: 7,
+ threshold: 0.2,
asOfDate: '2026-06-15T00:00:00.000Z',
});
expect(result).toEqual(mockResponse);
diff --git a/src/module/gamification-indicators/gamification-indicators.controller.ts b/src/module/gamification-indicators/gamification-indicators.controller.ts
index 50ca3a7..fd101c6 100644
--- a/src/module/gamification-indicators/gamification-indicators.controller.ts
+++ b/src/module/gamification-indicators/gamification-indicators.controller.ts
@@ -19,13 +19,14 @@ export class GamificationIndicatorsController {
/**
* Triggers the calculation of adaptive gamification indicators for a project.
- * Computes community metrics (CMI, avgPMI) and badge metrics (CII, ET_b).
+ * Computes player metrics (AB(p), i3(p,b), ignored_by(p)), badge metrics (CII(b)),
+ * and §4.1/§4.2 vanishing badge adaptation triggers.
* Restricted to administrators to protect volunteer behavioral telemetry.
*/
@Get(':projectId')
@ApiOperation({
summary:
- 'Calculates and returns adaptive gamification indicators (CII, CMI, ET_b) for a project',
+ 'Calculates and returns adaptive gamification indicators (AB, i3, ignored_by, CII) for a project',
})
@ApiResponse({
status: 200,
diff --git a/src/module/gamification-indicators/gamification-indicators.service.spec.ts b/src/module/gamification-indicators/gamification-indicators.service.spec.ts
index 444fcb3..0bde4c9 100644
--- a/src/module/gamification-indicators/gamification-indicators.service.spec.ts
+++ b/src/module/gamification-indicators/gamification-indicators.service.spec.ts
@@ -39,15 +39,19 @@ describe('GamificationIndicatorsService', () => {
formulaStrategy = {
calculateIndicators: jest.fn().mockReturnValue({
projectId: 'proj1',
- currentPeriod: 1,
- startDate: new Date().toISOString(),
asOfDate: new Date().toISOString(),
- daysPerPeriod: 7,
+ threshold: 0.2,
totalPlayers: 0,
activePlayers: 0,
totalContributions: 0,
- avgPMI: 0,
- CMI: 0,
+ isTriggered: false,
+ triggerBadges: [],
+ communityIgnoredCount: 0,
+ totalPlayerIgnored: 0,
+ allPlayerBadges: [],
+ unreachableBadges: [],
+ candidateBadges: [],
+ lowestCII: null,
badges: [],
players: [],
adaptationCandidateBadge: null,
@@ -80,25 +84,24 @@ describe('GamificationIndicatorsService', () => {
).rejects.toThrow(BadRequestException);
});
- it('should throw BadRequestException if startDate is after asOfDate', async () => {
+ it('should throw BadRequestException if threshold is invalid or non-positive', async () => {
await expect(
service.computeIndicators('proj1', {
- startDate: '25-07-2026',
- asOfDate: '20-07-2026',
+ threshold: -0.1,
}),
).rejects.toThrow(BadRequestException);
});
- it('should parse DD-MM-YYYY format for asOfDate and startDate', async () => {
+ it('should parse DD-MM-YYYY format for asOfDate and custom threshold', async () => {
const res = await service.computeIndicators('proj1', {
- startDate: '01-07-2026',
asOfDate: '20-07-2026',
+ threshold: '0.25',
});
expect(res).toBeDefined();
expect(formulaStrategy.calculateIndicators).toHaveBeenCalledWith(
expect.objectContaining({
- startDate: expect.any(Date),
asOfDate: expect.any(Date),
+ threshold: 0.25,
}),
);
});
@@ -141,8 +144,8 @@ describe('GamificationIndicatorsService', () => {
]);
const result = await service.computeIndicators('proj1', {
- daysPerPeriod: 7,
asOfDate: '2026-06-15T00:00:00.000Z',
+ threshold: 0.2,
});
expect(gamificationDao.getGamificationByProjectId).toHaveBeenCalledWith(
diff --git a/src/module/gamification-indicators/gamification-indicators.service.ts b/src/module/gamification-indicators/gamification-indicators.service.ts
index d54fd11..4aae5ab 100644
--- a/src/module/gamification-indicators/gamification-indicators.service.ts
+++ b/src/module/gamification-indicators/gamification-indicators.service.ts
@@ -43,16 +43,19 @@ export class GamificationIndicatorsService {
projectId: string,
query?: GetIndicatorsQueryDto,
): Promise {
- const daysPerPeriod = Math.max(1, Number(query?.daysPerPeriod) || 7);
- const startDate = query?.startDate
- ? this.parseDate(query.startDate, 'start', 'startDate')
- : undefined;
const asOfDate = query?.asOfDate
? this.parseDate(query.asOfDate, 'end', 'asOfDate')
: new Date();
- if (startDate && startDate.getTime() > asOfDate.getTime()) {
- throw new BadRequestException('startDate cannot be after asOfDate');
+ let threshold = 0.2;
+ if (query?.threshold !== undefined && query?.threshold !== '') {
+ const parsedThreshold = Number(query.threshold);
+ if (isNaN(parsedThreshold) || parsedThreshold <= 0) {
+ throw new BadRequestException(
+ `Invalid threshold value: "${query.threshold}". Must be a positive number (e.g. 0.20).`,
+ );
+ }
+ threshold = parsedThreshold;
}
// 1. Fetch project gamification rules
@@ -101,9 +104,10 @@ export class GamificationIndicatorsService {
const checkins: CheckinRecord[] = rawCheckins.map((c) => {
const chId = String(c.id);
const move = moveByCheckinId.get(chId);
+ const rawUserId = c.userId || c.user?.id || (c as any).userId;
return {
id: chId,
- userId: c.userId || c.user?.id || (c as any).userId,
+ userId: rawUserId ? String(rawUserId) : '',
datetime: new Date(c.date),
taskType: c.taskType,
contributesTo: c.contributesTo,
@@ -113,13 +117,14 @@ export class GamificationIndicatorsService {
// Build player profiles and resolve earned badges with timestamps & contribution counts
const players: PlayerProfile[] = rawUsers.map((u) => {
+ const uId = String(u.id);
const earnedBadges = new Map();
const projectProfile = u.getGameProfileFromProject(projectId);
const profileBadges = projectProfile?.badges || [];
// Find user check-ins sorted chronologically
const userCheckins = checkins
- .filter((c) => c.userId === u.id)
+ .filter((c) => String(c.userId) === uId)
.sort((a, b) => a.datetime.getTime() - b.datetime.getTime());
let runningContribCount = 0;
@@ -156,15 +161,16 @@ export class GamificationIndicatorsService {
});
return {
- id: u.id,
+ id: uId,
joinDate: u.createdAt || asOfDate,
earnedBadges,
};
});
- const minActiveCheckins = query.minActiveCheckins
- ? parseInt(String(query.minActiveCheckins), 10)
- : 1;
+ const minActiveCheckins =
+ query?.minActiveCheckins !== undefined
+ ? parseInt(String(query.minActiveCheckins), 10)
+ : 0;
// 5. Build computation context and delegate to formula strategy
const ctx: IndicatorComputationContext = {
@@ -172,10 +178,9 @@ export class GamificationIndicatorsService {
badges,
players,
checkins,
- startDate,
asOfDate,
- daysPerPeriod,
- minActiveCheckins: isNaN(minActiveCheckins) ? 1 : minActiveCheckins,
+ threshold,
+ minActiveCheckins: isNaN(minActiveCheckins) ? 0 : minActiveCheckins,
};
return this.formulaStrategy.calculateIndicators(ctx);