mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
import type { MetricsActivitySplit } from "@hexclave/shared/dist/interface/admin-metrics";
|
|
|
|
export type ActivitySplit = MetricsActivitySplit;
|
|
|
|
export function createEmptySplitSeries(days: string[]): ActivitySplit {
|
|
const emptySeries = days.map((date) => ({ date, activity: 0 }));
|
|
return {
|
|
total: emptySeries.map((item) => ({ ...item })),
|
|
new: emptySeries.map((item) => ({ ...item })),
|
|
retained: emptySeries.map((item) => ({ ...item })),
|
|
reactivated: emptySeries.map((item) => ({ ...item })),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Bucket each day's active entity ids into new / retained / reactivated counts.
|
|
*
|
|
* Classification rules (in order):
|
|
* 1. createdDay === current day → new
|
|
* 2. entity was active on the immediately previous day → retained
|
|
* 3. entity was active earlier in the window → reactivated
|
|
* 4. createdDay falls inside the window → new (gap-day case)
|
|
* 5. otherwise (created before window, or unknown) → reactivated
|
|
* (avoids inflating "new" for pre-existing entities first seen inside the window)
|
|
*/
|
|
export function buildSplitFromDailyEntitySets(options: {
|
|
orderedDays: string[],
|
|
entityIdsByDay: Map<string, Set<string>>,
|
|
createdDayByEntityId?: Map<string, string>,
|
|
}): ActivitySplit {
|
|
const { orderedDays, entityIdsByDay, createdDayByEntityId } = options;
|
|
const split = createEmptySplitSeries(orderedDays);
|
|
const windowStart = orderedDays[0];
|
|
const previouslySeen = new Set<string>();
|
|
let previousDaySet = new Set<string>();
|
|
|
|
for (let i = 0; i < orderedDays.length; i += 1) {
|
|
const day = orderedDays[i];
|
|
const currentDaySet = entityIdsByDay.get(day) ?? new Set<string>();
|
|
let newCount = 0;
|
|
let retainedCount = 0;
|
|
let reactivatedCount = 0;
|
|
|
|
for (const entityId of currentDaySet) {
|
|
const createdDay = createdDayByEntityId?.get(entityId);
|
|
if (createdDay === day) {
|
|
newCount += 1;
|
|
} else if (previousDaySet.has(entityId)) {
|
|
retainedCount += 1;
|
|
} else if (previouslySeen.has(entityId)) {
|
|
reactivatedCount += 1;
|
|
} else if (createdDay != null && createdDay >= windowStart) {
|
|
// Created within the window on a different day, but not active on the
|
|
// immediately previous day — treat as new (gap-day case).
|
|
newCount += 1;
|
|
} else {
|
|
// Either created before the window started, or createdDay is unknown.
|
|
// Either way, we cannot legitimately bucket this as "new" — count as
|
|
// reactivated to avoid inflating new-user metrics for pre-existing
|
|
// entities first seen inside the window.
|
|
reactivatedCount += 1;
|
|
}
|
|
}
|
|
|
|
split.total[i].activity = currentDaySet.size;
|
|
split.new[i].activity = newCount;
|
|
split.retained[i].activity = retainedCount;
|
|
split.reactivated[i].activity = reactivatedCount;
|
|
|
|
for (const entityId of currentDaySet) {
|
|
previouslySeen.add(entityId);
|
|
}
|
|
previousDaySet = currentDaySet;
|
|
}
|
|
|
|
return split;
|
|
}
|
|
|
|
if (import.meta.vitest) {
|
|
const { test, expect, describe } = import.meta.vitest;
|
|
|
|
// Three-day window for the simple cases below.
|
|
const days = ['2026-04-01', '2026-04-02', '2026-04-03'];
|
|
|
|
describe("buildSplitFromDailyEntitySets", () => {
|
|
test("classifies a user active only today as new when their createdDay === today", () => {
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map([
|
|
['2026-04-01', new Set()],
|
|
['2026-04-02', new Set()],
|
|
['2026-04-03', new Set(['user-a'])],
|
|
]),
|
|
createdDayByEntityId: new Map([['user-a', '2026-04-03']]),
|
|
});
|
|
expect(split.new.map((d) => d.activity)).toEqual([0, 0, 1]);
|
|
expect(split.retained.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
expect(split.total.map((d) => d.activity)).toEqual([0, 0, 1]);
|
|
});
|
|
|
|
test("classifies a user active on consecutive days as new on day 1 and retained on day 2", () => {
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map([
|
|
['2026-04-01', new Set(['user-a'])],
|
|
['2026-04-02', new Set(['user-a'])],
|
|
['2026-04-03', new Set()],
|
|
]),
|
|
createdDayByEntityId: new Map([['user-a', '2026-04-01']]),
|
|
});
|
|
expect(split.new.map((d) => d.activity)).toEqual([1, 0, 0]);
|
|
expect(split.retained.map((d) => d.activity)).toEqual([0, 1, 0]);
|
|
expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
});
|
|
|
|
test("classifies a user with a gap day as new then reactivated", () => {
|
|
// Active day 1, missing day 2, active day 3 → reactivated on day 3
|
|
// because they were previously seen but not on the immediately previous day.
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map([
|
|
['2026-04-01', new Set(['user-a'])],
|
|
['2026-04-02', new Set()],
|
|
['2026-04-03', new Set(['user-a'])],
|
|
]),
|
|
createdDayByEntityId: new Map([['user-a', '2026-04-01']]),
|
|
});
|
|
expect(split.new.map((d) => d.activity)).toEqual([1, 0, 0]);
|
|
expect(split.retained.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 1]);
|
|
});
|
|
|
|
test("classifies a user created in-window with a gap before first activity as new (rule 4)", () => {
|
|
// createdDay is 2026-04-02 but they're not active on 2026-04-02.
|
|
// First active 2026-04-03 → bucket as new (created within window),
|
|
// not reactivated, because they have never been seen before.
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map([
|
|
['2026-04-01', new Set()],
|
|
['2026-04-02', new Set()],
|
|
['2026-04-03', new Set(['user-a'])],
|
|
]),
|
|
createdDayByEntityId: new Map([['user-a', '2026-04-02']]),
|
|
});
|
|
expect(split.new.map((d) => d.activity)).toEqual([0, 0, 1]);
|
|
expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
});
|
|
|
|
test("classifies a user created before the window as reactivated, never new (rule 5)", () => {
|
|
// createdDay is 2026-03-15 (before window). They are first seen on 2026-04-02.
|
|
// Should bucket as reactivated to avoid inflating new-user metrics with
|
|
// pre-existing entities that just happened to log in inside the window.
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map([
|
|
['2026-04-01', new Set()],
|
|
['2026-04-02', new Set(['user-a'])],
|
|
['2026-04-03', new Set(['user-a'])],
|
|
]),
|
|
createdDayByEntityId: new Map([['user-a', '2026-03-15']]),
|
|
});
|
|
expect(split.new.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
expect(split.retained.map((d) => d.activity)).toEqual([0, 0, 1]);
|
|
expect(split.reactivated.map((d) => d.activity)).toEqual([0, 1, 0]);
|
|
});
|
|
|
|
test("treats unknown createdDay as 'never new' (rule 5 fallback)", () => {
|
|
// user-a is active in the window but has no createdDay record. The
|
|
// function should not bucket them as new — falls into reactivated.
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map([
|
|
['2026-04-01', new Set()],
|
|
['2026-04-02', new Set(['user-a'])],
|
|
['2026-04-03', new Set()],
|
|
]),
|
|
createdDayByEntityId: new Map(),
|
|
});
|
|
expect(split.new.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
expect(split.reactivated.map((d) => d.activity)).toEqual([0, 1, 0]);
|
|
});
|
|
|
|
test("handles multiple users with different classification on the same day", () => {
|
|
// Day 3:
|
|
// - user-a created day 3 → new
|
|
// - user-b active day 2 + day 3 → retained
|
|
// - user-c active day 1 then day 3 (gap) → reactivated
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map([
|
|
['2026-04-01', new Set(['user-c'])],
|
|
['2026-04-02', new Set(['user-b'])],
|
|
['2026-04-03', new Set(['user-a', 'user-b', 'user-c'])],
|
|
]),
|
|
createdDayByEntityId: new Map([
|
|
['user-a', '2026-04-03'],
|
|
['user-b', '2026-04-02'],
|
|
['user-c', '2026-04-01'],
|
|
]),
|
|
});
|
|
expect(split.total[2].activity).toBe(3);
|
|
expect(split.new[2].activity).toBe(1); // user-a
|
|
expect(split.retained[2].activity).toBe(1); // user-b
|
|
expect(split.reactivated[2].activity).toBe(1); // user-c
|
|
});
|
|
|
|
test("returns all-zero series when no entities are active", () => {
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map(),
|
|
createdDayByEntityId: new Map(),
|
|
});
|
|
expect(split.total.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
expect(split.new.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
expect(split.retained.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 0]);
|
|
});
|
|
|
|
test("preserves the orderedDays date list across all four series", () => {
|
|
const split = buildSplitFromDailyEntitySets({
|
|
orderedDays: days,
|
|
entityIdsByDay: new Map(),
|
|
});
|
|
const dateList = days;
|
|
expect(split.total.map((d) => d.date)).toEqual(dateList);
|
|
expect(split.new.map((d) => d.date)).toEqual(dateList);
|
|
expect(split.retained.map((d) => d.date)).toEqual(dateList);
|
|
expect(split.reactivated.map((d) => d.date)).toEqual(dateList);
|
|
});
|
|
});
|
|
|
|
describe("createEmptySplitSeries", () => {
|
|
test("returns independent series objects (not aliased)", () => {
|
|
const split = createEmptySplitSeries(['2026-04-01', '2026-04-02']);
|
|
split.new[0].activity = 5;
|
|
// Mutating .new should not bleed into .total/.retained/.reactivated.
|
|
expect(split.total[0].activity).toBe(0);
|
|
expect(split.retained[0].activity).toBe(0);
|
|
expect(split.reactivated[0].activity).toBe(0);
|
|
});
|
|
});
|
|
}
|