Skip to content

Commit

Permalink
feat: display core web vitals results
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe committed Feb 6, 2024
1 parent 57ffbce commit c933bb6
Show file tree
Hide file tree
Showing 7 changed files with 293 additions and 63 deletions.
95 changes: 71 additions & 24 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,52 @@
</form>
</h1>
<template v-if="!editing && domain">
<div v-if="status === 'pending' || results"
class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.performance : undefined"
caption="performance" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.accessibility : undefined"
caption="accessibility" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.bestPractices : undefined"
caption="best practices" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.seo : undefined" caption="SEO" />
</div>
<template v-if="status === 'pending' || results">
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<template v-if="status === 'pending' || results?.crux">
<ProgressRing size="normal" :value="results?.crux && status !== 'pending' ? results.crux.cwv : undefined"
caption="core web vitals" />
<Histogram size="normal" :value="results?.crux && status !== 'pending' ? results.crux.lcp : undefined"
caption="LCP" />
<Histogram size="normal" :value="results?.crux && status !== 'pending' ? results.crux.cls : undefined"
caption="CLS" />
<Histogram size="normal" :value="results?.crux && status !== 'pending' ? results.crux.inp : undefined"
caption="INP" />
</template>
<template v-else-if="results">
<ProgressRing size="normal" :value="results.lighthouse.performance" caption="performance" />
<ProgressRing size="normal" :value="results.lighthouse.accessibility" caption="accessibility" />
<ProgressRing size="normal" :value="results.lighthouse.bestPractices" caption="best practices" />
<ProgressRing size="normal" :value="results.lighthouse.seo" caption="SEO" />
</template>
</div>
<div v-if="status === 'pending' || (results && results.crux)"
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">
<span class="flex flex-row gap-2">
<span class="font-bold">{{ results && status !== 'pending' ? results.lighthouse.performance.toFixed(0) :
undefined
}}</span>
<span>performance</span>
</span>
<span class="flex flex-row gap-2">
<span class="font-bold">{{ results && status !== 'pending' ? results.lighthouse.accessibility.toFixed(0) :
undefined
}}</span>
<span>accessibility</span>
</span>
<span class="flex flex-row gap-2">
<span class="font-bold">{{ results && status !== 'pending' ? results.lighthouse.bestPractices.toFixed(0) :
undefined
}}</span>
<span>best practices</span>
</span>
<span class="flex flex-row gap-2">
<span class="font-bold">{{ results && status !== 'pending' ? results.lighthouse.seo.toFixed(0) : undefined
}}</span>
<span>SEO</span>
</span>
</div>
</template>
<div v-else-if="domain">
No results could be fetched. Is it a valid domain?
</div>
Expand All @@ -33,13 +69,19 @@
:href="shareLink" @click.prevent="nativeShare">
Share results
</NuxtLink>
<a :href="`https://pagespeed.web.dev/analysis?url=https://${domain}`"
<a v-if="results?.crux"
:href="`https://lookerstudio.google.com/c/u/0/reporting/bbc5698d-57bb-4969-9e07-68810b9fa348/page/keDQB?params=%7B%22origin%22:%22https://${domain}%22%7D`"
class="self-start underline text-gray-400 hover:text-green-400 focus:text-green-400 active:text-green-400">
Explore full results in the CrUX Dashboard &raquo;
</a>
<a v-else :href="`https://pagespeed.web.dev/analysis?url=https://${domain}`"
class="self-start underline text-gray-400 hover:text-green-400 focus:text-green-400 active:text-green-400">
See full results on PageSpeed Insights &raquo;
</a>
<span v-if="results?.timestamp" class="text-gray-400">
<span v-if="results?.crux?.timestamp || results?.lighthouse?.timestamp" class="text-gray-400">
Last updated at
<NuxtTime :datetime="results.timestamp" dateStyle="full" timeStyle="medium" />.
<NuxtTime :datetime="results.crux?.timestamp || results.lighthouse.timestamp" dateStyle="full"
timeStyle="medium" />.
</span>
</div>
</template>
Expand Down Expand Up @@ -83,7 +125,14 @@ function enableEditing () {
}, { once: true })
}
const { data: results, status, refresh } = await useFetch(() => `/api/run/${domain.value}`, {
const { data: results, status, refresh } = await useAsyncData(async () => {
const [lighthouse, crux] = await Promise.all([
$fetch(`/api/run/${domain.value}`),
$fetch(`/api/crux/${domain.value}`).catch(() => null)
])
return { lighthouse, crux }
}, {
watch: [domain],
immediate: !!domain.value,
})
Expand All @@ -99,7 +148,7 @@ const favicon = computed(() => {
const value = status.value === 'pending' || (domain.value && !results.value)
? undefined
: (results.value ? results.value.performance : 100)
: (results.value ? results.value.crux?.cwv || results.value.lighthouse.performance : 100)
const color = !value ? '#6b7280' : value >= 90 ? '#23c55e' : value >= 50 ? '#fbbf24' : '#ef4444'
const svg = `<svg xmlns="http://www.w3.org/2000/svg" height="${radius * 2}" width="${radius * 2}">
<style>@keyframes spin {
Expand Down Expand Up @@ -186,22 +235,20 @@ useServerSeoMeta({
if (!domain.value) {
defineOgImageComponent('Home')
useServerSeoMeta({
description: 'See and share PageSpeed Insights results simply and easily.',
description: 'See and share Core Web Vitals and Page Speed Insights results simply and easily.',
})
} else if (results.value) {
useServerSeoMeta({
description:
`Performance: ${results.value?.performance} | ` +
`Accessibility: ${results.value?.accessibility} | ` +
`Best Practices: ${results.value?.bestPractices} | ` +
`SEO: ${results.value?.seo}`
`Performance: ${results.value?.lighthouse.performance} | ` +
`Accessibility: ${results.value?.lighthouse.accessibility} | ` +
`Best Practices: ${results.value?.lighthouse.bestPractices} | ` +
`SEO: ${results.value?.lighthouse.seo}`
})
defineOgImageComponent('Lighthouse', {
performance: results.value?.performance,
accessibility: results.value?.accessibility,
bestPractices: results.value?.bestPractices,
seo: results.value?.seo,
lighthouse: results.value.lighthouse,
crux: results.value?.crux,
domain: domain.value,
})
}
Expand Down
62 changes: 62 additions & 0 deletions components/Histogram.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
const props = defineProps({
value: {
type: Object as () => {
caption: string
segments: number[]
},
},
caption: {
type: String,
},
size: {
type: String as () => 'large' | 'normal',
default: 'large'
}
})
const radius = props.size === 'large' ? 132 : 75
const stroke = props.size === 'large' ? 14 : 9
const normalizedRadius = radius - stroke * 2
const circumference = normalizedRadius * 2 * Math.PI
const colorMap = [
'#ef4444',
'#fbbf24',
'#23c55e'
]
</script>

<template>
<span class="flex flex-col items-center" :class="size === 'large' ? 'gap-10' : 'gap-4'">
<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']">
<svg class="absolute -right-0 -bottom-0" :height="radius * 2" :width="radius * 2" :class="{ 'animate-spin': !value }">
<circle
v-for="segment, index of [...value?.segments || [80]].reverse()"
:stroke="value ? colorMap[index] : '#6b7280'"
fill="transparent"
class="transform-origin-center"
:stroke-dasharray="circumference + ' ' + circumference"
:style="{
strokeDashoffset: circumference - segment / 100 * circumference,
transform: `rotate(270deg)`,
}"
:stroke-width="stroke"
:r="normalizedRadius"
:cx="radius"
:cy="radius"
/>
</svg>
<slot>
<span class="flex flex-row items-baseline gap-1">
<span :class="size === 'large' ? 'text-6xl' : 'text-3xl'">{{ value?.caption.replace(/m?s/, '') }}</span>
<span v-if="value?.caption?.match(/m?s/)" :class="size === 'large' ? 'text-4xl' : 'text-lg'">{{ value?.caption?.match(/m?s/)![0] }}</span>
</span>
</slot>
</span>
<span :class="size === 'large' ? 'text-4xl' : 'text-2xl'">{{ caption }}</span>
</span>
</template>

64 changes: 44 additions & 20 deletions components/OgImage/Lighthouse.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,59 @@ defineProps({
type: String,
required: true,
},
performance: {
type: Number,
required: true,
},
accessibility: {
type: Number,
required: true,
lighthouse: {
type: Object,
required: true
},
bestPractices: {
type: Number,
required: true,
},
seo: {
type: Number,
required: true,
crux: {
type: Object
},
})
</script>

<template>
<div class="h-full w-full flex items-start justify-start border-solid bg-[#212121] text-white">
<div class="flex flex-col items-center justify-between h-full w-full">
<div class="flex flex-row justify-center gap-12 pt-12 w-full">
<ProgressRing :value="performance" caption="performance" />
<ProgressRing :value="accessibility" caption="accessibility" />
<ProgressRing :value="bestPractices" caption="best practices" />
<ProgressRing :value="seo" caption="SEO" />
<div class="flex flex-col justify-center gap-8 pt-12 pb-6 w-full">
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<template v-if="crux">
<ProgressRing :value="crux.cwv"
caption="core web vitals" />
<Histogram :value="crux.lcp"
caption="LCP" />
<Histogram :value="crux.cls"
caption="CLS" />
<Histogram :value="crux.inp"
caption="INP" />
</template>
<template v-else-if="lighthouse">
<ProgressRing :value="lighthouse.performance" caption="performance" />
<ProgressRing :value="lighthouse.accessibility" caption="accessibility" />
<ProgressRing :value="lighthouse.bestPractices" caption="best practices" />
<ProgressRing :value="lighthouse.seo" caption="SEO" />
</template>
</div>
<div v-if="crux"
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">
<span class="flex flex-row gap-2">
<span class="font-bold">{{ lighthouse.performance }}</span>
<span>performance</span>
</span>
<span class="flex flex-row gap-2">
<span class="font-bold">{{ lighthouse.accessibility }}</span>
<span>accessibility</span>
</span>
<span class="flex flex-row gap-2">
<span class="font-bold">{{ lighthouse.bestPractices }}</span>
<span>best practices</span>
</span>
<span class="flex flex-row gap-2">
<span class="font-bold">{{ lighthouse.seo }}</span>
<span>SEO</span>
</span>
</div>
</div>
<div class="flex flex-row gap-4 self-start text-white text-5xl pl-16 pb-24">
<div class="flex flex-row gap-4 self-end text-white text-5xl pr-8 pt-4 pb-24">
<span class="text-green-400">&raquo;</span> {{ domain }}
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@azure/identity": "^4.0.1",
"@azure/storage-blob": "^12.17.0",
"@unocss/nuxt": "^0.58.4",
"nitropack": "^2.8.1",
"nuxt": "^3.10.0",
"nuxt-og-image": "^3.0.0-rc.30",
"nuxt-time": "^0.1.2",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 91 additions & 0 deletions server/api/crux/[domain].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export default defineCachedEventHandler(async event => {
const domain = getRouterParam(event, 'domain')
if (!domain || domain.includes('/') || domain.includes('%')) {
throw createError({ message: 'Invalid domain', statusCode: 422 })
}

try {
const results = await $fetch<CrUXResult>(`/records:queryRecord`, {
baseURL: 'https://chromeuxreport.googleapis.com/v1',
method: 'POST',
query: {
key: useRuntimeConfig().google.apiToken,
},
body: {
origin: `https://${domain}`,
formFactor: 'PHONE'
},
})

return {
cwv: computeCWVScore(results.record.metrics),
lcp: normalizeHistogram(results.record.metrics['largest_contentful_paint']),
cls: normalizeHistogram(results.record.metrics['cumulative_layout_shift']),
inp: normalizeHistogram(results.record.metrics['interaction_to_next_paint']),
timestamp: Date.now(),
}
} catch (e) {
console.error(e)
throw createError({ message: 'No CrUX report available', statusCode: 404 })
}
}, {
base: 'pagespeed',
swr: true,
shouldBypassCache: () => true,
getKey: (event) => 'crux:domain:' + getRouterParam(event, 'domain'),
maxAge: 60 * 60,
staleMaxAge: 24 * 60 * 60,
})

