diff --git a/openspec/changes/blog-umami-fix/.openspec.yaml b/openspec/changes/blog-umami-fix/.openspec.yaml new file mode 100644 index 0000000..70eb9e0 --- /dev/null +++ b/openspec/changes/blog-umami-fix/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-10 diff --git a/openspec/changes/blog-umami-fix/design.md b/openspec/changes/blog-umami-fix/design.md new file mode 100644 index 0000000..d4d6bde --- /dev/null +++ b/openspec/changes/blog-umami-fix/design.md @@ -0,0 +1,44 @@ +## Context + +The site uses Umami for analytics. Most site clickables are instrumented using Umami’s data-attribute method (`data-umami-event` and optional `data-umami-event-*` properties) so events are recorded automatically on click. + +The Blog section was added recently and its clickables (post cards, category nav, page links) are not consistently emitting Umami events. This creates a measurement blind spot for the `/blog` surface. + +## Goals / Non-Goals + +**Goals:** +- Ensure all blog clickables emit Umami events using the documented data-attribute method. +- Ensure every tracked clickable has a deterministic, unique `target_id` and includes at minimum `placement` and `target_url` per taxonomy. +- Keep event names within Umami limits (<= 50 chars) and avoid sending event data without an event name. +- Add tests to prevent regressions (blog pages/components should contain required Umami attributes). + +**Non-Goals:** +- Introducing custom JavaScript tracking (`window.umami.track`) for v1; we will use Umami’s data-attribute method. +- Adding new analytics providers or changing Umami initialization. +- Tracking PII or user-generated content in event properties. + +## Decisions + +- **Decision: Use Umami-native data attributes on every blog clickable.** + - Rationale: Aligns with Umami’s “Track events” docs and the rest of the site’s tracking approach; avoids adding JS listeners that can interfere with other handlers. + +- **Decision: Use consistent event names by clickable type.** + - Rationale: Keeps reporting clean while still allowing segmentation via event properties. + - Proposed: + - `click` for internal navigation links (including blog category navigation) + - `outbound_click` for external links (if any in blog chrome) + +- **Decision: Add a deterministic `target_id` namespace for blog elements.** + - Rationale: Blog has many repeated elements; we need unique IDs that remain stable across builds. + - Proposed conventions: + - Blog header link: `nav.blog` + - Blog secondary nav: `blog.subnav.all`, `blog.subnav.pages`, `blog.subnav.category.` + - Blog post card: `blog.card.post.` (placement `blog.index` or `blog.category.`) + - Blog post detail back link: `blog.post.back` + - Blog page list links: `blog.pages.link.` + +## Risks / Trade-offs + +- [Risk] Some blog content areas render raw HTML from WordPress; links inside content are not instrumented. -> Mitigation: Track the blog chrome (cards/nav/back links) first; consider JS-based delegated tracking for content-body links in a future change if needed. +- [Risk] Over-instrumentation adds noisy events. -> Mitigation: Keep event names simple, rely on `target_id` + `placement` for segmentation, and avoid tracking non-clickable elements. + diff --git a/openspec/changes/blog-umami-fix/proposal.md b/openspec/changes/blog-umami-fix/proposal.md new file mode 100644 index 0000000..524aba5 --- /dev/null +++ b/openspec/changes/blog-umami-fix/proposal.md @@ -0,0 +1,26 @@ +## Why + +The Blog section’s click tracking is not firing reliably in Umami, which prevents measuring what users do in `/blog` and where they go next. + +## What Changes + +- Update the Blog section UI so every clickable element uses Umami’s data-attribute event tracking format: + - `data-umami-event=""` + - `data-umami-event-*` attributes for event data +- Ensure every tracked clickable item has a unique, deterministic set of event data elements (especially `target_id`, `placement`, `target_url`) so clicks can be measured independently. +- Add verification/tests to ensure Blog clickables are instrumented and follow the same taxonomy as the rest of the site. + +## Capabilities + +### New Capabilities +- (none) + +### Modified Capabilities +- `blog-section-surface`: instrument blog clickables (post cards, post/page links, category secondary nav, blog header link) using Umami `data-umami-event` attributes. +- `interaction-tracking-taxonomy`: extend/clarify tracking rules to cover blog-specific UI elements and namespaces for `target_id`. +- `analytics-umami`: ensure the implementation adheres to Umami’s Track Events specification for data attributes. + +## Impact + +- Affected UI/components: blog pages and components under `site/src/pages/blog/` and `site/src/components/` (cards and secondary nav), plus any shared navigation link to `/blog`. +- Testing: add/update tests to assert required Umami data attributes exist and are unique per clickable element in blog surfaces. diff --git a/openspec/changes/blog-umami-fix/specs/analytics-umami/spec.md b/openspec/changes/blog-umami-fix/specs/analytics-umami/spec.md new file mode 100644 index 0000000..893be02 --- /dev/null +++ b/openspec/changes/blog-umami-fix/specs/analytics-umami/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Custom event tracking +When Umami is enabled, the site MUST support custom event emission for: +- `cta_click` +- `outbound_click` +- a general click interaction event for all instrumented clickable items (per the site tracking taxonomy) + +Each emitted event MUST include enough properties to segment reports by platform and placement when applicable. + +All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`. + +The site MUST instrument tracked clickables using Umami’s supported Track Events data-attribute method: +- `data-umami-event=""` +- optional event data using `data-umami-event-*` + +#### Scenario: Emit outbound click event +- **WHEN** a user clicks a non-CTA outbound link from the homepage +- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain + +#### Scenario: Emit general click event for any clickable +- **WHEN** a user clicks an instrumented navigation link +- **THEN** the system emits a click interaction event with `target_id` and `placement` + +#### Scenario: Uninstrumented clicks do not break the page +- **WHEN** a user clicks an element with no tracking metadata +- **THEN** the system does not throw and navigation/interaction proceeds normally + diff --git a/openspec/changes/blog-umami-fix/specs/blog-section-surface/spec.md b/openspec/changes/blog-umami-fix/specs/blog-section-surface/spec.md new file mode 100644 index 0000000..60f9b16 --- /dev/null +++ b/openspec/changes/blog-umami-fix/specs/blog-section-surface/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Blog index listing (posts) +The site MUST provide a blog index page at `/blog` that lists WordPress posts as cards containing: +- featured image (when available) +- title +- excerpt/summary + +The listing MUST be ordered by publish date descending (newest first). + +Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum: +- `data-umami-event` +- `data-umami-event-target_id` +- `data-umami-event-placement` +- `data-umami-event-target_url` + +#### Scenario: Blog index lists posts +- **WHEN** the cached WordPress dataset contains posts +- **THEN** `/blog` renders a list of post cards ordered by publish date descending + +#### Scenario: Blog post card click is tracked +- **WHEN** a user clicks a blog post card on `/blog` +- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url` + +### Requirement: Category-based secondary navigation +The blog section MUST render a secondary navigation under the header derived from the cached WordPress categories. + +Selecting a category MUST navigate to a category listing page showing only posts in that category. + +Each secondary navigation link MUST be instrumented with Umami Track Events data attributes and MUST include at minimum: +- `data-umami-event` +- `data-umami-event-target_id` +- `data-umami-event-placement` +- `data-umami-event-target_url` + +#### Scenario: Category nav present +- **WHEN** the cached WordPress dataset contains categories +- **THEN** the blog section shows a secondary navigation with those categories + +#### Scenario: Category listing filters posts +- **WHEN** a user navigates to a category listing page +- **THEN** only posts assigned to that category are listed + +#### Scenario: Category nav click is tracked +- **WHEN** a user clicks a category link in the blog secondary navigation +- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url` + diff --git a/openspec/changes/blog-umami-fix/specs/interaction-tracking-taxonomy/spec.md b/openspec/changes/blog-umami-fix/specs/interaction-tracking-taxonomy/spec.md new file mode 100644 index 0000000..fcfdb95 --- /dev/null +++ b/openspec/changes/blog-umami-fix/specs/interaction-tracking-taxonomy/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: Unique identifier for every clickable item +Every clickable item that is tracked MUST have a stable identifier (`target_id`) that is unique across the site (or unique within a documented namespace). + +The identifier MUST be deterministic across builds for the same element and placement. + +The taxonomy MUST define namespaces for repeated UI surfaces. For the blog surface, the following namespaces MUST be used: +- `blog.subnav.*` for secondary navigation links +- `blog.card.post.` for blog post cards +- `blog.pages.link.` for blog page listing links +- `blog.post.*` / `blog.page.*` for detail page chrome links (e.g., back links) + +#### Scenario: Two links in different placements +- **WHEN** two links point to the same destination but appear in different placements +- **THEN** their `target_id` values are different so their clicks can be measured independently + diff --git a/openspec/changes/blog-umami-fix/tasks.md b/openspec/changes/blog-umami-fix/tasks.md new file mode 100644 index 0000000..3fbba69 --- /dev/null +++ b/openspec/changes/blog-umami-fix/tasks.md @@ -0,0 +1,15 @@ +## 1. Audit Blog Clickables + +- [x] 1.1 Inventory blog clickables (`site/src/pages/blog/**`, `site/src/components/Blog*`) that should emit Umami events (post cards, category subnav, pages list links, detail chrome links) +- [x] 1.2 Confirm each clickable has the required Umami attributes and a deterministic unique `target_id` per taxonomy + +## 2. Implement Umami Attributes + +- [x] 2.1 Instrument blog secondary navigation links with `data-umami-event` and required event data (`target_id`, `placement`, `target_url`) +- [x] 2.2 Instrument blog post cards and any inline links in listing UIs with `data-umami-event` and required event data +- [x] 2.3 Instrument blog detail page chrome links (e.g., Back) and pages listing links with required Umami attributes + +## 3. Verify + +- [x] 3.1 Add/update tests to assert blog components/pages contain Umami `data-umami-event` attributes (and key properties like `target_id`, `placement`, `target_url`) +- [x] 3.2 Build the site and confirm `/blog` and a blog detail page render with instrumented clickables diff --git a/site/src/components/BlogPostCard.astro b/site/src/components/BlogPostCard.astro index 761b818..0c618c8 100644 --- a/site/src/components/BlogPostCard.astro +++ b/site/src/components/BlogPostCard.astro @@ -3,9 +3,11 @@ import type { WordpressPost } from "../lib/content/types"; type Props = { post: WordpressPost; + placement: string; + targetId: string; }; -const { post } = Astro.props; +const { post, placement, targetId } = Astro.props; function truncate(s: string, n: number) { if (!s) return ""; @@ -15,11 +17,17 @@ function truncate(s: string, n: number) { } --- - + {post.featuredImageUrl ? : null}

