]> git.morado.dev Git - blog.morado.dev/commitdiff
Add metrics
authorRoberto Morado <roramigator@duck.com>
Mon, 10 Nov 2025 00:08:39 +0000 (19:08 -0500)
committerRoberto Morado <roramigator@duck.com>
Mon, 10 Nov 2025 00:08:39 +0000 (19:08 -0500)
main.ts
tests/main_test.ts

diff --git a/main.ts b/main.ts
index 4f0fd0ddd56f458ce5d6ae2535dbe36906bfbaf8..ceb9f6858e9d3c7701c661ff278ff3f7837934e2 100644 (file)
--- a/main.ts
+++ b/main.ts
@@ -33,6 +33,8 @@ const config = {
   title: "God's Witness",
 };
 
+export const METRICS_KEY = ["metrics", "visits"] as const;
+
 await Deno.mkdir(STORAGE_DIR, { recursive: true });
 const kv = await Deno.openKv(`${STORAGE_DIR}/db`);
 export function __closeKvForTests() {
@@ -115,6 +117,18 @@ export function generateFilename(title: string, id: string): string {
   return `${slug}-${id}.md`;
 }
 
+async function incrementVisitCount(): Promise<number> {
+  const result = await kv.get<number>(METRICS_KEY);
+  const next = (result.value ?? 0) + 1;
+  await kv.set(METRICS_KEY, next);
+  return next;
+}
+
+export async function getVisitCount(): Promise<number> {
+  const result = await kv.get<number>(METRICS_KEY);
+  return result.value ?? 0;
+}
+
 // Session management
 function generateSessionId(): string {
   return crypto.randomUUID();
@@ -398,6 +412,41 @@ function renderHTML(content: string): string {
       color: #666;
       margin-bottom: 15px;
     }
+    .metrics-section {
+      background: white;
+      padding: 25px;
+      border-radius: 4px;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+      margin-bottom: 30px;
+    }
+    .metrics-section h3 {
+      margin-bottom: 15px;
+      font-size: 1.2em;
+      color: #333;
+    }
+    .metrics-grid {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 15px;
+    }
+    .metrics-card {
+      flex: 1;
+      min-width: 200px;
+      padding: 15px;
+      border-radius: 4px;
+      border: 1px solid #eee;
+      background: #fafafa;
+    }
+    .metrics-label {
+      font-size: 0.9em;
+      color: #777;
+      margin-bottom: 5px;
+    }
+    .metrics-value {
+      font-size: 1.8em;
+      font-weight: 600;
+      color: #222;
+    }
     .error {
       background: #f8d7da;
       color: #721c24;
@@ -493,6 +542,7 @@ async function renderPostView(postId: string): Promise<string | null> {
 async function renderAdminPage(
   username: string,
   csrfToken: string,
+  visitCount: number,
 ): Promise<string> {
   const posts = await getAllPosts();
   const sortedPosts = posts.sort((a, b) => b.timestamp - a.timestamp);
@@ -516,6 +566,18 @@ async function renderAdminPage(
       }).join("")
     : '<div class="empty">No posts yet. Create your first post below!</div>';
 
+  const metricsHTML = `
+    <section class="metrics-section">
+      <h3>Metrics</h3>
+      <div class="metrics-grid">
+        <div class="metrics-card">
+          <div class="metrics-label">Total visits</div>
+          <div class="metrics-value">${visitCount.toLocaleString()}</div>
+        </div>
+      </div>
+    </section>
+  `;
+
   return renderHTML(`
     ${renderHeader(username)}
 
@@ -541,6 +603,8 @@ code block
       <button type="submit">Publish</button>
     </form>
 
+    ${metricsHTML}
+
     <section>
       ${postsHTML}
     </section>
@@ -560,9 +624,9 @@ function loginFormResponse(message?: string, status = 200): Response {
   });
 }
 
-async function adminPageResponse(username: string): Promise<Response> {
+async function adminPageResponse(username: string, visitCount: number): Promise<Response> {
   const csrfToken = generateCsrfToken();
-  const html = await renderAdminPage(username, csrfToken);
+  const html = await renderAdminPage(username, csrfToken, visitCount);
   return new Response(html, {
     headers: {
       "Content-Type": "text/html",
@@ -631,6 +695,7 @@ export function parseFormData(body: string): Record<string, string> {
 
 export async function handler(req: Request): Promise<Response> {
   const url = new URL(req.url);
+  const visitCount = await incrementVisitCount();
 
   // Check authentication
   const sessionId = getSessionIdFromCookie(req);
@@ -671,7 +736,7 @@ export async function handler(req: Request): Promise<Response> {
       });
     }
 
-    return await adminPageResponse(session.username);
+    return await adminPageResponse(session.username, visitCount);
   }
 
   // Login page
index bffe6438be9afc1c3c715c3dc77454a655b41627..d68c1ed359ab63ea2e1aa6f787dd7588afcbee3e 100644 (file)
@@ -417,3 +417,29 @@ Deno.test("syncPosts regenerates Markdown files when KV is missing them", async
         }
     });
 });
+
+Deno.test("visit counter increments per request", async () => {
+    await withTempDir(async () => {
+        const mod = await import(
+            new URL("../main.ts", import.meta.url).href +
+                `?t=${crypto.randomUUID()}`
+        );
+        const { handler, METRICS_KEY, __closeKvForTests } = mod;
+
+        for (let i = 0; i < 3; i++) {
+            await handler(new Request("http://localhost/"));
+        }
+
+        const kv = await Deno.openKv("./storage/db");
+        const result = await kv.get<number>(METRICS_KEY);
+        kv.close();
+
+        if (result.value !== 3) {
+            throw new Error(
+                `Expected 3 visits recorded, got ${result.value ?? 0}`,
+            );
+        }
+
+        __closeKvForTests?.();
+    });
+});