///|
/// A retention value at a relative period from a cohort start.
pub struct RetentionPoint {
period : Int
cohort_size : Int
active_customers : Int
retention_percent : Int
value : Int
}
///|
pub struct RetentionSummary {
points : Array[RetentionPoint]
starting_value : Int
ending_value : Int
value_change_percent : Int
retained_customers : Int
}
///|
pub struct ReactivationCandidate {
customer_id : String
recency : Int
frequency : Int
monetary : Int
priority : Int
reason : String
}
///|
/// Build a value-aware retention curve for one cohort.
pub fn value_retention_curve(
events : Array[Event],
cohort_day : Int,
period_width : Int,
periods : Int,
) -> RetentionSummary {
if period_width <= 0 || periods <= 0 {
return {
points: [],
starting_value: 0,
ending_value: 0,
value_change_percent: 0,
retained_customers: 0,
}
}
let ids : Array[String] = []
for event in events {
if event.day >= cohort_day &&
event.day < cohort_day + period_width &&
!ids.contains(event.customer_id) {
ids.push(event.customer_id)
}
}
let points : Array[RetentionPoint] = []
for period in 0..= start &&
event.day < end &&
ids.contains(event.customer_id) {
if !active.contains(event.customer_id) {
active.push(event.customer_id)
}
value += event.amount
}
}
points.push({
period,
cohort_size: ids.length(),
active_customers: active.length(),
retention_percent: if ids.is_empty() {
0
} else {
active.length() * 100 / ids.length()
},
value,
})
}
let starting = if points.is_empty() { 0 } else { points[0].value }
let ending = if points.is_empty() {
0
} else {
points[points.length() - 1].value
}
{
points,
starting_value: starting,
ending_value: ending,
value_change_percent: if starting == 0 {
0
} else {
(ending - starting) * 100 / starting
},
retained_customers: if points.is_empty() {
0
} else {
points[points.length() - 1].active_customers
},
}
}
///|
/// Identify repeat customers whose inactivity exceeds the configured window.
pub fn reactivation_candidates(
report : Report,
as_of : Int,
minimum_recency : Int,
) -> Array[ReactivationCandidate] {
let candidates = report.customers.filter_map(customer => {
if customer.recency < minimum_recency || customer.frequency < 2 {
None
} else {
Some({
customer_id: customer.customer_id,
recency: customer.recency,
frequency: customer.frequency,
monetary: customer.monetary,
priority: customer.frequency * 10 +
customer.monetary / 10 +
(as_of - customer.last_day),
reason: "repeat customer is inactive beyond the reactivation window",
})
}
})
candidates.sort_by((left, right) => right.priority - left.priority)
candidates
}