/** Helpers */

interface CrUXResult {
record: {
key: { origin: string }
metrics: Record<'cumulative_layout_shift' | 'first_contentful_paint' | /* 'first_input_delay' | */ 'interaction_to_next_paint' | 'largest_contentful_paint' | 'experimental_time_to_first_byte', {
histogram: Array<{
start: number | string
end?: number | string
density?: number
}>
percentiles: {
p75: number
}
}>
collectionPeriod: Record<'firstDate' | 'lastDate', {
year: number
month: number
day: number
}>
}
}

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']>

function computeCWVScore (metrics: CrUXResult['record']['metrics']) {
const scores: number[] = []
for (const key of cwvKeys) {
const passes = Number(metrics[key].percentiles.p75) < (Number(metrics[key].histogram[0].end || 0))
scores.push(passes ? 100 : 0)
}
return scores.reduce((a, b) => a + b) / scores.length
}

function normalizeHistogram (metric: CrUXResult['record']['metrics'][keyof CrUXResult['record']['metrics']]) {
const segments = [] as number[]
let count = 0
for (const item of metric.histogram) {
const value = Number(item.density || 0) * 100
count += value
segments.push(Math.round(count))
}

const caption = metric.percentiles.p75 > 1000
? (Number(metric.percentiles.p75) / 1000).toFixed(1) + 's'
: Math.round(Number(metric.percentiles.p75)) + 'ms'

return {
caption,
segments,
}
}
Loading

0 comments on commit c933bb6

Please sign in to comment.