@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sanity-visitor-insights",
3
- "version": "0.3.0",
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",
@@ -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
+ })
@@ -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: 'day',
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
 
@@ -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
- <Box
112
+ <Card
113
113
  aria-hidden="true"
114
- style={{
115
- height: 8,
116
- borderRadius: 4,
117
- border: '1px dashed var(--card-border-color)',
118
- }}
114
+ radius={2}
115
+ tone="transparent"
116
+ border
117
+ style={{ height: 8, borderStyle: 'dashed' }}
119
118
  />
120
119
  ) : (
121
- <Box
122
- aria-hidden="true"
123
- style={{
124
- height: 8,
125
- borderRadius: 4,
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
- <Flex gap={2} align="flex-start">
169
- <Text size={1} muted aria-hidden="true">⚠</Text>
170
- <Text size={1} muted>{notice}</Text>
171
- </Flex>
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>
@@ -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
- <Flex gap={4} wrap="wrap">
70
- <Stack space={2}>
71
- <Text size={1} muted>GA4 sessions</Text>
72
- <MetricFigure metric={data.ga4Sessions} label="GA4 sessions" />
73
- </Stack>
74
- <Stack space={2}>
75
- <Text size={1} muted>Orders</Text>
76
- <MetricFigure metric={data.orders} label="Orders" />
77
- </Stack>
78
- <Stack space={2}>
79
- <Text size={1} muted>Consent granted</Text>
80
- <MetricFigure metric={data.consentRate} label="Consent granted, percent of sessions" />
81
- </Stack>
82
- </Flex>
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
- <Flex gap={4} wrap="wrap">
93
- <Stack space={2}>
94
- <Text size={1} muted>Sessions</Text>
95
- <Text size={4}>{formatCount(data.totalSessions)}</Text>
96
- </Stack>
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
- <Stack space={2}>
105
- <Text size={1} muted>Unattributed</Text>
106
- <Text size={4}>{formatPercent(data.unattributedShare, 1)}</Text>
107
- </Stack>
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
- </Flex>
123
+ </Grid>
110
124
 
111
- <Box style={tableWrap}>
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}><Text size={1} weight="semibold">Source</Text></th>
119
- <th scope="col" style={cell}><Text size={1} weight="semibold">Channel</Text></th>
120
- <th scope="col" style={numericCell}><Text size={1} weight="semibold">Sessions</Text></th>
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 && <Text size={0} muted>· design industry</Text>}
130
- {row.unattributed && <Text size={0} muted>· unattributed</Text>}
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
- </Box>
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
- <Box style={tableWrap}>
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}><Text size={1} weight="semibold">Page</Text></th>
177
- <th scope="col" style={numericCell}><Text size={1} weight="semibold">Exits</Text></th>
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
- </Box>
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
- <Box style={tableWrap}>
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}><Text size={1} weight="semibold">Typeface</Text></th>
212
- <th scope="col" style={numericCell}><Text size={1} weight="semibold">Viewed</Text></th>
213
- <th scope="col" style={numericCell}><Text size={1} weight="semibold">Tested</Text></th>
214
- <th scope="col" style={numericCell}><Text size={1} weight="semibold">Bought</Text></th>
215
- <th scope="col" style={numericCell}><Text size={1} weight="semibold">Test rate</Text></th>
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
- </Box>
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
- <Card padding={2} radius={2} tone={CHECK_TONE[item.status]}>
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>}