Skip to content

Commit c933bb6

Browse files
committed
feat: display core web vitals results
1 parent 57ffbce commit c933bb6

File tree

7 files changed

+293
-63
lines changed

7 files changed

+293
-63
lines changed

app.vue

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,52 @@
1414
</form>
1515
</h1>
1616
<template v-if="!editing && domain">
17-
<div v-if="status === 'pending' || results"
18-
class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
19-
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.performance : undefined"
20-
caption="performance" />
21-
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.accessibility : undefined"
22-
caption="accessibility" />
23-
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.bestPractices : undefined"
24-
caption="best practices" />
25-
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.seo : undefined" caption="SEO" />
26-
</div>
17+
<template v-if="status === 'pending' || results">
18+
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
19+
<template v-if="status === 'pending' || results?.crux">
20+
<ProgressRing size="normal" :value="results?.crux && status !== 'pending' ? results.crux.cwv : undefined"
21+
caption="core web vitals" />
22+
<Histogram size="normal" :value="results?.crux && status !== 'pending' ? results.crux.lcp : undefined"
23+
caption="LCP" />
24+
<Histogram size="normal" :value="results?.crux && status !== 'pending' ? results.crux.cls : undefined"
25+
caption="CLS" />
26+
<Histogram size="normal" :value="results?.crux && status !== 'pending' ? results.crux.inp : undefined"
27+
caption="INP" />
28+
</template>
29+
<template v-else-if="results">
30+
<ProgressRing size="normal" :value="results.lighthouse.performance" caption="performance" />
31+
<ProgressRing size="normal" :value="results.lighthouse.accessibility" caption="accessibility" />
32+
<ProgressRing size="normal" :value="results.lighthouse.bestPractices" caption="best practices" />
33+
<ProgressRing size="normal" :value="results.lighthouse.seo" caption="SEO" />
34+
</template>
35+
</div>
36+
<div v-if="status === 'pending' || (results && results.crux)"
37+
class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full border border-green-700 border-2 rounded-lg p-4">
38+
<span class="flex flex-row gap-2">
39+
<span class="font-bold">{{ results && status !== 'pending' ? results.lighthouse.performance.toFixed(0) :
40+
undefined
41+
}}</span>
42+
<span>performance</span>
43+
</span>
44+
<span class="flex flex-row gap-2">
45+
<span class="font-bold">{{ results && status !== 'pending' ? results.lighthouse.accessibility.toFixed(0) :
46+
undefined
47+
}}</span>
48+
<span>accessibility</span>
49+
</span>
50+
<span class="flex flex-row gap-2">
51+
<span class="font-bold">{{ results && status !== 'pending' ? results.lighthouse.bestPractices.toFixed(0) :
52+
undefined
53+
}}</span>
54+
<span>best practices</span>
55+
</span>
56+
<span class="flex flex-row gap-2">
57+
<span class="font-bold">{{ results && status !== 'pending' ? results.lighthouse.seo.toFixed(0) : undefined
58+
}}</span>
59+
<span>SEO</span>
60+
</span>
61+
</div>
62+
</template>
2763
<div v-else-if="domain">
2864
No results could be fetched. Is it a valid domain?
2965
</div>
@@ -33,13 +69,19 @@
3369
:href="shareLink" @click.prevent="nativeShare">
3470
Share results
3571
</NuxtLink>
36-
<a :href="`https://pagespeed.web.dev/analysis?url=https://${domain}`"
72+
<a v-if="results?.crux"
73+
:href="`https://lookerstudio.google.com/c/u/0/reporting/bbc5698d-57bb-4969-9e07-68810b9fa348/page/keDQB?params=%7B%22origin%22:%22https://${domain}%22%7D`"
74+
class="self-start underline text-gray-400 hover:text-green-400 focus:text-green-400 active:text-green-400">
75+
Explore full results in the CrUX Dashboard &raquo;
76+
</a>
77+
<a v-else :href="`https://pagespeed.web.dev/analysis?url=https://${domain}`"
3778
class="self-start underline text-gray-400 hover:text-green-400 focus:text-green-400 active:text-green-400">
3879
See full results on PageSpeed Insights &raquo;
3980
</a>
40-
<span v-if="results?.timestamp" class="text-gray-400">
81+
<span v-if="results?.crux?.timestamp || results?.lighthouse?.timestamp" class="text-gray-400">
4182
Last updated at
42-
<NuxtTime :datetime="results.timestamp" dateStyle="full" timeStyle="medium" />.
83+
<NuxtTime :datetime="results.crux?.timestamp || results.lighthouse.timestamp" dateStyle="full"
84+
timeStyle="medium" />.
4385
</span>
4486
</div>
4587
</template>
@@ -83,7 +125,14 @@ function enableEditing () {
83125
}, { once: true })
84126
}
85127
86-
const { data: results, status, refresh } = await useFetch(() => `/api/run/${domain.value}`, {
128+
const { data: results, status, refresh } = await useAsyncData(async () => {
129+
const [lighthouse, crux] = await Promise.all([
130+
$fetch(`/api/run/${domain.value}`),
131+
$fetch(`/api/crux/${domain.value}`).catch(() => null)
132+
])
133+
return { lighthouse, crux }
134+
}, {
135+
watch: [domain],
87136
immediate: !!domain.value,
88137
})
89138
@@ -99,7 +148,7 @@ const favicon = computed(() => {
99148
100149
const value = status.value === 'pending' || (domain.value && !results.value)
101150
? undefined
102-
: (results.value ? results.value.performance : 100)
151+
: (results.value ? results.value.crux?.cwv || results.value.lighthouse.performance : 100)
103152
const color = !value ? '#6b7280' : value >= 90 ? '#23c55e' : value >= 50 ? '#fbbf24' : '#ef4444'
104153
const svg = `<svg xmlns="http://www.w3.org/2000/svg" height="${radius * 2}" width="${radius * 2}">
105154
<style>@keyframes spin {
@@ -186,22 +235,20 @@ useServerSeoMeta({
186235
if (!domain.value) {
187236
defineOgImageComponent('Home')
188237
useServerSeoMeta({
189-
description: 'See and share PageSpeed Insights results simply and easily.',
238+
description: 'See and share Core Web Vitals and Page Speed Insights results simply and easily.',
190239
})
191240
} else if (results.value) {
192241
useServerSeoMeta({
193242
description:
194-
`Performance: ${results.value?.performance} | ` +
195-
`Accessibility: ${results.value?.accessibility} | ` +
196-
`Best Practices: ${results.value?.bestPractices} | ` +
197-
`SEO: ${results.value?.seo}`
243+
`Performance: ${results.value?.lighthouse.performance} | ` +
244+
`Accessibility: ${results.value?.lighthouse.accessibility} | ` +
245+
`Best Practices: ${results.value?.lighthouse.bestPractices} | ` +
246+
`SEO: ${results.value?.lighthouse.seo}`
198247
})
199248
200249
defineOgImageComponent('Lighthouse', {
201-
performance: results.value?.performance,
202-
accessibility: results.value?.accessibility,
203-
bestPractices: results.value?.bestPractices,
204-
seo: results.value?.seo,
250+
lighthouse: results.value.lighthouse,
251+
crux: results.value?.crux,
205252
domain: domain.value,
206253
})
207254
}

components/Histogram.vue

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script setup lang="ts">
2+
3+
const props = defineProps({
4+
value: {
5+
type: Object as () => {
6+
caption: string
7+
segments: number[]
8+
},
9+
},
10+
caption: {
11+
type: String,
12+
},
13+
size: {
14+
type: String as () => 'large' | 'normal',
15+
default: 'large'
16+
}
17+
})
18+
19+
const radius = props.size === 'large' ? 132 : 75
20+
const stroke = props.size === 'large' ? 14 : 9
21+
const normalizedRadius = radius - stroke * 2
22+
const circumference = normalizedRadius * 2 * Math.PI
23+
24+
const colorMap = [
25+
'#ef4444',
26+
'#fbbf24',
27+
'#23c55e'
28+
]
29+
30+
</script>
31+
32+
<template>
33+
<span class="flex flex-col items-center" :class="size === 'large' ? 'gap-10' : 'gap-4'">
34+
<span class="relative rounded-full flex items-center justify-center" :class="[size === 'large' ? 'text-7xl h-60 w-60' : 'text-3xl h-36 w-36']">
35+
<svg class="absolute -right-0 -bottom-0" :height="radius * 2" :width="radius * 2" :class="{ 'animate-spin': !value }">
36+
<circle
37+
v-for="segment, index of [...value?.segments || [80]].reverse()"
38+
:stroke="value ? colorMap[index] : '#6b7280'"
39+
fill="transparent"
40+
class="transform-origin-center"
41+
:stroke-dasharray="circumference + ' ' + circumference"
42+
:style="{
43+
strokeDashoffset: circumference - segment / 100 * circumference,
44+
transform: `rotate(270deg)`,
45+
}"
46+
:stroke-width="stroke"
47+
:r="normalizedRadius"
48+
:cx="radius"
49+
:cy="radius"
50+
/>
51+
</svg>
52+
<slot>
53+
<span class="flex flex-row items-baseline gap-1">
54+
<span :class="size === 'large' ? 'text-6xl' : 'text-3xl'">{{ value?.caption.replace(/m?s/, '') }}</span>
55+
<span v-if="value?.caption?.match(/m?s/)" :class="size === 'large' ? 'text-4xl' : 'text-lg'">{{ value?.caption?.match(/m?s/)![0] }}</span>
56+
</span>
57+
</slot>
58+
</span>
59+
<span :class="size === 'large' ? 'text-4xl' : 'text-2xl'">{{ caption }}</span>
60+
</span>
61+
</template>
62+

components/OgImage/Lighthouse.vue

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,59 @@ defineProps({
44
type: String,
55
required: true,
66
},
7-
performance: {
8-
type: Number,
9-
required: true,
10-
},
11-
accessibility: {
12-
type: Number,
13-
required: true,
7+
lighthouse: {
8+
type: Object,
9+
required: true
1410
},
15-
bestPractices: {
16-
type: Number,
17-
required: true,
18-
},
19-
seo: {
20-
type: Number,
21-
required: true,
11+
crux: {
12+
type: Object
2213
},
2314
})
2415
</script>
2516

2617
<template>
2718
<div class="h-full w-full flex items-start justify-start border-solid bg-[#212121] text-white">
2819
<div class="flex flex-col items-center justify-between h-full w-full">
29-
<div class="flex flex-row justify-center gap-12 pt-12 w-full">
30-
<ProgressRing :value="performance" caption="performance" />
31-
<ProgressRing :value="accessibility" caption="accessibility" />
32-
<ProgressRing :value="bestPractices" caption="best practices" />
33-
<ProgressRing :value="seo" caption="SEO" />
20+
<div class="flex flex-col justify-center gap-8 pt-12 pb-6 w-full">
21+
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
22+
<template v-if="crux">
23+
<ProgressRing :value="crux.cwv"
24+
caption="core web vitals" />
25+
<Histogram :value="crux.lcp"
26+
caption="LCP" />
27+
<Histogram :value="crux.cls"
28+
caption="CLS" />
29+
<Histogram :value="crux.inp"
30+
caption="INP" />
31+
</template>
32+
<template v-else-if="lighthouse">
33+
<ProgressRing :value="lighthouse.performance" caption="performance" />
34+
<ProgressRing :value="lighthouse.accessibility" caption="accessibility" />
35+
<ProgressRing :value="lighthouse.bestPractices" caption="best practices" />
36+
<ProgressRing :value="lighthouse.seo" caption="SEO" />
37+
</template>
38+
</div>
39+
<div v-if="crux"
40+
class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around self-center mx-auto w-full border border-green-700 border-2 rounded-lg p-4 text-2xl">
41+
<span class="flex flex-row gap-2">
42+
<span class="font-bold">{{ lighthouse.performance }}</span>
43+
<span>performance</span>
44+
</span>
45+
<span class="flex flex-row gap-2">
46+
<span class="font-bold">{{ lighthouse.accessibility }}</span>
47+
<span>accessibility</span>
48+
</span>
49+
<span class="flex flex-row gap-2">
50+
<span class="font-bold">{{ lighthouse.bestPractices }}</span>
51+
<span>best practices</span>
52+
</span>
53+
<span class="flex flex-row gap-2">
54+
<span class="font-bold">{{ lighthouse.seo }}</span>
55+
<span>SEO</span>
56+
</span>
57+
</div>
3458
</div>
35-
<div class="flex flex-row gap-4 self-start text-white text-5xl pl-16 pb-24">
59+
<div class="flex flex-row gap-4 self-end text-white text-5xl pr-8 pt-4 pb-24">
3660
<span class="text-green-400">&raquo;</span> {{ domain }}
3761
</div>
3862
</div>

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@azure/identity": "^4.0.1",
1414
"@azure/storage-blob": "^12.17.0",
1515
"@unocss/nuxt": "^0.58.4",
16+
"nitropack": "^2.8.1",
1617
"nuxt": "^3.10.0",
1718
"nuxt-og-image": "^3.0.0-rc.30",
1819
"nuxt-time": "^0.1.2",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/api/crux/[domain].get.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
export default defineCachedEventHandler(async event => {
2+
const domain = getRouterParam(event, 'domain')
3+
if (!domain || domain.includes('/') || domain.includes('%')) {
4+
throw createError({ message: 'Invalid domain', statusCode: 422 })
5+
}
6+
7+
try {
8+
const results = await $fetch<CrUXResult>(`/records:queryRecord`, {
9+
baseURL: 'https://chromeuxreport.googleapis.com/v1',
10+
method: 'POST',
11+
query: {
12+
key: useRuntimeConfig().google.apiToken,
13+
},
14+
body: {
15+
origin: `https://${domain}`,
16+
formFactor: 'PHONE'
17+
},
18+
})
19+
20+
return {
21+
cwv: computeCWVScore(results.record.metrics),
22+
lcp: normalizeHistogram(results.record.metrics['largest_contentful_paint']),
23+
cls: normalizeHistogram(results.record.metrics['cumulative_layout_shift']),
24+
inp: normalizeHistogram(results.record.metrics['interaction_to_next_paint']),
25+
timestamp: Date.now(),
26+
}
27+
} catch (e) {
28+
console.error(e)
29+
throw createError({ message: 'No CrUX report available', statusCode: 404 })
30+
}
31+
}, {
32+
base: 'pagespeed',
33+
swr: true,
34+
shouldBypassCache: () => true,
35+
getKey: (event) => 'crux:domain:' + getRouterParam(event, 'domain'),
36+
maxAge: 60 * 60,
37+
staleMaxAge: 24 * 60 * 60,
38+
})
39+
40+
/** Helpers */
41+
42+
interface CrUXResult {
43+
record: {
44+
key: { origin: string }
45+
metrics: Record<'cumulative_layout_shift' | 'first_contentful_paint' | /* 'first_input_delay' | */ 'interaction_to_next_paint' | 'largest_contentful_paint' | 'experimental_time_to_first_byte', {
46+
histogram: Array<{
47+
start: number | string
48+
end?: number | string
49+
density?: number
50+
}>
51+
percentiles: {
52+
p75: number
53+
}
54+
}>
55+
collectionPeriod: Record<'firstDate' | 'lastDate', {
56+
year: number
57+
month: number
58+
day: number
59+
}>
60+
}
61+
}
62+
63+
const cwvKeys = ['cumulative_layout_shift', 'first_contentful_paint', 'interaction_to_next_paint', 'largest_contentful_paint', 'experimental_time_to_first_byte'] satisfies Array<keyof CrUXResult['record']['metrics']>
64+
65+
function computeCWVScore (metrics: CrUXResult['record']['metrics']) {
66+
const scores: number[] = []
67+
for (const key of cwvKeys) {
68+
const passes = Number(metrics[key].percentiles.p75) < (Number(metrics[key].histogram[0].end || 0))
69+
scores.push(passes ? 100 : 0)
70+
}
71+
return scores.reduce((a, b) => a + b) / scores.length
72+
}
73+
74+
function normalizeHistogram (metric: CrUXResult['record']['metrics'][keyof CrUXResult['record']['metrics']]) {
75+
const segments = [] as number[]
76+
let count = 0
77+
for (const item of metric.histogram) {
78+
const value = Number(item.density || 0) * 100
79+
count += value
80+
segments.push(Math.round(count))
81+
}
82+
83+
const caption = metric.percentiles.p75 > 1000
84+
? (Number(metric.percentiles.p75) / 1000).toFixed(1) + 's'
85+
: Math.round(Number(metric.percentiles.p75)) + 'ms'
86+
87+
return {
88+
caption,
89+
segments,
90+
}
91+
}

0 commit comments

Comments
 (0)