From 4a322e1b1fb957553a310679e767713f57b4d600 Mon Sep 17 00:00:00 2001 From: Andy Meier Date: Wed, 15 Apr 2026 10:40:14 -0400 Subject: [PATCH] add Google Analytics with consent banner --- app/src/App/src/Articles/Handler.fs | 6 ++--- app/src/App/src/Common/Handler.fs | 5 ++-- app/src/App/src/Common/View.fs | 42 +++++++++++++++++++++++++++-- app/src/App/src/Config.fs | 9 +++++++ app/src/App/src/Index/Handler.fs | 2 +- app/src/App/src/Projects/Handler.fs | 2 +- app/src/App/src/Services/Handler.fs | 2 +- app/src/Tests/Tests.fsproj | 1 + app/src/Tests/ViewTests.fs | 26 ++++++++++++++++++ pulumi/src/config.ts | 6 +++++ pulumi/src/k8s/deployment.ts | 1 + 11 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 app/src/Tests/ViewTests.fs diff --git a/app/src/App/src/Articles/Handler.fs b/app/src/App/src/Articles/Handler.fs index 06299bb..0db339b 100644 --- a/app/src/App/src/Articles/Handler.fs +++ b/app/src/App/src/Articles/Handler.fs @@ -21,7 +21,7 @@ let private getArticlesPage (services:Services) : HttpHandler = do! pushUrl ds "/articles" return Some ctx else - return! renderPage page "nav-articles" next ctx + return! renderPage services page "nav-articles" next ctx } let private getArticlePage (services:Services) (id:string) : HttpHandler = @@ -39,7 +39,7 @@ let private getArticlePage (services:Services) (id:string) : HttpHandler = do! pushUrl ds url return Some ctx else - return! renderPage page "nav-articles" next ctx + return! renderPage services page "nav-articles" next ctx | None -> let page = notFoundPage if ctx.IsDatastar then @@ -47,7 +47,7 @@ let private getArticlePage (services:Services) (id:string) : HttpHandler = do! patchElement ds page return Some ctx else - return! renderPage page "nav-articles" next ctx + return! renderPage services page "nav-articles" next ctx } let handler (services:Services) : HttpHandler = diff --git a/app/src/App/src/Common/Handler.fs b/app/src/App/src/Common/Handler.fs index bfd1bea..ac3b817 100644 --- a/app/src/App/src/Common/Handler.fs +++ b/app/src/App/src/Common/Handler.fs @@ -1,6 +1,7 @@ module App.Common.Handler open App.Common.View +open App.ServiceRegistry open Giraffe open FSharp.ViewEngine open Microsoft.AspNetCore.Http @@ -21,9 +22,9 @@ let pushUrl (ds:IDatastarService) (url:string) = task { do! ds.ExecuteScriptAsync(js) } -let renderPage (page:HtmlElement) (selectedNav:string) : HttpHandler = +let renderPage (services:Services) (page:HtmlElement) (selectedNav:string) : HttpHandler = fun next ctx -> task { - let doc = Document.primary(page, selectedNav=selectedNav) + let doc = Document.primary(page, services.config.googleAnalytics.measurementId, selectedNav) let html = Render.toHtmlDocString doc return! htmlString html next ctx } diff --git a/app/src/App/src/Common/View.fs b/app/src/App/src/Common/View.fs index 8ae280b..2c689cc 100644 --- a/app/src/App/src/Common/View.fs +++ b/app/src/App/src/Common/View.fs @@ -95,7 +95,7 @@ module ArticleCard = _class "mt-2 text-xl font-semibold tracking-tight text-gray-900 dark:text-gray-100" a { _href url - _dataOn ("click", $"@get('{url}')") + _dataOn ("click__prevent", $"@get('{url}')") _class "hover:text-emerald-600 dark:hover:text-emerald-400" article'.title } @@ -266,7 +266,7 @@ module Page = div { _id "page"; _class "min-h-screen bg-gray-100 dark:bg-gray-900"; page } type Document = - static member primary (page:HtmlElement, ?selectedNav:string) = + static member primary (page:HtmlElement, googleAnalyticsMeasurementId:string, ?selectedNav:string) = let selectedNav = defaultArg selectedNav "" html { _lang "en" @@ -275,6 +275,9 @@ type Document = meta { _charset "UTF-8" } meta { _name "viewport"; _content "width=device-width, initial-scale=1.0" } script { js "let t=localStorage.getItem('theme');if(t==='dark'||(!t||t==='system')&&window.matchMedia('(prefers-color-scheme: dark)').matches){document.documentElement.classList.add('dark')}" } + script { + js $"window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){{dataLayer.push(arguments);}};gtag('consent','default',{{analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'}});window.loadGoogleAnalytics=window.loadGoogleAnalytics||function(){{if(window.__gaLoaded)return;window.__gaLoaded=true;var s=document.createElement('script');s.async=true;s.src='https://www.googletagmanager.com/gtag/js?id={googleAnalyticsMeasurementId}';document.head.appendChild(s);gtag('js',new Date());gtag('config','{googleAnalyticsMeasurementId}');}};window.applyAnalyticsConsent=window.applyAnalyticsConsent||function(v){{if(v==='accepted'){{gtag('consent','update',{{analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'}});window.loadGoogleAnalytics();}}else{{gtag('consent','update',{{analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'}});}}}};window.setAnalyticsConsent=window.setAnalyticsConsent||function(v){{localStorage.setItem('analytics-consent',v);window.applyAnalyticsConsent(v);var b=document.getElementById('cookie-consent-banner');if(b)b.classList.add('hidden');}};document.addEventListener('DOMContentLoaded',function(){{var saved=localStorage.getItem('analytics-consent');var banner=document.getElementById('cookie-consent-banner');if(saved==='accepted'||saved==='declined'){{window.applyAnalyticsConsent(saved);if(banner)banner.classList.add('hidden');}}else if(banner){{banner.classList.remove('hidden');}}}});" + } link { _href "/css/compiled.css"; _rel "stylesheet" } link { _href "/css/prism.css"; _rel "stylesheet" } script { _type "module"; _src "/scripts/tailwindplus-elements.1.js" } @@ -289,6 +292,41 @@ type Document = page Footer.primary } + div { + _id "cookie-consent-banner" + _class "hidden fixed inset-x-0 bottom-0 z-50 border-t border-gray-300 bg-white/95 p-4 shadow-2xl backdrop-blur dark:border-gray-700 dark:bg-gray-900/95" + _role "dialog" + _ariaLive "polite" + div { + _class "mx-auto flex max-w-5xl flex-col gap-4 md:flex-row md:items-center md:justify-between" + div { + _class "max-w-3xl" + p { + _class "text-sm font-semibold text-gray-900 dark:text-gray-100" + "Analytics cookies" + } + p { + _class "mt-1 text-sm text-gray-600 dark:text-gray-300" + "I use Google Analytics to measure how this site is used. You can accept or reject analytics cookies, and the site will work either way." + } + } + div { + _class "flex flex-col gap-2 sm:flex-row" + button { + _type "button" + _class "inline-flex items-center justify-center rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:cursor-pointer dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800" + _onclick "setAnalyticsConsent('declined')" + "Reject" + } + button { + _type "button" + _class "inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 hover:cursor-pointer dark:bg-emerald-500 dark:hover:bg-emerald-400" + _onclick "setAnalyticsConsent('accepted')" + "Accept" + } + } + } + } script { js "function getInitialTheme(){return localStorage.getItem('theme')||'system'};function applyTheme(t){var d=document.documentElement,isDark=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);d.classList.toggle('dark',isDark)};function setTheme(t){localStorage.setItem('theme',t);applyTheme(t)}" } } } diff --git a/app/src/App/src/Config.fs b/app/src/App/src/Config.fs index feba4a5..6e28718 100644 --- a/app/src/App/src/Config.fs +++ b/app/src/App/src/Config.fs @@ -17,11 +17,19 @@ module ServerConfig = let load () = { url = Env.variableOrDefault "SERVER_URL" "https://localhost:5000" } +type GoogleAnalyticsConfig = + { measurementId:string } + +module GoogleAnalyticsConfig = + let load () = + { measurementId = Env.variable "GOOGLE_ANALYTICS_MEASUREMENT_ID" } + type Config = { debug:bool appName:string server:ServerConfig seq:SeqConfig + googleAnalytics:GoogleAnalyticsConfig sqlite:Sqlite.Config notion:Notion.Config } @@ -31,5 +39,6 @@ module Config = appName = "andymeier" server = ServerConfig.load () seq = SeqConfig.load () + googleAnalytics = GoogleAnalyticsConfig.load () sqlite = Sqlite.Config.load () notion = Notion.Config.load () } diff --git a/app/src/App/src/Index/Handler.fs b/app/src/App/src/Index/Handler.fs index 6e6fe3c..c1651ba 100644 --- a/app/src/App/src/Index/Handler.fs +++ b/app/src/App/src/Index/Handler.fs @@ -21,7 +21,7 @@ let private getHomePage (services:Services) : HttpHandler = do! pushUrl ds "/" return Some ctx else - return! renderPage page "nav-home" next ctx + return! renderPage services page "nav-home" next ctx } let handler (services:Services) : HttpHandler = diff --git a/app/src/App/src/Projects/Handler.fs b/app/src/App/src/Projects/Handler.fs index 365fd80..d8fb500 100644 --- a/app/src/App/src/Projects/Handler.fs +++ b/app/src/App/src/Projects/Handler.fs @@ -19,7 +19,7 @@ let private getPage (services:Services) : HttpHandler = do! pushUrl ds "/projects" return Some ctx else - return! renderPage page "nav-projects" next ctx + return! renderPage services page "nav-projects" next ctx } let handler (services:Services) : HttpHandler = diff --git a/app/src/App/src/Services/Handler.fs b/app/src/App/src/Services/Handler.fs index 9de51d2..06f453e 100644 --- a/app/src/App/src/Services/Handler.fs +++ b/app/src/App/src/Services/Handler.fs @@ -19,7 +19,7 @@ let private getPage (services:Services) : HttpHandler = do! pushUrl ds "/services" return Some ctx else - return! renderPage page "nav-services" next ctx + return! renderPage services page "nav-services" next ctx } let handler (services:Services) : HttpHandler = diff --git a/app/src/Tests/Tests.fsproj b/app/src/Tests/Tests.fsproj index 52545b9..41673b8 100644 --- a/app/src/Tests/Tests.fsproj +++ b/app/src/Tests/Tests.fsproj @@ -12,6 +12,7 @@ + diff --git a/app/src/Tests/ViewTests.fs b/app/src/Tests/ViewTests.fs new file mode 100644 index 0000000..5608288 --- /dev/null +++ b/app/src/Tests/ViewTests.fs @@ -0,0 +1,26 @@ +module ViewTests + +open App.Common.View +open Expecto +open FSharp.ViewEngine +open type Html + +[] +let tests = + testList "Document" [ + test "includes consent banner and delayed google analytics loading" { + let doc = Document.primary(div { "Hello" }, "G-TEST123", "nav-home") + + let html = Render.toHtmlDocString doc + + Expect.stringContains html "Andy Meier" "Expected page to render" + Expect.stringContains html "selectedNav: 'nav-home'" "Expected nav signal to render" + Expect.stringContains html "cookie-consent-banner" "Expected consent banner" + Expect.stringContains html "Reject" "Expected reject action" + Expect.stringContains html "Accept" "Expected accept action" + Expect.stringContains html "gtag('consent','default',{analytics_storage:'denied'" "Expected denied-by-default consent mode" + Expect.stringContains html "localStorage.setItem('analytics-consent',v)" "Expected consent to be persisted" + Expect.stringContains html "https://www.googletagmanager.com/gtag/js?id=G-TEST123" "Expected deferred gtag script source" + Expect.stringContains html "gtag('config','G-TEST123');" "Expected GA config call after consent" + } + ] diff --git a/pulumi/src/config.ts b/pulumi/src/config.ts index a4cf795..8cb38c2 100644 --- a/pulumi/src/config.ts +++ b/pulumi/src/config.ts @@ -38,3 +38,9 @@ export const notionConfig = { articlesDatabaseId: rawNotionConfig.require('articlesDatabaseId'), apiKey: rawNotionConfig.requireSecret('apiKey') } + +const rawGoogleAnalyticsConfig = new pulumi.Config('googleAnalytics') + +export const googleAnalyticsConfig = { + measurementId: rawGoogleAnalyticsConfig.require('measurementId') +} diff --git a/pulumi/src/k8s/deployment.ts b/pulumi/src/k8s/deployment.ts index c6105cf..e7b7dfa 100644 --- a/pulumi/src/k8s/deployment.ts +++ b/pulumi/src/k8s/deployment.ts @@ -17,6 +17,7 @@ let appSecret = new k8s.core.v1.Secret('app', { SQLITE_PATH: '/data/app.db', NOTION_ARTICLES_DATABASE_ID: config.notionConfig.articlesDatabaseId, NOTION_API_KEY: config.notionConfig.apiKey, + GOOGLE_ANALYTICS_MEASUREMENT_ID: config.googleAnalyticsConfig.measurementId } }, { provider })