{post.title}

{truncate(post.excerpt || "", 180)}

- diff --git a/site/src/components/BlogSecondaryNav.astro b/site/src/components/BlogSecondaryNav.astro index 6021e93..f57ba8e 100644 --- a/site/src/components/BlogSecondaryNav.astro +++ b/site/src/components/BlogSecondaryNav.astro @@ -10,16 +10,36 @@ const { categories, activeCategorySlug } = Astro.props; --- - diff --git a/site/src/pages/blog/category/[slug].astro b/site/src/pages/blog/category/[slug].astro index defcf2f..719ea0a 100644 --- a/site/src/pages/blog/category/[slug].astro +++ b/site/src/pages/blog/category/[slug].astro @@ -44,7 +44,11 @@ if (!activeCategory) { {posts.length > 0 ? (
{posts.map((p) => ( - + ))}
) : ( diff --git a/site/src/pages/blog/index.astro b/site/src/pages/blog/index.astro index ac125cf..e04be35 100644 --- a/site/src/pages/blog/index.astro +++ b/site/src/pages/blog/index.astro @@ -25,7 +25,7 @@ const pages = wordpressPages(cache); {posts.length > 0 ? (
{posts.map((p) => ( - + ))}
) : ( @@ -37,18 +37,32 @@ const pages = wordpressPages(cache);
{pages.slice(0, 6).map((p) => ( ))}
) : null} - diff --git a/site/src/pages/blog/page/[slug].astro b/site/src/pages/blog/page/[slug].astro index 88de082..8745226 100644 --- a/site/src/pages/blog/page/[slug].astro +++ b/site/src/pages/blog/page/[slug].astro @@ -33,7 +33,16 @@ const metaDescription = (page.excerpt || "").slice(0, 160);

{page.title}

- Back → + + Back → +
{page.featuredImageUrl ? (
- diff --git a/site/src/pages/blog/pages.astro b/site/src/pages/blog/pages.astro index 5a174ef..cc8751c 100644 --- a/site/src/pages/blog/pages.astro +++ b/site/src/pages/blog/pages.astro @@ -25,7 +25,15 @@ const pages = wordpressPages(cache);
{pages.map((p) => ( ))}
@@ -34,4 +42,3 @@ const pages = wordpressPages(cache); )} - diff --git a/site/src/pages/blog/post/[slug].astro b/site/src/pages/blog/post/[slug].astro index 2b1b5fd..0cc83d5 100644 --- a/site/src/pages/blog/post/[slug].astro +++ b/site/src/pages/blog/post/[slug].astro @@ -33,7 +33,16 @@ const metaDescription = (post.excerpt || "").slice(0, 160);

{post.title}

- Back → + + Back → +

{new Date(post.publishedAt).toLocaleDateString()} @@ -49,4 +58,3 @@ const metaDescription = (post.excerpt || "").slice(0, 160);

- diff --git a/site/tests/blog-umami-attributes.test.ts b/site/tests/blog-umami-attributes.test.ts new file mode 100644 index 0000000..5ed0d63 --- /dev/null +++ b/site/tests/blog-umami-attributes.test.ts @@ -0,0 +1,64 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +async function read(rel: string) { + return await readFile(path.join(process.cwd(), rel), "utf8"); +} + +describe("blog umami event attributes", () => { + it("instruments blog secondary nav links", async () => { + const src = await read("src/components/BlogSecondaryNav.astro"); + + expect(src).toContain('data-umami-event="click"'); + expect(src).toContain('data-umami-event-target_id="blog.subnav.all"'); + expect(src).toContain('data-umami-event-target_id="blog.subnav.pages"'); + expect(src).toContain("data-umami-event-target_id={`blog.subnav.category.${c.slug}`}"); + expect(src).toContain('data-umami-event-placement="blog.subnav"'); + expect(src).toContain("data-umami-event-target_url"); + }); + + it("instruments blog post cards with deterministic target_id and placement", async () => { + const src = await read("src/components/BlogPostCard.astro"); + + expect(src).toContain('data-umami-event="click"'); + expect(src).toContain("data-umami-event-target_id={targetId}"); + expect(src).toContain("data-umami-event-placement={placement}"); + expect(src).toContain("data-umami-event-target_url"); + }); + + it("instruments blog pages list links (and keeps distinct IDs per placement)", async () => { + const indexSrc = await read("src/pages/blog/index.astro"); + expect(indexSrc).toContain('data-umami-event-target_id="blog.index.pages.browse"'); + expect(indexSrc).toContain("data-umami-event-target_id={`blog.index.pages.link.${p.slug}`}"); + expect(indexSrc).toContain('data-umami-event-placement="blog.index.pages_preview"'); + + const pagesSrc = await read("src/pages/blog/pages.astro"); + expect(pagesSrc).toContain("data-umami-event-target_id={`blog.pages.link.${p.slug}`}"); + expect(pagesSrc).toContain('data-umami-event-placement="blog.pages.list"'); + }); + + it("instruments blog detail back links", async () => { + const postSrc = await read("src/pages/blog/post/[slug].astro"); + expect(postSrc).toContain('data-umami-event-target_id="blog.post.back"'); + expect(postSrc).toContain('data-umami-event-placement="blog.post"'); + expect(postSrc).toContain('data-umami-event-target_url="/blog"'); + + const pageSrc = await read("src/pages/blog/page/[slug].astro"); + expect(pageSrc).toContain('data-umami-event-target_id="blog.page.back"'); + expect(pageSrc).toContain('data-umami-event-placement="blog.page"'); + expect(pageSrc).toContain('data-umami-event-target_url="/blog"'); + }); + + it("uses placement-specific target_id for post cards", async () => { + const indexSrc = await read("src/pages/blog/index.astro"); + expect(indexSrc).toContain("targetId={`blog.index.card.post.${p.slug}`}"); + expect(indexSrc).toContain('placement="blog.index"'); + + const categorySrc = await read("src/pages/blog/category/[slug].astro"); + expect(categorySrc).toContain("placement={`blog.category.${activeCategory.slug}`}"); + expect(categorySrc).toContain("targetId={`blog.category.${activeCategory.slug}.card.post.${p.slug}`}"); + }); +}); +