Skip to content

Commit 4f9bd16

Browse files
authored
feat: display core web vitals results (#3)
1 parent 57ffbce commit 4f9bd16

File tree

13 files changed

+483
-166
lines changed

13 files changed

+483
-166
lines changed

app.vue

Lines changed: 42 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,29 @@
33
<div class="flex flex-col justify-start mt-4 sm:mt-8 md:my-12 p-4 gap-8 md:gap-12 flex-grow max-w-full">
44
<h1 class="flex flex-row gap-4 text-white text-3xl md:text-5xl">
55
<span class="text-green-400">&raquo;</span>
6-
<button v-if="domain && !editing" class="bg-transparent" @click="enableEditing">{{ domain }}</button>
7-
<form v-else class="flex flex-col gap-4 overflow-hidden" @submit.prevent="navigateToNewDomain">
8-
<input ref="input" v-model="newDomain" name="domain" type="text"
9-
class="md:-mt-1 rounded-none py-0 bg-transparent outline-none border-b-2 border-b-solid border-transparent focus:border-green-500 underline-dashed"
10-
autofocus inputmode="url" autocapitalize="none" placeholder="Enter a domain" required />
11-
<button type="submit"
12-
class="bg-green-400 text-black hover: hover:bg-white focus:bg-white active:bg-white text-xl md:text-2xl py-2 px-6 md:self-start">See
13-
results</button>
14-
</form>
6+
<TheDomainForm v-model:domain="domain" v-model:editing="editing" />
157
</h1>
168
<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>
9+
<template v-if="status === 'pending' || results">
10+
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
11+
<CoreWebVitals v-if="status === 'pending' || results?.crux" :pass="results?.crux?.cwv"
12+
:lcp="results?.crux?.lcp" :cls="results?.crux?.cls" :inp="results?.crux?.inp"
13+
:loading="status === 'pending'" size="normal" show-p75 />
14+
<template v-else-if="results">
15+
<ProgressRing size="normal" :value="results.lighthouse.performance" caption="performance" />
16+
<ProgressRing size="normal" :value="results.lighthouse.accessibility" caption="accessibility" />
17+
<ProgressRing size="normal" :value="results.lighthouse.bestPractices" caption="best practices" />
18+
<ProgressRing size="normal" :value="results.lighthouse.seo" caption="SEO" />
19+
</template>
20+
</div>
21+
<LighthouseTable v-if="status === 'pending' || (results && results.crux)" :loading="status === 'pending'"
22+
v-bind="results?.lighthouse || {}" />
23+
</template>
2724
<div v-else-if="domain">
2825
No results could be fetched. Is it a valid domain?
2926
</div>
30-
<div class="flex flex-col gap-2 mt-auto md:mt-8">
31-
<NuxtLink type="submit"
32-
class="bg-green-400 text-black hover: hover:bg-white focus:bg-white active:bg-white text-xl md:text-2xl py-2 px-6 md:self-start mb-8"
33-
:href="shareLink" @click.prevent="nativeShare">
34-
Share results
35-
</NuxtLink>
36-
<a :href="`https://pagespeed.web.dev/analysis?url=https://${domain}`"
37-
class="self-start underline text-gray-400 hover:text-green-400 focus:text-green-400 active:text-green-400">
38-
See full results on PageSpeed Insights &raquo;
39-
</a>
40-
<span v-if="results?.timestamp" class="text-gray-400">
41-
Last updated at
42-
<NuxtTime :datetime="results.timestamp" dateStyle="full" timeStyle="medium" />.
43-
</span>
44-
</div>
27+
<TheShareLink v-if="results" :domain="domain" :type="results.crux ? 'crux' : 'pagespeed-insights'"
28+
:timestamp="results.crux?.timestamp || results.lighthouse.timestamp" />
4529
</template>
4630
</div>
4731
<footer class="mt-auto p-3 text-gray-400">
@@ -58,7 +42,7 @@
5842

5943
<script lang="ts" setup>
6044
import '@unocss/reset/tailwind-compat.css'
61-
import { joinURL, withoutLeadingSlash, parseURL } from 'ufo'
45+
import { joinURL, withoutLeadingSlash } from 'ufo'
6246
6347
const route = useRoute()
6448
const domain = computed(() => withoutLeadingSlash(route.path).toLowerCase().replace(/(\/|\?).*$/, '').trim())
@@ -69,75 +53,26 @@ if (domain.value && !/^[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/g.test(domain.value)) {
6953
}
7054
7155
const editing = ref(!domain.value)
72-
const newDomain = ref('')
73-
const input = ref<HTMLInputElement>()
74-
75-
function enableEditing () {
76-
newDomain.value = domain.value
77-
editing.value = true
78-
watch(input, (input) => {
79-
if (input) {
80-
input.focus()
81-
input.setSelectionRange(0, newDomain.value.length)
82-
}
83-
}, { once: true })
84-
}
8556
86-
const { data: results, status, refresh } = await useFetch(() => `/api/run/${domain.value}`, {
57+
const { data: results, status, refresh } = await useAsyncData(async () => {
58+
const [lighthouse, crux] = await Promise.all([
59+
$fetch(`/api/run/${domain.value}`),
60+
$fetch(`/api/crux/${domain.value}`).catch(() => null)
61+
])
62+
return { lighthouse, crux }
63+
}, {
64+
watch: [domain],
8765
immediate: !!domain.value,
8866
})
8967
9068
if (!domain.value) {
9169
watch(domain, () => refresh(), { once: true })
9270
}
9371
94-
const favicon = computed(() => {
95-
const radius = 80
96-
const stroke = 14
97-
const normalizedRadius = radius - stroke * 2
98-
const circumference = normalizedRadius * 2 * Math.PI
99-
100-
const value = status.value === 'pending' || (domain.value && !results.value)
101-
? undefined
102-
: (results.value ? results.value.performance : 100)
103-
const color = !value ? '#6b7280' : value >= 90 ? '#23c55e' : value >= 50 ? '#fbbf24' : '#ef4444'
104-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" height="${radius * 2}" width="${radius * 2}">
105-
<style>@keyframes spin {
106-
from {transform: rotate(0deg)}
107-
to {transform: rotate(360deg)}
108-
}</style>
109-
<circle
110-
stroke="${color}"
111-
fill="transparent"
112-
stroke-linecap="round"
113-
stroke-dasharray="${circumference + ' ' + circumference}"
114-
style="transform-origin:center;stroke-dashoffset:${circumference - (Math.floor((value || 85) / 4) * 4) / 100 * circumference};transform:rotate(270deg)${!value ? ';animation:spin 1s linear infinite' : ''}"
115-
stroke-width="${stroke}"
116-
r="${normalizedRadius}"
117-
cx="${radius}"
118-
cy="${radius}"
119-
/>
120-
<circle
121-
fill="${color}"
122-
stroke-width="${stroke}"
123-
r="${normalizedRadius - 35}"
124-
cx="${radius}"
125-
cy="${radius}"
126-
/>
127-
</svg>`
128-
return `data:image/svg+xml;base64,${btoa(svg)}`
129-
})
72+
useFavicon(() => status.value !== 'pending' && !!domain.value && (results.value ? results.value.lighthouse.performance : 100))
13073
13174
useHead({
13275
title: () => domain.value ? domain.value : 'page-speed.dev',
133-
link: [
134-
() => ({
135-
key: 'favicon',
136-
rel: 'icon',
137-
type: 'image/svg',
138-
href: favicon.value
139-
})
140-
]
14176
})
14277
14378
useServerHead({
@@ -186,49 +121,29 @@ useServerSeoMeta({
186121
if (!domain.value) {
187122
defineOgImageComponent('Home')
188123
useServerSeoMeta({
189-
description: 'See and share PageSpeed Insights results simply and easily.',
124+
description: 'See and share Core Web Vitals and Page Speed Insights results simply and easily.',
190125
})
191126
} else if (results.value) {
192127
useServerSeoMeta({
193128
description:
194-
`Performance: ${results.value?.performance} | ` +
195-
`Accessibility: ${results.value?.accessibility} | ` +
196-
`Best Practices: ${results.value?.bestPractices} | ` +
197-
`SEO: ${results.value?.seo}`
129+
results.value?.crux
130+
?
131+
`Core Web Vitals: ${results.value?.crux.cwv ? 'pass' : 'fail'} | ` +
132+
`LCP: ${results.value?.crux.lcp.caption} | ` +
133+
`CLS: ${results.value?.crux.cls.caption} | ` +
134+
`INP: ${results.value?.crux.inp.caption}`
135+
:
136+
`Performance: ${results.value?.lighthouse.performance} | ` +
137+
`Accessibility: ${results.value?.lighthouse.accessibility} | ` +
138+
`Best Practices: ${results.value?.lighthouse.bestPractices} | ` +
139+
`SEO: ${results.value?.lighthouse.seo}`
198140
})
199141
200142
defineOgImageComponent('Lighthouse', {
201-
performance: results.value?.performance,
202-
accessibility: results.value?.accessibility,
203-
bestPractices: results.value?.bestPractices,
204-
seo: results.value?.seo,
143+
lighthouse: results.value.lighthouse,
144+
crux: results.value?.crux,
205145
domain: domain.value,
206146
})
207147
}
208148
209-
function navigateToNewDomain () {
210-
if (!newDomain.value) { return }
211-
212-
const host = parseURL(newDomain.value).host || newDomain.value
213-
editing.value = false
214-
return navigateTo('/' + withoutLeadingSlash(host).toLowerCase().replace(/(\/|\?).*$/, '').trim())
215-
}
216-
217-
const shareLink = computed(() => domain.value ? `https://twitter.com/intent/tweet?text=${encodeURIComponent(`Check out the Page Speed results for ${domain.value.replace(/\./g, '.​')}` + `\n\nhttps://page-speed.dev/${domain.value}`)}` : 'See and share PageSpeed Insights results simply and easily.')
218-
219-
async function nativeShare () {
220-
try {
221-
if (navigator.share) {
222-
return await navigator.share({
223-
title: 'page-speed.dev',
224-
text: `See page speed results for ${domain.value.replace(/\./g, '.​')}`,
225-
url: canonicalURL.value,
226-
})
227-
}
228-
} catch {
229-
// ignore errors sharing to native share and fall back directly to Twitter
230-
}
231-
return await navigateTo(shareLink.value, { external: true, open: { target: '_blank' } })
232-
}
233-
234149
</script>

components/Histogram.vue

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

components/OgImage/Lighthouse.vue

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,39 @@ 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+
<CoreWebVitals
24+
:pass="crux.cwv"
25+
:lcp="crux.lcp"
26+
:cls="crux.cls"
27+
:inp="crux.inp"
28+
/>
29+
</template>
30+
<template v-else-if="lighthouse">
31+
<ProgressRing :value="lighthouse.performance" caption="performance" />
32+
<ProgressRing :value="lighthouse.accessibility" caption="accessibility" />
33+
<ProgressRing :value="lighthouse.bestPractices" caption="best practices" />
34+
<ProgressRing :value="lighthouse.seo" caption="SEO" />
35+
</template>
36+
</div>
37+
<LighthouseTable v-if="crux" v-bind="lighthouse" class="text-2xl" />
3438
</div>
35-
<div class="flex flex-row gap-4 self-start text-white text-5xl pl-16 pb-24">
39+
<div class="flex flex-row gap-4 self-end text-white text-5xl pr-8 pt-4 pb-24">
3640
<span class="text-green-400">&raquo;</span> {{ domain }}
3741
</div>
3842
</div>

0 commit comments

Comments
 (0)