Skip to content

Commit 1066b18

Browse files
committed
feat: add site statistics tracking and display in footer
1 parent f1e2d9b commit 1066b18

15 files changed

Lines changed: 435 additions & 39 deletions

app/components/SiteFooter.vue

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const commitUrl = computed(() =>
1212
? 'https://github.com/zyyv/zyyv.dev'
1313
: `https://github.com/zyyv/zyyv.dev/commit/${commitHash.value}`,
1414
)
15+
const { stats } = useSiteStats()
16+
const totalViews = computed(() => stats.value.totalViews?.toLocaleString('en-US') ?? '')
17+
const onlineVisitors = computed(() => stats.value.onlineVisitors?.toLocaleString('en-US') ?? '')
1518
1619
const socialLinks = [
1720
{ label: 'GitHub', href: 'https://github.com/zyyv', external: true },
@@ -27,11 +30,6 @@ function backToTop() {
2730

2831
<template>
2932
<footer class="site-footer" :class="{ 'site-footer--home': isHome }">
30-
<div class="site-footer__meta">
31-
<p>Chris / Front-end developer</p>
32-
<p>End of transmission</p>
33-
</div>
34-
3533
<p class="site-footer__statement">
3634
<span>You found the edge</span>
3735
<span>of the page. Nothing fell off.</span>
@@ -40,6 +38,16 @@ function backToTop() {
4038
<div class="site-footer__base">
4139
<div class="site-footer__identity">
4240
<p>© {{ year }} Chris</p>
41+
<dl class="site-footer__stats" aria-label="Site activity">
42+
<div>
43+
<dt>Views</dt>
44+
<dd>{{ totalViews }}</dd>
45+
</div>
46+
<div>
47+
<dt><span class="site-footer__live-dot" aria-hidden="true" /> Online</dt>
48+
<dd>{{ onlineVisitors }}</dd>
49+
</div>
50+
</dl>
4351
<nav class="site-footer__links" aria-label="Social links">
4452
<a
4553
v-for="link in socialLinks"
@@ -72,13 +80,12 @@ function backToTop() {
7280
<style scoped>
7381
.site-footer {
7482
width: 100%;
75-
padding: clamp(5rem, 10vw, 10rem) clamp(1rem, 4vw, 4rem) clamp(1.5rem, 3vw, 3rem);
83+
padding: clamp(3.5rem, 6vw, 6rem) clamp(1rem, 4vw, 4rem) clamp(1.5rem, 3vw, 3rem);
7684
border-top: 1px solid color-mix(in srgb, currentColor 16%, transparent);
7785
color: inherit;
7886
font-family: 'DM Sans', sans-serif;
7987
}
8088
81-
.site-footer__meta,
8289
.site-footer__base {
8390
display: grid;
8491
grid-template-columns: 1fr 1fr;
@@ -88,21 +95,16 @@ function backToTop() {
8895
text-transform: uppercase;
8996
}
9097
91-
.site-footer__meta p,
9298
.site-footer__base p,
9399
.site-footer__statement {
94100
margin: 0;
95101
}
96102
97-
.site-footer__meta p:last-child {
98-
text-align: right;
99-
}
100-
101103
.site-footer__statement {
102104
display: flex;
103105
flex-wrap: wrap;
104106
gap: 0.3em;
105-
margin: clamp(3rem, 6vw, 5rem) 0 clamp(3.5rem, 7vw, 6rem);
107+
margin: clamp(2.5rem, 4vw, 4rem) 0 clamp(3rem, 5vw, 4.5rem);
106108
font-size: clamp(1.15rem, 2.2vw, 1.7rem);
107109
font-weight: 500;
108110
line-height: 1.15;
@@ -119,15 +121,14 @@ function backToTop() {
119121
}
120122
121123
.site-footer--home .site-footer__statement {
122-
display: grid;
123-
margin: clamp(4.5rem, 10vw, 9rem) 0 clamp(5rem, 11vw, 10rem);
124-
font-size: clamp(3.9rem, 10.2vw, 10rem);
125-
line-height: 0.78;
126-
letter-spacing: -0.085em;
124+
margin: clamp(2.75rem, 5vw, 4.5rem) 0 clamp(3.25rem, 6vw, 5rem);
125+
font-size: clamp(2.1rem, 5vw, 5.25rem);
126+
line-height: 0.92;
127+
letter-spacing: -0.065em;
127128
}
128129
129130
.site-footer--home .site-footer__statement span:last-child {
130-
margin-left: 8vw;
131+
margin-left: clamp(1.5rem, 5vw, 5rem);
131132
}
132133
133134
.site-footer__base {
@@ -149,6 +150,38 @@ function backToTop() {
149150
gap: 0.45rem clamp(0.85rem, 1.8vw, 1.5rem);
150151
}
151152
153+
.site-footer__stats {
154+
display: flex;
155+
gap: 0.5rem clamp(0.9rem, 1.8vw, 1.5rem);
156+
margin: 0;
157+
font-variant-numeric: tabular-nums;
158+
}
159+
160+
.site-footer__stats div {
161+
display: flex;
162+
gap: 0.35rem;
163+
}
164+
165+
.site-footer__stats dt,
166+
.site-footer__stats dd {
167+
margin: 0;
168+
}
169+
170+
.site-footer__stats dt {
171+
color: color-mix(in srgb, currentColor 48%, transparent);
172+
}
173+
174+
.site-footer__live-dot {
175+
display: inline-block;
176+
width: 0.38rem;
177+
height: 0.38rem;
178+
margin-right: 0.18rem;
179+
border-radius: 50%;
180+
background: #4f9a68;
181+
box-shadow: 0 0 0 0.18rem color-mix(in srgb, #4f9a68 18%, transparent);
182+
vertical-align: 0.04rem;
183+
}
184+
152185
.site-footer__links a {
153186
color: inherit;
154187
text-decoration: none;
@@ -180,22 +213,34 @@ function backToTop() {
180213
181214
.site-footer__actions a,
182215
.site-footer__actions button {
216+
position: relative;
183217
display: inline-flex;
184218
align-items: center;
185219
gap: 0.45rem;
186220
padding: 0 0 0.2rem;
187221
border: 0;
188-
border-bottom: 1px solid currentColor;
189222
border-radius: 0;
190223
background: transparent;
191224
color: inherit;
192225
font: inherit;
193226
text-transform: uppercase;
194227
text-decoration: none;
195228
cursor: pointer;
196-
transition:
197-
opacity 240ms ease,
198-
transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
229+
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
230+
}
231+
232+
.site-footer__actions a::after,
233+
.site-footer__actions button::after {
234+
position: absolute;
235+
right: 0;
236+
bottom: 0;
237+
left: 0;
238+
height: 1px;
239+
background: currentColor;
240+
content: '';
241+
transform: scaleX(0);
242+
transform-origin: right center;
243+
transition: transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
199244
}
200245
201246
.site-footer__actions code {
@@ -206,10 +251,17 @@ function backToTop() {
206251
207252
.site-footer__actions a:hover,
208253
.site-footer__actions button:hover {
209-
opacity: 0.55;
210254
transform: translateY(-1px);
211255
}
212256
257+
.site-footer__actions a:hover::after,
258+
.site-footer__actions button:hover::after,
259+
.site-footer__actions a:focus-visible::after,
260+
.site-footer__actions button:focus-visible::after {
261+
transform: scaleX(1);
262+
transform-origin: left center;
263+
}
264+
213265
.site-footer__actions a:active,
214266
.site-footer__actions button:active {
215267
transform: translateY(1px);
@@ -223,12 +275,8 @@ function backToTop() {
223275
224276
@media (max-width: 767.9px) {
225277
.site-footer {
226-
padding-top: 5rem;
227-
padding-bottom: 5.5rem;
228-
}
229-
230-
.site-footer__meta {
231-
font-size: 0.62rem;
278+
padding-top: 3.5rem;
279+
padding-bottom: 4.5rem;
232280
}
233281
234282
.site-footer__statement {
@@ -238,9 +286,9 @@ function backToTop() {
238286
}
239287
240288
.site-footer--home .site-footer__statement {
241-
margin: 4.75rem 0 6rem;
242-
font-size: clamp(3.65rem, 18.5vw, 6rem);
243-
line-height: 0.82;
289+
margin: 3rem 0 4rem;
290+
font-size: clamp(2.65rem, 13vw, 4.5rem);
291+
line-height: 0.9;
244292
}
245293
246294
.site-footer--home .site-footer__statement span:last-child {
@@ -266,6 +314,11 @@ function backToTop() {
266314
gap: 0.75rem 1rem;
267315
}
268316
317+
.site-footer__stats {
318+
order: 3;
319+
width: 100%;
320+
}
321+
269322
.site-footer__links {
270323
gap: 0.45rem 0.85rem;
271324
}
@@ -274,6 +327,8 @@ function backToTop() {
274327
@media (prefers-reduced-motion: reduce) {
275328
.site-footer__actions a,
276329
.site-footer__actions button,
330+
.site-footer__actions a::after,
331+
.site-footer__actions button::after,
277332
.site-footer__links a {
278333
transition: none;
279334
}

app/composables/useSiteStats.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { SitePresenceMessage, SiteStatsResponse, SiteVisitResponse } from '~/types/site-stats'
2+
3+
const VISITOR_ID_KEY = 'zyyv:visitor-id'
4+
const VISIT_ID_KEY = 'zyyv:visit-id'
5+
const MAX_RECONNECT_DELAY = 30_000
6+
7+
export function useSiteStats() {
8+
const stats = useState<SiteStatsResponse>('site-stats', () => ({
9+
totalViews: null,
10+
onlineVisitors: null,
11+
}))
12+
const isStarted = useState('site-stats-started', () => false)
13+
14+
async function recordVisit(visitId: string) {
15+
try {
16+
const response = await $fetch<SiteVisitResponse>('/api/site-stats', {
17+
method: 'POST',
18+
body: { visitId },
19+
})
20+
stats.value = { ...stats.value, totalViews: response.totalViews }
21+
} catch {
22+
// Analytics should never interrupt navigation or footer interactions.
23+
}
24+
}
25+
26+
function start() {
27+
if (!import.meta.client || isStarted.value) return
28+
isStarted.value = true
29+
30+
let visitorId = localStorage.getItem(VISITOR_ID_KEY)
31+
if (!visitorId) {
32+
visitorId = crypto.randomUUID()
33+
localStorage.setItem(VISITOR_ID_KEY, visitorId)
34+
}
35+
const presenceVisitorId = visitorId
36+
37+
let visitId = sessionStorage.getItem(VISIT_ID_KEY)
38+
if (!visitId) {
39+
visitId = crypto.randomUUID()
40+
sessionStorage.setItem(VISIT_ID_KEY, visitId)
41+
}
42+
43+
void recordVisit(visitId)
44+
45+
if (import.meta.dev) {
46+
stats.value = { ...stats.value, onlineVisitors: 1 }
47+
return
48+
}
49+
50+
let socket: WebSocket | null = null
51+
let reconnectTimer: number | undefined
52+
let reconnectAttempts = 0
53+
let disposed = false
54+
55+
function connectPresence() {
56+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
57+
const url = new URL('/api/presence', `${protocol}//${location.host}`)
58+
url.searchParams.set('visitorId', presenceVisitorId)
59+
socket = new WebSocket(url)
60+
61+
socket.addEventListener('open', () => {
62+
reconnectAttempts = 0
63+
socket?.send('presence')
64+
})
65+
socket.addEventListener('message', (event) => {
66+
if (typeof event.data !== 'string') return
67+
try {
68+
const message = JSON.parse(event.data) as Partial<SitePresenceMessage>
69+
if (!Number.isSafeInteger(message.onlineVisitors) || message.onlineVisitors! < 0) return
70+
stats.value = { ...stats.value, onlineVisitors: message.onlineVisitors! }
71+
} catch {
72+
// Ignore malformed presence updates and keep the last valid count.
73+
}
74+
})
75+
socket.addEventListener('close', () => {
76+
if (disposed) return
77+
const delay = Math.min(1000 * 2 ** reconnectAttempts, MAX_RECONNECT_DELAY)
78+
reconnectAttempts += 1
79+
reconnectTimer = window.setTimeout(connectPresence, delay)
80+
})
81+
socket.addEventListener('error', () => socket?.close())
82+
}
83+
84+
connectPresence()
85+
86+
onScopeDispose(() => {
87+
disposed = true
88+
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer)
89+
socket?.close(1000, 'Page closed')
90+
})
91+
}
92+
93+
return {
94+
stats: readonly(stats),
95+
start,
96+
}
97+
}

app/plugins/site-stats.client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default defineNuxtPlugin(() => {
2+
const route = useRoute()
3+
if (route.path.startsWith('/admin')) return
4+
5+
const { start } = useSiteStats()
6+
start()
7+
})

app/types/site-stats.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface SiteStatsResponse {
2+
totalViews: number | null
3+
onlineVisitors: number | null
4+
}
5+
6+
export interface SiteVisitResponse {
7+
totalViews: number
8+
}
9+
10+
export interface SitePresenceMessage {
11+
onlineVisitors: number
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
CREATE TABLE site_stats (
2+
id INTEGER PRIMARY KEY CHECK (id = 1),
3+
total_views INTEGER NOT NULL DEFAULT 0,
4+
updated_at INTEGER NOT NULL
5+
);
6+
7+
INSERT INTO site_stats (id, total_views, updated_at)
8+
VALUES (1, 0, unixepoch());
9+
10+
CREATE TABLE site_visits (
11+
visit_id TEXT PRIMARY KEY,
12+
created_at INTEGER NOT NULL
13+
);

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
"url": "git+https://github.com/zyyv/zyyv.dev.git"
1919
},
2020
"type": "module",
21+
"imports": {
22+
"#nitro-output": {
23+
"types": "./worker/nitro-handler.d.ts",
24+
"default": "./.output/server/index.mjs"
25+
}
26+
},
2127
"scripts": {
2228
"dev": "nuxt dev",
2329
"build": "nuxt build",

0 commit comments

Comments
 (0)