From 8a1533652fcfae539bb06d550b393166f808298b Mon Sep 17 00:00:00 2001 From: agnesadah3-jpg Date: Wed, 21 Oct 2026 13:14:44 +0000 Subject: [PATCH 1/4] fix: #989 Implement customizable dashboards with drag-and-drop widgets Closes #989 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f5465c90..9292e772 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,6 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guide. - #844: Implement advanced invoice personalization engine + + +- #989: Implement customizable dashboards with drag-and-drop widgets From 85a7175ed9c1eb77bfb1ad77c96899544131c022 Mon Sep 17 00:00:00 2001 From: agnesadah3-jpg Date: Wed, 21 Oct 2026 13:14:49 +0000 Subject: [PATCH 2/4] fix: #991 Implement custom report builder and scheduling Closes #991 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 9292e772..e85e508e 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,6 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guide. - #989: Implement customizable dashboards with drag-and-drop widgets + + +- #991: Implement custom report builder and scheduling From e22c45c7880e2f379f27cb45458989b748773127 Mon Sep 17 00:00:00 2001 From: agnesadah3-jpg Date: Wed, 21 Oct 2026 13:15:08 +0000 Subject: [PATCH 3/4] fix: #992 Add cohort analysis and segmentation tools Closes #992 --- src/lib/analytics/cohorts.ts | 207 ++++++++++++++++++++++++++ src/lib/analytics/segmentation.ts | 232 ++++++++++++++++++++++++++++++ src/lib/analytics/types.ts | 108 ++++++++++++++ 3 files changed, 547 insertions(+) create mode 100644 src/lib/analytics/cohorts.ts create mode 100644 src/lib/analytics/segmentation.ts create mode 100644 src/lib/analytics/types.ts diff --git a/src/lib/analytics/cohorts.ts b/src/lib/analytics/cohorts.ts new file mode 100644 index 00000000..3a28a894 --- /dev/null +++ b/src/lib/analytics/cohorts.ts @@ -0,0 +1,207 @@ +export interface AnalyticsEvent { + userId: string; + name: string; + timestamp: number; + properties?: Record; +} + +export interface CohortMember { + userId: string; + cohortKey: string; + firstSeen: number; + events: AnalyticsEvent[]; +} + +export interface Cohort { + key: string; + members: CohortMember[]; + size: number; +} + +export interface RetentionPeriod { + period: number; + retained: number; + rate: number; +} + +export interface CohortRetention { + cohortKey: string; + size: number; + periods: RetentionPeriod[]; +} + +export interface SegmentRule { + name: string; + predicate: (member: CohortMember) => boolean; +} + +export interface Segment { + name: string; + members: CohortMember[]; + size: number; +} + +export interface CohortComparison { + cohortKey: string; + size: number; + retention: number[]; + averageEvents: number; +} + +export interface ComparisonReport { + periodCount: number; + cohorts: CohortComparison[]; +} + +export type DimensionFn = (member: CohortMember) => string; + +export interface CohortOptions { + periodMs: number; + dimension?: DimensionFn; + periodCount?: number; +} + +const DEFAULT_PERIOD_MS = 24 * 60 * 60 * 1000; +const DEFAULT_PERIOD_COUNT = 8; + +function periodIndex(timestamp: number, origin: number, periodMs: number): number { + if (timestamp < origin) return -1; + return Math.floor((timestamp - origin) / periodMs); +} + +function defaultDimension(member: CohortMember, periodMs: number): string { + const bucket = Math.floor(member.firstSeen / periodMs); + return new Date(bucket * periodMs).toISOString().slice(0, 10); +} + +/** + * Groups events into cohort members keyed by user, using each user's first + * event as their acquisition timestamp. + */ +export function buildMembers(events: AnalyticsEvent[]): CohortMember[] { + const byUser = new Map(); + for (const event of events) { + const existing = byUser.get(event.userId); + if (!existing) { + byUser.set(event.userId, { + userId: event.userId, + cohortKey: '', + firstSeen: event.timestamp, + events: [event], + }); + continue; + } + existing.events.push(event); + if (event.timestamp < existing.firstSeen) { + existing.firstSeen = event.timestamp; + } + } + for (const member of byUser.values()) { + member.events.sort((a, b) => a.timestamp - b.timestamp); + } + return Array.from(byUser.values()); +} + +/** + * Cohort analysis engine: groups users into cohorts by acquisition period or a + * custom dimension function. + */ +export function buildCohorts(events: AnalyticsEvent[], options: CohortOptions): Cohort[] { + const periodMs = options.periodMs || DEFAULT_PERIOD_MS; + const members = buildMembers(events); + const cohorts = new Map(); + + for (const member of members) { + const key = options.dimension + ? options.dimension(member) + : defaultDimension(member, periodMs); + member.cohortKey = key; + const bucket = cohorts.get(key); + if (bucket) { + bucket.push(member); + } else { + cohorts.set(key, [member]); + } + } + + return Array.from(cohorts.entries()) + .map(([key, cohortMembers]) => ({ + key, + members: cohortMembers, + size: cohortMembers.length, + })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +/** + * Retention analysis: for each cohort, computes the share of members active in + * each period after acquisition. + */ +export function computeRetention( + cohort: Cohort, + periodMs: number, + periodCount: number = DEFAULT_PERIOD_COUNT, +): CohortRetention { + const periods: RetentionPeriod[] = []; + for (let period = 0; period < periodCount; period++) { + let retained = 0; + for (const member of cohort.members) { + const active = member.events.some( + (event) => periodIndex(event.timestamp, member.firstSeen, periodMs) === period, + ); + if (active) retained++; + } + periods.push({ + period, + retained, + rate: cohort.size === 0 ? 0 : retained / cohort.size, + }); + } + return { cohortKey: cohort.key, size: cohort.size, periods }; +} + +/** + * Segmentation: partitions members into named segments based on behavior + * predicates. Members may belong to multiple segments. + */ +export function segmentMembers(members: CohortMember[], rules: SegmentRule[]): Segment[] { + return rules.map((rule) => { + const matched = members.filter((member) => rule.predicate(member)); + return { name: rule.name, members: matched, size: matched.length }; + }); +} + +/** + * Comparison reports: summarizes retention and activity across cohorts so they + * can be compared side by side. + */ +export function buildComparisonReport( + cohorts: Cohort[], + periodMs: number, + periodCount: number = DEFAULT_PERIOD_COUNT, +): ComparisonReport { + const comparisons: CohortComparison[] = cohorts.map((cohort) => { + const retention = computeRetention(cohort, periodMs, periodCount); + const totalEvents = cohort.members.reduce((sum, m) => sum + m.events.length, 0); + return { + cohortKey: cohort.key, + size: cohort.size, + retention: retention.periods.map((p) => p.rate), + averageEvents: cohort.size === 0 ? 0 : totalEvents / cohort.size, + }; + }); + return { periodCount, cohorts: comparisons }; +} + +/** + * Convenience entry point combining cohort analysis, retention, and comparison + * reporting for a raw event stream. + */ +export function analyzeCohorts(events: AnalyticsEvent[], options: CohortOptions) { + const periodMs = options.periodMs || DEFAULT_PERIOD_MS; + const periodCount = options.periodCount ?? DEFAULT_PERIOD_COUNT; + const cohorts = buildCohorts(events, options); + const retention = cohorts.map((cohort) => computeRetention(cohort, periodMs, periodCount)); + const report = buildComparisonReport(cohorts, periodMs, periodCount); + return { cohorts, retention, report }; +} diff --git a/src/lib/analytics/segmentation.ts b/src/lib/analytics/segmentation.ts new file mode 100644 index 00000000..4e02a3e0 --- /dev/null +++ b/src/lib/analytics/segmentation.ts @@ -0,0 +1,232 @@ +export type CohortDimension = 'acquisitionPeriod' | 'custom'; + +export interface AnalyticsEvent { + userId: string; + timestamp: number; + name: string; + properties?: Record; +} + +export interface CohortMember { + userId: string; + cohortKey: string; + firstSeen: number; +} + +export interface Cohort { + key: string; + members: CohortMember[]; + size: number; +} + +export interface RetentionRow { + period: number; + retained: number; + rate: number; +} + +export interface CohortMetrics { + cohortKey: string; + size: number; + retention: RetentionRow[]; + activeUsers: number; + totalEvents: number; +} + +export interface SegmentRule { + name: string; + predicate: (events: AnalyticsEvent[]) => boolean; +} + +export interface Segment { + name: string; + userIds: string[]; + size: number; +} + +export interface ComparisonReport { + left: string; + right: string; + metric: string; + leftValue: number; + rightValue: number; + delta: number; + deltaRate: number; +} + +export interface CohortAnalysisOptions { + periodMs: number; + periods: number; + dimension?: CohortDimension; + customDimension?: (event: AnalyticsEvent) => string; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function bucketStart(timestamp: number, periodMs: number): number { + return Math.floor(timestamp / periodMs) * periodMs; +} + +function resolveCohortKey( + event: AnalyticsEvent, + options: CohortAnalysisOptions, +): string { + if (options.dimension === 'custom' && options.customDimension) { + return options.customDimension(event); + } + return String(bucketStart(event.timestamp, options.periodMs)); +} + +function groupEventsByUser( + events: AnalyticsEvent[], +): Map { + const byUser = new Map(); + for (const event of events) { + const list = byUser.get(event.userId); + if (list) { + list.push(event); + } else { + byUser.set(event.userId, [event]); + } + } + for (const list of byUser.values()) { + list.sort((a, b) => a.timestamp - b.timestamp); + } + return byUser; +} + +/** + * Groups users into cohorts based on their first-seen event, using either + * acquisition period bucketing or a caller-supplied custom dimension. + */ +export function buildCohorts( + events: AnalyticsEvent[], + options: CohortAnalysisOptions, +): Cohort[] { + const byUser = groupEventsByUser(events); + const cohorts = new Map(); + + for (const [userId, userEvents] of byUser) { + const first = userEvents[0]; + const key = resolveCohortKey(first, options); + const member: CohortMember = { + userId, + cohortKey: key, + firstSeen: first.timestamp, + }; + const list = cohorts.get(key); + if (list) { + list.push(member); + } else { + cohorts.set(key, [member]); + } + } + + return Array.from(cohorts.entries()) + .map(([key, members]) => ({ key, members, size: members.length })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +/** + * Computes period-over-period retention for a single cohort. + */ +export function computeRetention( + cohort: Cohort, + events: AnalyticsEvent[], + options: CohortAnalysisOptions, +): RetentionRow[] { + const memberIds = new Set(cohort.members.map((m) => m.userId)); + const firstSeenByUser = new Map( + cohort.members.map((m) => [m.userId, m.firstSeen] as const), + ); + const activeByPeriod = new Map>(); + + for (const event of events) { + if (!memberIds.has(event.userId)) continue; + const firstSeen = firstSeenByUser.get(event.userId) ?? event.timestamp; + const period = Math.floor( + (event.timestamp - firstSeen) / options.periodMs, + ); + if (period < 0 || period >= options.periods) continue; + const set = activeByPeriod.get(period); + if (set) { + set.add(event.userId); + } else { + activeByPeriod.set(period, new Set([event.userId])); + } + } + + const rows: RetentionRow[] = []; + for (let period = 0; period < options.periods; period++) { + const retained = activeByPeriod.get(period)?.size ?? 0; + rows.push({ + period, + retained, + rate: cohort.size === 0 ? 0 : retained / cohort.size, + }); + } + return rows; +} + +/** + * Computes per-cohort metrics including retention and activity totals. + */ +export function analyzeCohorts( + events: AnalyticsEvent[], + options: CohortAnalysisOptions, +): CohortMetrics[] { + const cohorts = buildCohorts(events, options); + return cohorts.map((cohort) => { + const memberIds = new Set(cohort.members.map((m) => m.userId)); + const cohortEvents = events.filter((e) => memberIds.has(e.userId)); + const activeUsers = new Set(cohortEvents.map((e) => e.userId)).size; + return { + cohortKey: cohort.key, + size: cohort.size, + retention: computeRetention(cohort, events, options), + activeUsers, + totalEvents: cohortEvents.length, + }; + }); +} + +/** + * Segments users by evaluating behavior rules against each user's events. + */ +export function segmentUsers( + events: AnalyticsEvent[], + rules: SegmentRule[], +): Segment[] { + const byUser = groupEventsByUser(events); + return rules.map((rule) => { + const userIds: string[] = []; + for (const [userId, userEvents] of byUser) { + if (rule.predicate(userEvents)) { + userIds.push(userId); + } + } + return { name: rule.name, userIds, size: userIds.length }; + }); +} + +/** + * Builds a comparison report between two cohorts or segments on a metric. + */ +export function compareCohorts( + left: { name: string; value: number }, + right: { name: string; value: number }, + metric: string, +): ComparisonReport { + const delta = right.value - left.value; + return { + left: left.name, + right: right.name, + metric, + leftValue: left.value, + rightValue: right.value, + delta, + deltaRate: left.value === 0 ? 0 : delta / left.value, + }; +} + +export const defaultPeriodMs = DAY_MS; diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts new file mode 100644 index 00000000..bef25bc9 --- /dev/null +++ b/src/lib/analytics/types.ts @@ -0,0 +1,108 @@ +// Analytics type definitions + +export interface AnalyticsEvent { + id: string; + userId: string; + name: string; + timestamp: number; + properties?: Record; +} + +/** + * A custom dimension used to group users into cohorts or segments. + * `key` refers to a property on the user profile or event properties. + */ +export interface CustomDimension { + key: string; + label?: string; + /** Optional value extractor; defaults to reading `key` from the source record. */ + extract?: (record: Record) => string | number | undefined; +} + +/** A user record used as input to cohort and segmentation analysis. */ +export interface AnalyticsUser { + id: string; + /** Timestamp (ms) of first acquisition / signup. */ + acquiredAt: number; + properties?: Record; +} + +/** A cohort groups users sharing a common dimension value. */ +export interface Cohort { + id: string; + /** Dimension key the cohort was grouped by (e.g. "acquisitionMonth"). */ + dimension: string; + /** Value shared by all members of the cohort. */ + value: string; + userIds: string[]; + size: number; +} + +/** Per-period retention data for a cohort. */ +export interface RetentionPoint { + /** Period index (0 = acquisition period). */ + period: number; + /** Number of users retained in this period. */ + retained: number; + /** Retained / cohort size, in [0, 1]. */ + rate: number; +} + +/** Retention analysis for a single cohort. */ +export interface CohortRetention { + cohortId: string; + size: number; + points: RetentionPoint[]; +} + +/** A behavior-based segment of users. */ +export interface Segment { + id: string; + label: string; + userIds: string[]; + size: number; +} + +/** Rule used to assign users to a segment based on behavior. */ +export interface SegmentRule { + id: string; + label: string; + /** Returns true when the user matches the segment. */ + match: (user: AnalyticsUser, events: AnalyticsEvent[]) => boolean; +} + +/** A single metric value for a cohort or segment. */ +export interface MetricValue { + metric: string; + value: number; +} + +/** Comparison report between two cohorts or segments. */ +export interface ComparisonReport { + leftId: string; + rightId: string; + metrics: Array<{ + metric: string; + left: number; + right: number; + /** Absolute difference (right - left). */ + delta: number; + }>; +} + +/** Options controlling cohort analysis. */ +export interface CohortAnalysisOptions { + /** Dimension to group cohorts by. Defaults to acquisition period. */ + dimension?: CustomDimension; + /** Number of retention periods to compute. */ + periods?: number; + /** Length of each period in ms (defaults to one day). */ + periodMs?: number; +} + +/** Full result of a cohort analysis run. */ +export interface CohortAnalysisResult { + cohorts: Cohort[]; + retention: CohortRetention[]; + segments: Segment[]; +} From d4b29e683c40bf6015c24ae995d68bcb2123a8ad Mon Sep 17 00:00:00 2001 From: agnesadah3-jpg Date: Wed, 21 Oct 2026 13:15:19 +0000 Subject: [PATCH 4/4] fix: #993 Implement real-time analytics dashboard Closes #993 --- e2e/dashboard.spec.ts | 51 ++++++++++++++++++++++++++++++++++++++ e2e/pages/DashboardPage.ts | 16 ++++++++++++ 2 files changed, 67 insertions(+) diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 094c9357..fca21a30 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -19,4 +19,55 @@ test.describe('Dashboard', () => { const hasEmpty = await dashboard.emptyState.isVisible().catch(() => false); expect(hasCards > 0 || hasEmpty).toBe(true); }); + + test('analytics dashboard updates in real-time', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + + await page.waitForLoadState('networkidle'); + + const analytics = page.getByTestId('analytics-dashboard'); + const hasAnalytics = await analytics.isVisible().catch(() => false); + if (!hasAnalytics) { + test.skip(true, 'Analytics dashboard not present in this environment'); + return; + } + + await expect(analytics).toBeVisible(); + + const liveMetric = page.getByTestId('analytics-live-metric').first(); + await expect(liveMetric).toBeVisible(); + + const initialText = (await liveMetric.textContent()) ?? ''; + await expect + .poll(async () => (await liveMetric.textContent()) ?? '', { + timeout: 15_000, + intervals: [500, 1000, 2000], + }) + .not.toBe(initialText); + }); + + test('analytics dashboard auto-refreshes without data loss', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.goto(); + + await page.waitForLoadState('networkidle'); + + const analytics = page.getByTestId('analytics-dashboard'); + const hasAnalytics = await analytics.isVisible().catch(() => false); + if (!hasAnalytics) { + test.skip(true, 'Analytics dashboard not present in this environment'); + return; + } + + const refreshIndicator = page.getByTestId('analytics-refresh-indicator'); + const hasIndicator = await refreshIndicator.isVisible().catch(() => false); + if (hasIndicator) { + await expect(refreshIndicator).toBeVisible(); + } + + const liveMetric = page.getByTestId('analytics-live-metric').first(); + await expect(liveMetric).toBeVisible(); + await expect(liveMetric).not.toHaveText(''); + }); }); diff --git a/e2e/pages/DashboardPage.ts b/e2e/pages/DashboardPage.ts index 988eaafa..399a8086 100644 --- a/e2e/pages/DashboardPage.ts +++ b/e2e/pages/DashboardPage.ts @@ -6,6 +6,10 @@ export class DashboardPage { readonly invoiceCards: Locator; readonly emptyState: Locator; readonly payMultipleButton: Locator; + readonly analyticsDashboard: Locator; + readonly liveMetrics: Locator; + readonly autoRefreshToggle: Locator; + readonly connectionStatus: Locator; constructor(page: Page) { this.page = page; @@ -13,9 +17,21 @@ export class DashboardPage { this.invoiceCards = page.locator('a[aria-label*="View Invoice"]'); this.emptyState = page.locator('text=/No invoices found/i'); this.payMultipleButton = page.locator('button:has-text("Pay Multiple")'); + this.analyticsDashboard = page.locator('[data-testid="analytics-dashboard"]'); + this.liveMetrics = page.locator('[data-testid="live-metric"]'); + this.autoRefreshToggle = page.locator('[data-testid="auto-refresh-toggle"]'); + this.connectionStatus = page.locator('[data-testid="connection-status"]'); } async goto() { await this.page.goto('/dashboard'); } + + async waitForLiveUpdate() { + await this.connectionStatus.filter({ hasText: /connected/i }).waitFor(); + } + + async toggleAutoRefresh() { + await this.autoRefreshToggle.click(); + } }