@coursebuilder/analytics 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ import {
17
17
  PlayIcon,
18
18
  ShoppingCartIcon,
19
19
  TrendingUpIcon,
20
+ RouteIcon,
20
21
  } from 'lucide-react'
21
22
  import { parseAsStringLiteral, useQueryState } from 'nuqs'
22
23
 
@@ -125,6 +126,29 @@ export interface DashboardData {
125
126
  answerDistribution: { answer: string; count: number }[]
126
127
  }[]
127
128
  | null
129
+ valuePaths?: {
130
+ contacts: number
131
+ events: number
132
+ intents: number
133
+ completedIntents: number
134
+ pendingIntents: number
135
+ blockedIntents: number
136
+ answerEvents: number
137
+ dripEvents: number
138
+ enteredEvents: number
139
+ participantsWithAnswerClicks: number
140
+ participantsWithNoAnswerClicks: number
141
+ terminalParticipants: number
142
+ answerOptions: {
143
+ key: string
144
+ step: string
145
+ optionValue: string
146
+ count: number
147
+ }[]
148
+ answerSteps: { step: string; count: number }[]
149
+ terminalSteps: { emailResourceId: string; count: number }[]
150
+ notes: string[]
151
+ } | null
128
152
  surveyCorrelation: {
129
153
  totalRespondents: number
130
154
  respondentsWhoPurchased: number
@@ -661,6 +685,7 @@ export function OmnibusDashboard({
661
685
  const hasSurveys =
662
686
  data.surveySegments != null && data.surveySegments.length > 0
663
687
  const hasSurveyCorrelation = data.surveyCorrelation != null
688
+ const hasValuePaths = data.valuePaths != null && data.valuePaths.contacts > 0
664
689
  const hasShortlinks = data.shortlinks.length > 0
665
690
 
666
691
  return (
@@ -753,6 +778,14 @@ export function OmnibusDashboard({
753
778
  icon={MousePointerClickIcon}
754
779
  />
755
780
  )}
781
+ {hasValuePaths && (
782
+ <Stat
783
+ label="Value Paths"
784
+ value={`${data.valuePaths!.terminalParticipants}/${data.valuePaths!.contacts}`}
785
+ sub={`${data.valuePaths!.answerEvents} answers · ${data.valuePaths!.dripEvents} drips`}
786
+ icon={RouteIcon}
787
+ />
788
+ )}
756
789
  {hasSurveys && (
757
790
  <Stat
758
791
  label="Surveys"
@@ -883,6 +916,136 @@ export function OmnibusDashboard({
883
916
  </Card>
884
917
  )}
885
918
 
919
+ {/* ── Value Paths ──────────────────────────────────────────── */}
920
+ {hasValuePaths && (
921
+ <Card>
922
+ <CardHeader className="pb-3">
923
+ <div className="flex items-center gap-2.5">
924
+ <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-sky-500/10 text-sky-500">
925
+ <RouteIcon className="h-3.5 w-3.5" />
926
+ </div>
927
+ <div>
928
+ <CardTitle className="text-sm font-semibold">
929
+ Value Paths
930
+ </CardTitle>
931
+ <CardDescription className="text-[11px]">
932
+ Progression, answer clicks, drip fallback, and terminal
933
+ completion
934
+ </CardDescription>
935
+ </div>
936
+ </div>
937
+ </CardHeader>
938
+ <CardContent className="space-y-5">
939
+ {(() => {
940
+ const vp = data.valuePaths!
941
+ const completionRate =
942
+ vp.contacts > 0
943
+ ? (vp.terminalParticipants / vp.contacts) * 100
944
+ : 0
945
+ const clickRate =
946
+ vp.contacts > 0
947
+ ? (vp.participantsWithAnswerClicks / vp.contacts) * 100
948
+ : 0
949
+ return (
950
+ <>
951
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
952
+ <div className="bg-muted/20 rounded-lg p-3">
953
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
954
+ Contacts
955
+ </span>
956
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
957
+ {vp.contacts.toLocaleString()}
958
+ </span>
959
+ </div>
960
+ <div className="bg-muted/20 rounded-lg p-3">
961
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
962
+ Terminal
963
+ </span>
964
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
965
+ {vp.terminalParticipants.toLocaleString()}
966
+ </span>
967
+ <span className="text-muted-foreground/70 text-[10px]">
968
+ {completionRate.toFixed(0)}% completed
969
+ </span>
970
+ </div>
971
+ <div className="bg-muted/20 rounded-lg p-3">
972
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
973
+ Answered
974
+ </span>
975
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
976
+ {vp.participantsWithAnswerClicks.toLocaleString()}
977
+ </span>
978
+ <span className="text-muted-foreground/70 text-[10px]">
979
+ {clickRate.toFixed(0)}% clicked
980
+ </span>
981
+ </div>
982
+ <div className="bg-muted/20 rounded-lg p-3">
983
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
984
+ Drips
985
+ </span>
986
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
987
+ {vp.dripEvents.toLocaleString()}
988
+ </span>
989
+ </div>
990
+ <div className="bg-muted/20 rounded-lg p-3">
991
+ <span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
992
+ Blocked
993
+ </span>
994
+ <span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
995
+ {vp.blockedIntents.toLocaleString()}
996
+ </span>
997
+ </div>
998
+ </div>
999
+
1000
+ {vp.answerOptions.length > 0 && (
1001
+ <div className="space-y-2">
1002
+ <p className="text-muted-foreground text-[11px]">
1003
+ Top answer choices by captured answer event
1004
+ </p>
1005
+ <div className="space-y-1.5">
1006
+ {vp.answerOptions.slice(0, 8).map((answer) => {
1007
+ const pct =
1008
+ vp.answerEvents > 0
1009
+ ? (answer.count / vp.answerEvents) * 100
1010
+ : 0
1011
+ return (
1012
+ <div
1013
+ key={answer.key}
1014
+ className="flex items-center gap-2.5"
1015
+ >
1016
+ <span
1017
+ className="text-muted-foreground w-[140px] shrink-0 truncate text-right text-[12px]"
1018
+ title={answer.key}
1019
+ >
1020
+ {answer.optionValue}
1021
+ </span>
1022
+ <div className="bg-muted/50 relative h-5 flex-1 overflow-hidden rounded">
1023
+ <div
1024
+ className="absolute inset-y-0 left-0 rounded bg-sky-500/20"
1025
+ style={{ width: `${Math.max(pct, 1)}%` }}
1026
+ />
1027
+ <div className="relative flex h-full items-center px-2">
1028
+ <span className="text-foreground/70 text-[11px] font-medium tabular-nums">
1029
+ {pct.toFixed(0)}%
1030
+ </span>
1031
+ </div>
1032
+ </div>
1033
+ <span className="text-muted-foreground w-10 shrink-0 text-right text-[11px] tabular-nums">
1034
+ {answer.count.toLocaleString()}
1035
+ </span>
1036
+ </div>
1037
+ )
1038
+ })}
1039
+ </div>
1040
+ </div>
1041
+ )}
1042
+ </>
1043
+ )
1044
+ })()}
1045
+ </CardContent>
1046
+ </Card>
1047
+ )}
1048
+
886
1049
  {/* ── Survey Segments ──────────────────────────────────────── */}
887
1050
  {hasSurveys && (
888
1051
  <Card>
package/src/engine.ts CHANGED
@@ -27,9 +27,10 @@ function toYouTubeRange(range: AnalyticsRange): '24h' | '7d' | '30d' | '90d' {
27
27
  async function invokeSurface<S extends SurfaceName>(
28
28
  providers: ProviderMap,
29
29
  surface: S,
30
- range: AnalyticsRange,
31
- limit: number,
30
+ options: Required<Pick<QueryOptions, 'range' | 'limit' | 'offset'>> &
31
+ Omit<QueryOptions, 'range' | 'limit' | 'offset'>,
32
32
  ): Promise<SurfaceMap[S] | null> {
33
+ const { range, limit, offset } = options
33
34
  const entry = catalog[surface]
34
35
  const provider = providers[entry.provider]
35
36
 
@@ -86,22 +87,71 @@ async function invokeSurface<S extends SurfaceName>(
86
87
  ) => Promise<SurfaceMap[S]>
87
88
  return fn(range)
88
89
  }
89
- case 'attribution/email-campaigns': {
90
+ case 'attribution/sources':
91
+ case 'attribution/coverage':
92
+ case 'attribution/commerce-lanes': {
93
+ const fn = provider[entry.fn] as (
94
+ range: AnalyticsRange,
95
+ filters?: Pick<QueryOptions, 'productId'>,
96
+ ) => Promise<SurfaceMap[S]>
97
+ return fn(range, { productId: options.productId })
98
+ }
99
+ case 'attribution/email-campaigns':
100
+ case 'attribution/email-campaigns/strict': {
90
101
  const fn = provider[entry.fn] as (
91
102
  range: AnalyticsRange,
92
103
  limit?: number,
104
+ filters?: Pick<QueryOptions, 'productId'>,
105
+ ) => Promise<SurfaceMap[S]>
106
+ return fn(range, limit, { productId: options.productId })
107
+ }
108
+ case 'attribution/checkout-receipt': {
109
+ const fn = provider[entry.fn] as (
110
+ filters: Pick<QueryOptions, 'purchaseId'>,
111
+ ) => Promise<SurfaceMap[S]>
112
+ return fn({ purchaseId: options.purchaseId })
113
+ }
114
+ case 'attribution/checkout-survey-fallback': {
115
+ const fn = provider[entry.fn] as (
116
+ range: AnalyticsRange,
117
+ limit?: number,
118
+ filters?: Pick<QueryOptions, 'productId'>,
119
+ ) => Promise<SurfaceMap[S]>
120
+ return fn(range, limit, { productId: options.productId })
121
+ }
122
+ case 'surveys/questions': {
123
+ const fn = provider[entry.fn] as (
124
+ range: AnalyticsRange,
125
+ limit: number,
93
126
  ) => Promise<SurfaceMap[S]>
94
127
  return fn(range, limit)
95
128
  }
96
- case 'surveys/questions':
97
129
  case 'surveys/responses': {
98
130
  const fn = provider[entry.fn] as (
99
131
  range: AnalyticsRange,
100
132
  limit: number,
133
+ offset: number,
101
134
  ) => Promise<SurfaceMap[S]>
102
- return fn(range, limit)
135
+ return fn(range, limit, offset)
103
136
  }
104
137
  default: {
138
+ if (surface === 'correlation/survey-revenue/product') {
139
+ const productSurveyFn = provider[entry.fn] as (
140
+ range: AnalyticsRange,
141
+ limit?: number,
142
+ filters?: Pick<
143
+ QueryOptions,
144
+ 'productId' | 'surveyId' | 'surveySlug' | 'questionId'
145
+ >,
146
+ ) => Promise<SurfaceMap[S] | null>
147
+ return productSurveyFn(range, limit, {
148
+ productId: options.productId,
149
+ surveyId: options.surveyId,
150
+ surveySlug: options.surveySlug,
151
+ questionId: options.questionId,
152
+ })
153
+ }
154
+
105
155
  const fn = provider[entry.fn] as (
106
156
  range: AnalyticsRange,
107
157
  limit?: number,
@@ -132,11 +182,21 @@ export function createAnalyticsEngine(providers: ProviderMap) {
132
182
  ): Promise<QueryResult<S>> {
133
183
  const range = options?.range ?? '30d'
134
184
  const limit = options?.limit ?? 20
185
+ const offset = options?.offset ?? 0
135
186
  const entry = catalog[surface]
136
187
  const startMs = Date.now()
137
188
 
138
189
  try {
139
- const data = await invokeSurface(providers, surface, range, limit)
190
+ const data = await invokeSurface(providers, surface, {
191
+ range,
192
+ limit,
193
+ offset,
194
+ productId: options?.productId,
195
+ purchaseId: options?.purchaseId,
196
+ surveyId: options?.surveyId,
197
+ surveySlug: options?.surveySlug,
198
+ questionId: options?.questionId,
199
+ })
140
200
 
141
201
  if (data === null) {
142
202
  return {
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ classifyPurchaseAttribution,
5
+ hasExactShortlinkPurchaseAttribution,
6
+ SHORTLINK_RECOVERY_LANE,
7
+ } from './attribution-recovery'
8
+
9
+ describe('attribution recovery classification', () => {
10
+ it('keeps purchase field attribution ahead of exact shortlink recovery', () => {
11
+ expect(
12
+ classifyPurchaseAttribution({
13
+ purchaseId: 'purch_1',
14
+ fields: { attribution: { source: 'email' } },
15
+ shortlinkAttributions: [
16
+ {
17
+ type: 'purchase',
18
+ metadata: JSON.stringify({ purchaseId: 'purch_1' }),
19
+ },
20
+ ],
21
+ }),
22
+ ).toBe('purchase_field')
23
+ })
24
+
25
+ it('recovers an otherwise dark purchase from exact shortlink metadata', () => {
26
+ expect(
27
+ classifyPurchaseAttribution({
28
+ purchaseId: 'purch_2',
29
+ fields: {},
30
+ shortlinkAttributions: [
31
+ {
32
+ type: 'purchase',
33
+ metadata: JSON.stringify({ purchaseId: 'purch_2' }),
34
+ },
35
+ ],
36
+ }),
37
+ ).toBe(SHORTLINK_RECOVERY_LANE)
38
+ })
39
+
40
+ it('ignores invalid shortlink attribution metadata', () => {
41
+ expect(
42
+ hasExactShortlinkPurchaseAttribution({
43
+ purchaseId: 'purch_3',
44
+ attributions: [{ type: 'purchase', metadata: '{not json' }],
45
+ }),
46
+ ).toBe(false)
47
+ })
48
+
49
+ it('ignores non-matching shortlink attribution metadata', () => {
50
+ expect(
51
+ classifyPurchaseAttribution({
52
+ purchaseId: 'purch_4',
53
+ fields: {},
54
+ shortlinkAttributions: [
55
+ {
56
+ type: 'purchase',
57
+ metadata: JSON.stringify({ purchaseId: 'purch_other' }),
58
+ },
59
+ ],
60
+ }),
61
+ ).toBe('dark')
62
+ })
63
+ })
@@ -0,0 +1,97 @@
1
+ export const SHORTLINK_RECOVERY_LANE =
2
+ 'recovered_from_shortlink_attribution_table' as const
3
+
4
+ export type PurchaseAttributionClass =
5
+ | 'purchase_field'
6
+ | typeof SHORTLINK_RECOVERY_LANE
7
+ | 'dark'
8
+
9
+ export interface ShortlinkAttributionEvidence {
10
+ type?: string | null
11
+ metadata?: unknown
12
+ }
13
+
14
+ function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
16
+ }
17
+
18
+ function nonEmptyString(value: unknown) {
19
+ return typeof value === 'string' && value.trim().length > 0
20
+ }
21
+
22
+ function parseMetadata(value: unknown): Record<string, unknown> | null {
23
+ if (typeof value === 'string') {
24
+ try {
25
+ const parsed = JSON.parse(value) as unknown
26
+ return isRecord(parsed) ? parsed : null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ return isRecord(value) ? value : null
33
+ }
34
+
35
+ /**
36
+ * Detects durable purchase-field attribution on a purchase fields object.
37
+ *
38
+ * @param fields Unknown purchase fields payload.
39
+ * @returns True when the purchase already has attribution, legacy UTM source, or legacy GA client ID evidence.
40
+ * @example
41
+ * hasPurchaseFieldAttribution({ attribution: { source: 'email' } })
42
+ */
43
+ export function hasPurchaseFieldAttribution(fields: unknown) {
44
+ const record = isRecord(fields) ? fields : {}
45
+ const attribution = record.attribution
46
+
47
+ return Boolean(
48
+ (isRecord(attribution) && Object.keys(attribution).length > 0) ||
49
+ nonEmptyString(record.utmSource) ||
50
+ nonEmptyString(record.gaClientId),
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Detects exact shortlink purchase evidence for one purchase.
56
+ *
57
+ * @param params.purchaseId Purchase ID that must match attribution metadata.purchaseId.
58
+ * @param params.attributions ShortlinkAttributionEvidence rows to inspect.
59
+ * @returns True only for purchase rows with valid JSON metadata and an exact purchase ID match.
60
+ */
61
+ export function hasExactShortlinkPurchaseAttribution(params: {
62
+ purchaseId: string
63
+ attributions: readonly ShortlinkAttributionEvidence[]
64
+ }) {
65
+ return params.attributions.some((attribution) => {
66
+ if (attribution.type !== 'purchase') return false
67
+ const metadata = parseMetadata(attribution.metadata)
68
+ return metadata?.purchaseId === params.purchaseId
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Classifies purchase attribution with purchase fields taking precedence.
74
+ *
75
+ * @param params.purchaseId Purchase ID being classified.
76
+ * @param params.fields Purchase fields payload.
77
+ * @param params.shortlinkAttributions ShortlinkAttributionEvidence rows used as exact fallback evidence.
78
+ * @returns A PurchaseAttributionClass: 'purchase_field', SHORTLINK_RECOVERY_LANE, or 'dark'.
79
+ * @example
80
+ * classifyPurchaseAttribution({ purchaseId: 'p1', fields: {}, shortlinkAttributions: [] })
81
+ */
82
+ export function classifyPurchaseAttribution(params: {
83
+ purchaseId: string
84
+ fields?: unknown
85
+ shortlinkAttributions?: readonly ShortlinkAttributionEvidence[]
86
+ }): PurchaseAttributionClass {
87
+ if (hasPurchaseFieldAttribution(params.fields)) return 'purchase_field'
88
+ if (
89
+ hasExactShortlinkPurchaseAttribution({
90
+ purchaseId: params.purchaseId,
91
+ attributions: params.shortlinkAttributions ?? [],
92
+ })
93
+ ) {
94
+ return SHORTLINK_RECOVERY_LANE
95
+ }
96
+ return 'dark'
97
+ }