@liiift-studio/sanity-visitor-insights 0.3.0 → 0.3.2
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.
- package/dist/index.js +49 -67
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +58 -76
- package/dist/index.mjs.map +1 -1
- package/dist/server.js +10 -1
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +10 -1
- package/dist/server.mjs.map +1 -1
- package/package.json +1 -1
- package/src/core/core.test.ts +17 -0
- package/src/server/vercel.ts +23 -1
- package/src/studio/Figure.tsx +17 -35
- package/src/studio/panels.tsx +62 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/sanity-visitor-insights",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Visitor-behaviour analytics for type-foundry Sanity Studios \u2014 GA4, Vercel and order data reconciled honestly",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
package/src/core/core.test.ts
CHANGED
|
@@ -237,3 +237,20 @@ describe('validateSiteConfig', () => {
|
|
|
237
237
|
expect(validateSiteConfig({}).length).toBeGreaterThan(1)
|
|
238
238
|
})
|
|
239
239
|
})
|
|
240
|
+
|
|
241
|
+
describe('vercel granularity', () => {
|
|
242
|
+
it('picks a granularity the API will actually accept', async () => {
|
|
243
|
+
const { granularityFor } = await import('../server/vercel')
|
|
244
|
+
// Limits measured against the live API: day caps at 62 days, week at 26 weeks. Exceeding
|
|
245
|
+
// either is a 400, not a truncated series, so the year view fails outright if this is wrong.
|
|
246
|
+
expect(granularityFor('2026-08-01', '2026-08-30')).toBe('day')
|
|
247
|
+
expect(granularityFor('2026-07-01', '2026-08-31')).toBe('day')
|
|
248
|
+
expect(granularityFor('2026-06-01', '2026-08-30')).toBe('week')
|
|
249
|
+
// Exact boundaries: 62 days is still day, 63 is not; 182 days is still week, 183 is not.
|
|
250
|
+
expect(granularityFor('2026-01-01', '2026-03-03')).toBe('day')
|
|
251
|
+
expect(granularityFor('2026-01-01', '2026-03-04')).toBe('week')
|
|
252
|
+
expect(granularityFor('2026-01-01', '2026-07-01')).toBe('week')
|
|
253
|
+
expect(granularityFor('2026-01-01', '2026-07-02')).toBe('month')
|
|
254
|
+
expect(granularityFor('2025-08-30', '2026-08-30')).toBe('month')
|
|
255
|
+
})
|
|
256
|
+
})
|
package/src/server/vercel.ts
CHANGED
|
@@ -28,6 +28,22 @@ export interface VercelClient {
|
|
|
28
28
|
pageviews(start: string, end: string): Promise<VercelPageviews>
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Pick a time granularity the aggregate endpoint will accept.
|
|
33
|
+
*
|
|
34
|
+
* It rejects any grouping that would return more than 62 buckets — a quarter or a year grouped by
|
|
35
|
+
* day is a 400, not an empty result. Stepping the granularity up keeps long ranges working instead
|
|
36
|
+
* of failing outright, which is what the year view needs.
|
|
37
|
+
*/
|
|
38
|
+
export function granularityFor(start: string, end: string): 'day' | 'week' | 'month' {
|
|
39
|
+
const days = Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000) + 1
|
|
40
|
+
// Measured against the live API: day is capped at 62 days, week at 26 weeks. Both limits are
|
|
41
|
+
// on buckets returned, and exceeding either is a 400 rather than a truncated result.
|
|
42
|
+
if (days <= 62) return 'day'
|
|
43
|
+
if (days <= 26 * 7) return 'week'
|
|
44
|
+
return 'month'
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
/** Shape of the aggregate response rows. */
|
|
32
48
|
interface AggregateRow {
|
|
33
49
|
timestamp?: string
|
|
@@ -65,6 +81,7 @@ export function createVercelClient(projectId: string, token: string, teamId?: st
|
|
|
65
81
|
// Two calls on purpose. The count endpoint gives range totals with visitors deduped
|
|
66
82
|
// across the whole window; summing the daily rows would overcount visitors, because a
|
|
67
83
|
// person returning on three days is three daily visitors but one range visitor.
|
|
84
|
+
// It also has no range limit, unlike the aggregate below.
|
|
68
85
|
const [totals, series] = await Promise.all([
|
|
69
86
|
query<{ data?: { pageviews?: number; visitors?: number } }>('visits/count', {
|
|
70
87
|
since: start,
|
|
@@ -73,8 +90,13 @@ export function createVercelClient(projectId: string, token: string, teamId?: st
|
|
|
73
90
|
query<{ data?: AggregateRow[] }>('visits/aggregate', {
|
|
74
91
|
since: start,
|
|
75
92
|
until: end,
|
|
76
|
-
by:
|
|
93
|
+
by: granularityFor(start, end),
|
|
77
94
|
limit: '100',
|
|
95
|
+
}).catch((e: Error) => {
|
|
96
|
+
// The series is supplementary — it feeds sparklines, not the headline figure.
|
|
97
|
+
// Losing it must not cost the totals, which is what the panel actually compares.
|
|
98
|
+
console.error('Visitor insights: Vercel series unavailable:', e.message)
|
|
99
|
+
return { data: [] as AggregateRow[] }
|
|
78
100
|
}),
|
|
79
101
|
])
|
|
80
102
|
|
package/src/studio/Figure.tsx
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import React from 'react'
|
|
13
|
-
import { Badge, Box, Flex, Stack, Text, Tooltip } from '@liiift-studio/sanity-ui-compat'
|
|
13
|
+
import { Badge, Box, Card, Flex, Stack, Text, Tooltip } from '@liiift-studio/sanity-ui-compat'
|
|
14
14
|
import type { MetricValue, UnavailableReason } from '../types'
|
|
15
15
|
|
|
16
16
|
/** Human-readable explanation for each unavailable reason. */
|
|
@@ -109,39 +109,19 @@ export function ComparisonBar({ label, metric, max, tone = 'default' }: Comparis
|
|
|
109
109
|
|
|
110
110
|
{value === null ? (
|
|
111
111
|
// A dashed rail, not a zero-width bar: absence must not read as a measured zero.
|
|
112
|
-
<
|
|
112
|
+
<Card
|
|
113
113
|
aria-hidden="true"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}}
|
|
114
|
+
radius={2}
|
|
115
|
+
tone="transparent"
|
|
116
|
+
border
|
|
117
|
+
style={{ height: 8, borderStyle: 'dashed' }}
|
|
119
118
|
/>
|
|
120
119
|
) : (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
background: 'var(--card-border-color)',
|
|
127
|
-
overflow: 'hidden',
|
|
128
|
-
}}
|
|
129
|
-
>
|
|
130
|
-
<Box
|
|
131
|
-
style={{
|
|
132
|
-
width: `${width}%`,
|
|
133
|
-
height: '100%',
|
|
134
|
-
background:
|
|
135
|
-
tone === 'primary'
|
|
136
|
-
? 'var(--card-focus-ring-color)'
|
|
137
|
-
: tone === 'positive'
|
|
138
|
-
? 'var(--card-badge-positive-dot-color, var(--card-focus-ring-color))'
|
|
139
|
-
: tone === 'caution'
|
|
140
|
-
? 'var(--card-badge-caution-dot-color, var(--card-muted-fg-color))'
|
|
141
|
-
: 'var(--card-muted-fg-color)',
|
|
142
|
-
}}
|
|
143
|
-
/>
|
|
144
|
-
</Box>
|
|
120
|
+
// Track and fill are both Cards so the palette comes from the Studio theme tokens
|
|
121
|
+
// rather than hand-picked CSS variables, and follows light/dark without extra work.
|
|
122
|
+
<Card aria-hidden="true" radius={2} tone="transparent" border style={{ height: 8, overflow: 'hidden' }}>
|
|
123
|
+
<Card tone={tone === 'default' ? 'default' : tone} radius={2} style={{ width: `${width}%`, height: '100%' }} />
|
|
124
|
+
</Card>
|
|
145
125
|
)}
|
|
146
126
|
</Stack>
|
|
147
127
|
)
|
|
@@ -165,10 +145,12 @@ export function NoticeList({ notices }: NoticeListProps): React.ReactElement | n
|
|
|
165
145
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 8 }}>
|
|
166
146
|
{notices.map((notice) => (
|
|
167
147
|
<li key={notice}>
|
|
168
|
-
<
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
148
|
+
<Card padding={3} radius={2} tone="caution" border>
|
|
149
|
+
<Flex gap={3} align="flex-start">
|
|
150
|
+
<Badge tone="caution" fontSize={0}>Caveat</Badge>
|
|
151
|
+
<Text size={1}>{notice}</Text>
|
|
152
|
+
</Flex>
|
|
153
|
+
</Card>
|
|
172
154
|
</li>
|
|
173
155
|
))}
|
|
174
156
|
</ul>
|
package/src/studio/panels.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import React from 'react'
|
|
10
|
-
import { Box, Card, Flex, Heading, Stack, Text } from '@liiift-studio/sanity-ui-compat'
|
|
10
|
+
import { Badge, Box, Card, Flex, Grid, Heading, Label, Stack, Text } from '@liiift-studio/sanity-ui-compat'
|
|
11
11
|
import { ComparisonBar, MetricFigure, NoticeList, formatCount, formatPercent } from './Figure'
|
|
12
12
|
import type {
|
|
13
13
|
AcquisitionData,
|
|
@@ -28,6 +28,8 @@ function maxOf(metrics: MetricValue[]): number {
|
|
|
28
28
|
const tableWrap: React.CSSProperties = { overflowX: 'auto', width: '100%' }
|
|
29
29
|
const table: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', minWidth: 420 }
|
|
30
30
|
const cell: React.CSSProperties = { padding: '8px 12px', textAlign: 'left', borderBottom: '1px solid var(--card-border-color)' }
|
|
31
|
+
// @sanity/ui ships no Table primitive, so the element is native — but every cell's content is a
|
|
32
|
+
// Sanity UI component, and the table sits inside a Card, so it themes with the rest of the Studio.
|
|
31
33
|
const numericCell: React.CSSProperties = { ...cell, textAlign: 'right' }
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -66,20 +68,26 @@ export function MeasurementHealthPanel({ data }: { data: MeasurementHealthData }
|
|
|
66
68
|
<Text size={1} muted>
|
|
67
69
|
Different units to the figures above, and to each other. Shown for scale, never differenced.
|
|
68
70
|
</Text>
|
|
69
|
-
<
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
</
|
|
82
|
-
|
|
71
|
+
<Grid columns={[1, 3]} gap={4}>
|
|
72
|
+
<Card padding={3} radius={2} tone="transparent" border>
|
|
73
|
+
<Stack space={3}>
|
|
74
|
+
<Label size={1} muted>GA4 sessions</Label>
|
|
75
|
+
<MetricFigure metric={data.ga4Sessions} label="GA4 sessions" />
|
|
76
|
+
</Stack>
|
|
77
|
+
</Card>
|
|
78
|
+
<Card padding={3} radius={2} tone="transparent" border>
|
|
79
|
+
<Stack space={3}>
|
|
80
|
+
<Label size={1} muted>Orders</Label>
|
|
81
|
+
<MetricFigure metric={data.orders} label="Orders" />
|
|
82
|
+
</Stack>
|
|
83
|
+
</Card>
|
|
84
|
+
<Card padding={3} radius={2} tone="transparent" border>
|
|
85
|
+
<Stack space={3}>
|
|
86
|
+
<Label size={1} muted>Consent granted</Label>
|
|
87
|
+
<MetricFigure metric={data.consentRate} label="Consent granted, percent of sessions" />
|
|
88
|
+
</Stack>
|
|
89
|
+
</Card>
|
|
90
|
+
</Grid>
|
|
83
91
|
</Stack>
|
|
84
92
|
</Stack>
|
|
85
93
|
)
|
|
@@ -89,35 +97,41 @@ export function MeasurementHealthPanel({ data }: { data: MeasurementHealthData }
|
|
|
89
97
|
export function AcquisitionPanel({ data }: { data: AcquisitionData }): React.ReactElement {
|
|
90
98
|
return (
|
|
91
99
|
<Stack space={4}>
|
|
92
|
-
<
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{data.designIndustryShare !== null && (
|
|
98
|
-
<Stack space={2}>
|
|
99
|
-
<Text size={1} muted>From design-industry referrers</Text>
|
|
100
|
-
<Text size={4}>{formatPercent(data.designIndustryShare, 1)}</Text>
|
|
100
|
+
<Grid columns={[1, 3]} gap={4}>
|
|
101
|
+
<Card padding={3} radius={2} tone="transparent" border>
|
|
102
|
+
<Stack space={3}>
|
|
103
|
+
<Label size={1} muted>Sessions</Label>
|
|
104
|
+
<Text size={4}>{formatCount(data.totalSessions)}</Text>
|
|
101
105
|
</Stack>
|
|
106
|
+
</Card>
|
|
107
|
+
{data.designIndustryShare !== null && (
|
|
108
|
+
<Card padding={3} radius={2} tone="transparent" border>
|
|
109
|
+
<Stack space={3}>
|
|
110
|
+
<Label size={1} muted>From design-industry referrers</Label>
|
|
111
|
+
<Text size={4}>{formatPercent(data.designIndustryShare, 1)}</Text>
|
|
112
|
+
</Stack>
|
|
113
|
+
</Card>
|
|
102
114
|
)}
|
|
103
115
|
{data.unattributedShare !== null && (
|
|
104
|
-
<
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
<Card padding={3} radius={2} tone="transparent" border>
|
|
117
|
+
<Stack space={3}>
|
|
118
|
+
<Label size={1} muted>Unattributed</Label>
|
|
119
|
+
<Text size={4}>{formatPercent(data.unattributedShare, 1)}</Text>
|
|
120
|
+
</Stack>
|
|
121
|
+
</Card>
|
|
108
122
|
)}
|
|
109
|
-
</
|
|
123
|
+
</Grid>
|
|
110
124
|
|
|
111
|
-
<
|
|
125
|
+
<Card radius={2} tone="transparent" border style={tableWrap}>
|
|
112
126
|
<table style={table}>
|
|
113
127
|
<caption style={{ textAlign: 'left', paddingBottom: 8 }}>
|
|
114
128
|
<Text size={1} muted>Traffic sources by sessions</Text>
|
|
115
129
|
</caption>
|
|
116
130
|
<thead>
|
|
117
131
|
<tr>
|
|
118
|
-
<th scope="col" style={cell}><
|
|
119
|
-
<th scope="col" style={cell}><
|
|
120
|
-
<th scope="col" style={numericCell}><
|
|
132
|
+
<th scope="col" style={cell}><Label size={1} muted>Source</Label></th>
|
|
133
|
+
<th scope="col" style={cell}><Label size={1} muted>Channel</Label></th>
|
|
134
|
+
<th scope="col" style={numericCell}><Label size={1} muted>Sessions</Label></th>
|
|
121
135
|
</tr>
|
|
122
136
|
</thead>
|
|
123
137
|
<tbody>
|
|
@@ -126,8 +140,8 @@ export function AcquisitionPanel({ data }: { data: AcquisitionData }): React.Rea
|
|
|
126
140
|
<th scope="row" style={cell}>
|
|
127
141
|
<Flex gap={2} align="center">
|
|
128
142
|
<Text size={1}>{row.source}</Text>
|
|
129
|
-
{row.designIndustry && <
|
|
130
|
-
{row.unattributed && <
|
|
143
|
+
{row.designIndustry && <Badge tone="primary" fontSize={0}>design industry</Badge>}
|
|
144
|
+
{row.unattributed && <Badge tone="caution" fontSize={0}>unattributed</Badge>}
|
|
131
145
|
</Flex>
|
|
132
146
|
</th>
|
|
133
147
|
<td style={cell}><Text size={1} muted>{row.channel}</Text></td>
|
|
@@ -136,7 +150,7 @@ export function AcquisitionPanel({ data }: { data: AcquisitionData }): React.Rea
|
|
|
136
150
|
))}
|
|
137
151
|
</tbody>
|
|
138
152
|
</table>
|
|
139
|
-
</
|
|
153
|
+
</Card>
|
|
140
154
|
|
|
141
155
|
{data.rowsWithheld && (
|
|
142
156
|
<NoticeList notices={['GA4 withheld some low-traffic rows for privacy, so this list is shorter than reality.']} />
|
|
@@ -169,12 +183,12 @@ export function JourneyPanel({ data }: { data: JourneyData }): React.ReactElemen
|
|
|
169
183
|
{data.topExitPages.length > 0 && (
|
|
170
184
|
<Stack space={3}>
|
|
171
185
|
<Heading size={1}>Where sessions ended</Heading>
|
|
172
|
-
<
|
|
186
|
+
<Card radius={2} tone="transparent" border style={tableWrap}>
|
|
173
187
|
<table style={table}>
|
|
174
188
|
<thead>
|
|
175
189
|
<tr>
|
|
176
|
-
<th scope="col" style={cell}><
|
|
177
|
-
<th scope="col" style={numericCell}><
|
|
190
|
+
<th scope="col" style={cell}><Label size={1} muted>Page</Label></th>
|
|
191
|
+
<th scope="col" style={numericCell}><Label size={1} muted>Exits</Label></th>
|
|
178
192
|
</tr>
|
|
179
193
|
</thead>
|
|
180
194
|
<tbody>
|
|
@@ -186,7 +200,7 @@ export function JourneyPanel({ data }: { data: JourneyData }): React.ReactElemen
|
|
|
186
200
|
))}
|
|
187
201
|
</tbody>
|
|
188
202
|
</table>
|
|
189
|
-
</
|
|
203
|
+
</Card>
|
|
190
204
|
</Stack>
|
|
191
205
|
)}
|
|
192
206
|
</Stack>
|
|
@@ -201,18 +215,18 @@ export function TypefaceInterestPanel({ data }: { data: TypefaceInterestData }):
|
|
|
201
215
|
<Text size={1} muted>{data.interpretationNote}</Text>
|
|
202
216
|
</Card>
|
|
203
217
|
|
|
204
|
-
<
|
|
218
|
+
<Card radius={2} tone="transparent" border style={tableWrap}>
|
|
205
219
|
<table style={table}>
|
|
206
220
|
<caption style={{ textAlign: 'left', paddingBottom: 8 }}>
|
|
207
221
|
<Text size={1} muted>Engagement by typeface</Text>
|
|
208
222
|
</caption>
|
|
209
223
|
<thead>
|
|
210
224
|
<tr>
|
|
211
|
-
<th scope="col" style={cell}><
|
|
212
|
-
<th scope="col" style={numericCell}><
|
|
213
|
-
<th scope="col" style={numericCell}><
|
|
214
|
-
<th scope="col" style={numericCell}><
|
|
215
|
-
<th scope="col" style={numericCell}><
|
|
225
|
+
<th scope="col" style={cell}><Label size={1} muted>Typeface</Label></th>
|
|
226
|
+
<th scope="col" style={numericCell}><Label size={1} muted>Viewed</Label></th>
|
|
227
|
+
<th scope="col" style={numericCell}><Label size={1} muted>Tested</Label></th>
|
|
228
|
+
<th scope="col" style={numericCell}><Label size={1} muted>Bought</Label></th>
|
|
229
|
+
<th scope="col" style={numericCell}><Label size={1} muted>Test rate</Label></th>
|
|
216
230
|
</tr>
|
|
217
231
|
</thead>
|
|
218
232
|
<tbody>
|
|
@@ -229,7 +243,7 @@ export function TypefaceInterestPanel({ data }: { data: TypefaceInterestData }):
|
|
|
229
243
|
))}
|
|
230
244
|
</tbody>
|
|
231
245
|
</table>
|
|
232
|
-
</
|
|
246
|
+
</Card>
|
|
233
247
|
</Stack>
|
|
234
248
|
)
|
|
235
249
|
}
|
|
@@ -278,9 +292,7 @@ export function DiagnosticsPanel({ data }: { data: DiagnosticReport }): React.Re
|
|
|
278
292
|
<Stack space={3}>
|
|
279
293
|
<Flex align="center" justify="space-between" gap={3}>
|
|
280
294
|
<Text size={1} weight="semibold">{item.label}</Text>
|
|
281
|
-
<
|
|
282
|
-
<Text size={0} weight="medium">{CHECK_WORD[item.status]}</Text>
|
|
283
|
-
</Card>
|
|
295
|
+
<Badge tone={CHECK_TONE[item.status]} fontSize={0}>{CHECK_WORD[item.status]}</Badge>
|
|
284
296
|
</Flex>
|
|
285
297
|
<Text size={1} muted>{item.detail}</Text>
|
|
286
298
|
{item.remedy && <Text size={1}>{item.remedy}</Text>}
|