This implementation adds a comprehensive cohort and retention analysis API to the Trivela platform, enabling campaign operators to answer questions like "of users who registered in week N, how many claimed by week N+k?"
user_activitiestable: Tracks all user events (registered, claimed, active)cohort_statstable: Precomputed cohort statistics for performanceretention_datatable: Precomputed retention curves
- Repository (
sqliteCohortRepository.js): Complete data access for cohort analysis- Record user activities
- Save/retrieve cohort statistics
- Save/retrieve retention data
- Support for cache invalidation
- Service (
cohortService.js):- Compute cohorts by registration period
- Calculate retention curves with offset tracking
- Support for multiple granularities (day, week, month)
- Support for multiple metrics (claimed, active)
- Deterministic and testable outputs
- Caching with recomputation support
All endpoints under /api/v1/campaigns/:campaignId/cohorts (requires API key):
GET /campaigns/:campaignId/cohorts- Get full cohort analysis with retention curves- Query params:
granularity(day/week/month),metric(claimed/active),recompute(bool)
- Query params:
GET /campaigns/:campaignId/cohorts/:cohortPeriod/retention- Get retention curve for specific cohort- Query params:
granularity,metric
- Query params:
POST /campaigns/:campaignId/cohorts/recompute- Force recomputation of cohort data- Query params:
granularity,metric
- Query params:
POST /campaigns/:campaignId/activities- Record user activity (for testing/manual entry)- Body:
{ userAddress, activityType, occurredAt?, metadata? }
- Body:
- Zod schemas for request/response validation
- Comprehensive unit tests with deterministic fixtures
- Hand-computed expected values for verification
- Tests cover all granularities and metric types
A cohort is a group of users who registered in the same time period (day, week, or month). Cohorts are identified by period strings:
- Day:
2024-01-15 - Week:
2024-W03(ISO week number) - Month:
2024-01
Retention measures how many users from a cohort performed an activity at a given offset from their registration:
- Offset 0: Same period as registration
- Offset 1: One period later
- Offset 2: Two periods later
- etc.
Retention Rate = (Users who performed activity at offset) / (Cohort size) × 100%
- UTC timezone: All timestamps are normalized to UTC
- Week numbering: ISO 8601 week-date system (week 1 contains first Thursday)
- Period boundaries: Inclusive start, exclusive end
The algorithm assigns users to cohorts based on their registration timestamp:
registrationDate → getPeriodString(date, granularity) → cohortPeriodActivities are matched to cohorts, and offset is calculated:
cohortPeriod + activityPeriod + granularity → offset- Precomputation: Cohort stats and retention data are computed once and cached
- Recomputation: Can be triggered manually or when
recompute=true - Cache invalidation:
clearCache()removes all cached data for a campaign
# Get weekly cohorts with claim retention
curl "http://localhost:3001/api/v1/campaigns/1/cohorts?granularity=week&metric=claimed" \
-H "X-API-Key: your-api-key"Response:
{
"campaignId": "1",
"granularity": "week",
"metricType": "claimed",
"cohorts": [
{
"cohortPeriod": "2024-W01",
"cohortSize": 150,
"periodStart": "2024-01-01T00:00:00.000Z",
"periodEnd": "2024-01-08T00:00:00.000Z",
"retention": [
{ "offset": 0, "userCount": 100, "retentionRate": 66.67 },
{ "offset": 1, "userCount": 75, "retentionRate": 50.0 },
{ "offset": 2, "userCount": 45, "retentionRate": 30.0 }
]
},
{
"cohortPeriod": "2024-W02",
"cohortSize": 200,
"periodStart": "2024-01-08T00:00:00.000Z",
"periodEnd": "2024-01-15T00:00:00.000Z",
"retention": [
{ "offset": 0, "userCount": 140, "retentionRate": 70.0 },
{ "offset": 1, "userCount": 100, "retentionRate": 50.0 }
]
}
]
}# Get daily cohorts with active user retention
curl "http://localhost:3001/api/v1/campaigns/1/cohorts?granularity=day&metric=active" \
-H "X-API-Key: your-api-key"# Get retention curve for week 1
curl "http://localhost:3001/api/v1/campaigns/1/cohorts/2024-W01/retention?granularity=week&metric=claimed" \
-H "X-API-Key: your-api-key"Response:
{
"cohortPeriod": "2024-W01",
"cohortSize": 150,
"retention": [
{ "offset": 0, "userCount": 100, "retentionRate": 66.67 },
{ "offset": 1, "userCount": 75, "retentionRate": 50.0 },
{ "offset": 2, "userCount": 45, "retentionRate": 30.0 },
{ "offset": 3, "userCount": 30, "retentionRate": 20.0 }
]
}# Recompute cohort data (after reconciliation or data updates)
curl -X POST "http://localhost:3001/api/v1/campaigns/1/cohorts/recompute?granularity=week&metric=claimed" \
-H "X-API-Key: your-api-key"# Record user registration
curl -X POST "http://localhost:3001/api/v1/campaigns/1/activities" \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"userAddress": "GABC...XYZ",
"activityType": "registered",
"occurredAt": "2024-01-15T10:30:00Z"
}'
# Record user claim
curl -X POST "http://localhost:3001/api/v1/campaigns/1/activities" \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"userAddress": "GABC...XYZ",
"activityType": "claimed",
"occurredAt": "2024-01-20T14:15:00Z"
}'| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key |
| campaign_id | INTEGER | Foreign key to campaigns |
| user_address | TEXT | User identifier (wallet address) |
| activity_type | TEXT | 'registered', 'claimed', 'active' |
| occurred_at | TEXT | ISO 8601 timestamp (UTC) |
| ledger | INTEGER | Optional: on-chain ledger number |
| tx_hash | TEXT | Optional: transaction hash |
| metadata | TEXT | JSON blob for additional context |
| created_at | TEXT | Record creation timestamp |
Indexes:
campaign_idcampaign_id, user_addresscampaign_id, activity_typecampaign_id, occurred_atcampaign_id, user_address, activity_type
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key |
| campaign_id | INTEGER | Foreign key to campaigns |
| cohort_period | TEXT | Period identifier (e.g., '2024-W01') |
| cohort_size | INTEGER | Number of users in cohort |
| granularity | TEXT | 'day', 'week', 'month' |
| period_start | TEXT | ISO 8601 timestamp (period start) |
| period_end | TEXT | ISO 8601 timestamp (period end) |
| computed_at | TEXT | When this was computed |
Unique constraint: (campaign_id, cohort_period, granularity)
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key |
| campaign_id | INTEGER | Foreign key to campaigns |
| cohort_period | TEXT | Period identifier |
| offset_period | INTEGER | Offset from cohort (0, 1, 2, ...) |
| metric_type | TEXT | 'claimed', 'active' |
| user_count | INTEGER | Number of users who performed activity |
| granularity | TEXT | 'day', 'week', 'month' |
| computed_at | TEXT | When this was computed |
Unique constraint: (campaign_id, cohort_period, offset_period, metric_type, granularity)
The cohort system can be integrated with the existing event indexer (eventIndexer.js) to
automatically record user activities from on-chain events:
creditevents → record as "registered"claimevents → record as "claimed"- Contract interactions → record as "active"
The retention data is structured for easy visualization:
- Cohort table view (rows = cohorts, columns = offset periods)
- Retention curves (line charts showing decay over time)
- Comparative cohort analysis
- All timestamps normalized to UTC
- Period boundaries use UTC midnight
- ISO 8601 week numbering (first Thursday rule)
- System reports actual counts, not suppressed
- Frontend can flag low-n cohorts (e.g., < 30 users)
- Retention rates always calculated, even for small cohorts
recomputeflag clears cache and recomputes from raw data- Idempotent: safe to run multiple times
- Preserves historical activity data
- System requires registration activity first
- Activities before registration are ignored (shouldn't happen in normal flow)
- Missing cohort assignment results in activity being skipped
- Same user can have multiple activities in different periods
- Each activity counted separately
- Deduplication at query level (distinct users per offset)
- First query computes and caches all cohorts + retention
- Subsequent queries read from cache (fast)
- Recomputation only when explicitly requested or data changes
- Indexed queries on
campaign_id,occurred_at,activity_type - Precomputed aggregations avoid expensive GROUP BY on reads
- Retention data denormalized for fast lookup
- Computation time: O(N) where N = number of activities
- Storage: O(C × P) where C = cohorts, P = max offset periods
- Typical dataset: 1000 cohorts × 52 weeks = 52K rows (small)
Tests use hand-computed expected values:
// Week 1 (2024-W01): 3 users register
// Week 2 (2024-W02): 2 users register
// Various claims at different offsets
// Expected: Week 1 cohort size = 3, offset 0 retention = 66.67%, etc.- ✅ All granularities (day, week, month)
- ✅ All metric types (claimed, active)
- ✅ Specific cohort queries
- ✅ Recomputation and cache clearing
- ✅ Empty cohort handling
- ✅ Error cases (non-existent cohorts)
All 8 cohort service tests passing with deterministic, hand-verified outputs.
backend/src/db/migrations/011_cohort_retention_tables.js- Database schemabackend/src/dal/sqliteCohortRepository.js- Data access layerbackend/src/services/cohortService.js- Business logicbackend/src/routes/cohorts.js- API routesbackend/src/services/cohortService.test.js- Unit testsIMPLEMENTATION_ISSUE_623.md- This documentation
backend/src/dal/index.js- Integrated cohort repositorybackend/src/index.js- Registered cohort service and routes
✅ A known fixture yields the expected cohort/retention curves
- Implemented deterministic test with hand-computed values
- Week 1 cohort: 3 users, retention verified at offsets 0, 1, 2
- Week 2 cohort: 2 users, retention verified at offset 0
- All retention rates match expected percentages
- Automated activity recording: Integrate with event indexer for automatic tracking
- Cohort comparison: API endpoint to compare retention curves between cohorts
- Survival analysis: Kaplan-Meier curves for long-term retention
- Predictive retention: ML models to forecast future retention
- Segment-based cohorts: Group by user attributes (country, device, referral source)
- Export functionality: CSV/JSON export of cohort data
- Real-time updates: WebSocket notifications when new cohort data is available
- All endpoints require API key authentication
- Rate limiting applies to all cohort endpoints
- Campaign ID validation prevents unauthorized access
- SQL injection protected via parameterized queries
- User addresses can be hashed for privacy
Run migration before deploying:
npm run db:migrateNo new environment variables required. Uses existing:
DB_PATH- Database file locationRATE_LIMIT_*- Rate limiting configuration
- New endpoints only, no breaking changes
- Existing APIs unchanged
- Migration is additive (no data loss)
Issue: #623
Status: ✅ Complete
Author: Williams-1604
Date: 2026-06-18