diff --git a/.claude/skills/feature/SKILL.md b/.claude/skills/feature/SKILL.md index 28f9fbb5c..08aa03b32 100644 --- a/.claude/skills/feature/SKILL.md +++ b/.claude/skills/feature/SKILL.md @@ -24,7 +24,7 @@ For each user-facing flow this feature creates or modifies, identify: ### 3. Check completeness -- Every backend API endpoint has a corresponding UI +- Will every backend API endpoint have a corresponding UI? - Every UI action has visible feedback (success + error) - Feature works without mailer / without organizations enabled diff --git a/src/lib/helpers/analytics.js b/src/lib/helpers/analytics.js new file mode 100644 index 000000000..9b21a2906 --- /dev/null +++ b/src/lib/helpers/analytics.js @@ -0,0 +1,41 @@ +/** + * Module dependencies. + */ +import posthog from 'posthog-js'; + +/** + * @desc Check whether PostHog has been initialized and is ready to capture. + * @returns {boolean} True when the PostHog SDK instance is loaded. + */ +function isPosthogReady() { + return !!(posthog && posthog.__loaded); +} + +/** + * @desc Capture an analytics event, guarded by PostHog readiness. + * @param {string} event - The event name (e.g. 'signup_completed'). + * @param {Object} [properties={}] - Optional properties to attach. + * @returns {void} + */ +function capture(event, properties = {}) { + if (!isPosthogReady()) return; + posthog.capture(event, properties); +} + +/** + * @desc Capture a `$pageview` event for the given route. + * @param {import('vue-router').RouteLocationNormalized} to - Target route. + * @returns {void} + */ +function capturePageview(to) { + capture('$pageview', { + $current_url: to.fullPath, + title: to.meta?.title || to.name, + }); +} + +/** + * Exports. + */ +export { isPosthogReady, capture, capturePageview }; +export default { isPosthogReady, capture, capturePageview }; diff --git a/src/lib/helpers/tests/analytics.unit.tests.js b/src/lib/helpers/tests/analytics.unit.tests.js new file mode 100644 index 000000000..9b37cd1ad --- /dev/null +++ b/src/lib/helpers/tests/analytics.unit.tests.js @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockCapture = vi.fn(); + +vi.mock('posthog-js', () => ({ + default: { + __loaded: false, + capture: (...args) => mockCapture(...args), + }, +})); + +import posthog from 'posthog-js'; +import { isPosthogReady, capture, capturePageview } from '../analytics'; + +describe('analytics helper', () => { + beforeEach(() => { + vi.clearAllMocks(); + posthog.__loaded = false; + }); + + describe('isPosthogReady', () => { + it('should return false when posthog is not loaded', () => { + expect(isPosthogReady()).toBe(false); + }); + + it('should return true when posthog is loaded', () => { + posthog.__loaded = true; + expect(isPosthogReady()).toBe(true); + }); + }); + + describe('capture', () => { + it('should not call posthog.capture when not loaded', () => { + capture('test_event', { key: 'value' }); + expect(mockCapture).not.toHaveBeenCalled(); + }); + + it('should call posthog.capture when loaded', () => { + posthog.__loaded = true; + capture('test_event', { key: 'value' }); + expect(mockCapture).toHaveBeenCalledWith('test_event', { key: 'value' }); + }); + + it('should pass empty properties by default', () => { + posthog.__loaded = true; + capture('test_event'); + expect(mockCapture).toHaveBeenCalledWith('test_event', {}); + }); + }); + + describe('capturePageview', () => { + it('should not capture when posthog is not loaded', () => { + capturePageview({ fullPath: '/test', meta: { title: 'Test' }, name: 'test' }); + expect(mockCapture).not.toHaveBeenCalled(); + }); + + it('should capture $pageview with route title from meta', () => { + posthog.__loaded = true; + capturePageview({ fullPath: '/dashboard', meta: { title: 'Dashboard' }, name: 'dashboard' }); + expect(mockCapture).toHaveBeenCalledWith('$pageview', { + $current_url: '/dashboard', + title: 'Dashboard', + }); + }); + + it('should fall back to route name when meta.title is absent', () => { + posthog.__loaded = true; + capturePageview({ fullPath: '/tasks', meta: {}, name: 'tasks' }); + expect(mockCapture).toHaveBeenCalledWith('$pageview', { + $current_url: '/tasks', + title: 'tasks', + }); + }); + }); +}); diff --git a/src/modules/app/app.router.js b/src/modules/app/app.router.js index 5a9b65156..05f7cb3ec 100644 --- a/src/modules/app/app.router.js +++ b/src/modules/app/app.router.js @@ -4,6 +4,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import { useAuthStore } from '../auth/stores/auth.store'; import { ability } from '../../lib/helpers/ability'; +import { capturePageview } from '../../lib/helpers/analytics'; import config from '../../lib/services/config'; import home from '../home/router/home.router'; @@ -119,6 +120,12 @@ const getRouter = () => { return '/signin'; } }); + + // Automatic page-view tracking + router.afterEach((to) => { + capturePageview(to); + }); + return router; }; diff --git a/src/modules/app/tests/app.router.unit.tests.js b/src/modules/app/tests/app.router.unit.tests.js index b57622fea..263af9bcb 100644 --- a/src/modules/app/tests/app.router.unit.tests.js +++ b/src/modules/app/tests/app.router.unit.tests.js @@ -23,6 +23,11 @@ vi.mock('../../../lib/helpers/ability.js', () => ({ ability: mockAbility, })); +const mockCapturePageview = vi.fn(); +vi.mock('../../../lib/helpers/analytics', () => ({ + capturePageview: (...args) => mockCapturePageview(...args), +})); + const mockBillingStore = { subscription: null, @@ -50,6 +55,7 @@ describe('app.router', () => { mockAbility.can.mockReturnValue(false); mockBillingStore.subscription = null; mockBillingStore.fetchSubscription.mockReset().mockResolvedValue(); + mockCapturePageview.mockReset(); // Re-apply mocks after resetModules vi.doMock('../../auth/stores/auth.store', () => ({ @@ -64,6 +70,9 @@ describe('app.router', () => { vi.doMock('../../billing/stores/billing.store', () => ({ useBillingStore: () => mockBillingStore, })); + vi.doMock('../../../lib/helpers/analytics', () => ({ + capturePageview: (...args) => mockCapturePageview(...args), + })); const module = await import('../app.router.js'); getRouter = module.default; @@ -206,6 +215,26 @@ describe('app.router', () => { expect(router.currentRoute.value.path).toBe('/billing'); }); + describe('pageview tracking', () => { + it('should call capturePageview after navigation', async () => { + const router = getRouter(); + await router.push('/'); + await router.isReady(); + expect(mockCapturePageview).toHaveBeenCalled(); + const call = mockCapturePageview.mock.calls[0][0]; + expect(call.fullPath).toBe('/'); + }); + + it('should call capturePageview with route meta', async () => { + const router = getRouter(); + await router.push('/does-not-exist'); + await router.isReady(); + const calls = mockCapturePageview.mock.calls; + const lastCall = calls[calls.length - 1][0]; + expect(lastCall.meta.title).toBe('Page Not Found'); + }); + }); + describe('requiredPlan guard', () => { it('redirects to /pricing when plan is insufficient', async () => { mockAuthStore.isLoggedIn = true; diff --git a/src/modules/auth/stores/auth.store.js b/src/modules/auth/stores/auth.store.js index 359479200..c3ee17721 100644 --- a/src/modules/auth/stores/auth.store.js +++ b/src/modules/auth/stores/auth.store.js @@ -7,6 +7,7 @@ import axios from '../../../lib/services/axios'; import config from '../../../lib/services/config'; import { useCoreStore } from '../../core/stores/core.store'; import { updateAbilities } from '../../../lib/helpers/ability'; +import { capture } from '../../../lib/helpers/analytics'; /** * @desc Deduce firstName and lastName from an email address. @@ -179,6 +180,7 @@ export const useAuthStore = defineStore('auth', { this.user = res.data.user; coreStore.refreshNav(this.isLoggedIn); + capture('signup_completed', { email: res.data.user.email }); return res.data; } catch (err) { localStorage.removeItem('token'); diff --git a/src/modules/auth/tests/auth.store.unit.tests.js b/src/modules/auth/tests/auth.store.unit.tests.js index 1dc2c445d..abe1fa921 100644 --- a/src/modules/auth/tests/auth.store.unit.tests.js +++ b/src/modules/auth/tests/auth.store.unit.tests.js @@ -37,17 +37,24 @@ vi.mock('../../../lib/helpers/ability', () => ({ updateAbilities: (...args) => mockUpdateAbilities(...args), })); +// Mock analytics helper +const mockCapture = vi.fn(); +vi.mock('../../../lib/helpers/analytics', () => ({ + capture: (...args) => mockCapture(...args), +})); + describe('Auth Store', () => { beforeEach(() => { setActivePinia(createPinia()); localStorage.clear(); - // Reset ability mock + // Reset mocks mockUpdateAbilities.mockClear(); // Reset posthog mocks posthog.__loaded = false; posthog.identify.mockClear(); posthog.reset.mockClear(); posthog.group.mockClear(); + mockCapture.mockClear(); }); it('should initialize with default state', () => { @@ -231,6 +238,30 @@ describe('Auth Store', () => { expect(localStorage.getItem(`${config.cookie.prefix}UserRoles`)).toBe('user'); }); + it('should capture signup_completed event on success', async () => { + const authStore = useAuthStore(); + const mockResponse = { + data: { + user: { id: '456', email: 'new@test.com', roles: ['user'] }, + tokenExpiresIn: Date.now() + 3600000, + }, + }; + + axios.post.mockResolvedValueOnce(mockResponse); + await authStore.signup({ email: 'new@test.com', password: 'password123' }); + + expect(mockCapture).toHaveBeenCalledWith('signup_completed', { email: 'new@test.com' }); + }); + + it('should not capture signup_completed on failure', async () => { + const authStore = useAuthStore(); + + axios.post.mockRejectedValueOnce(new Error('Signup failed')); + await expect(authStore.signup({ email: 'new@test.com', password: 'password' })).rejects.toThrow('Signup failed'); + + expect(mockCapture).not.toHaveBeenCalled(); + }); + it('should handle signup error', async () => { const authStore = useAuthStore(); diff --git a/src/modules/billing/stores/billing.store.js b/src/modules/billing/stores/billing.store.js index 49723fe44..68ed8bed5 100644 --- a/src/modules/billing/stores/billing.store.js +++ b/src/modules/billing/stores/billing.store.js @@ -4,6 +4,7 @@ import { defineStore } from 'pinia'; import axios from '../../../lib/services/axios'; import config from '../../../lib/services/config'; +import { capture } from '../../../lib/helpers/analytics'; /** * @desc Build the base API URL from config. @@ -94,6 +95,7 @@ export const useBillingStore = defineStore('billing', { successUrl: `${window.location.origin}/billing?success=true`, cancelUrl: `${window.location.origin}/pricing?canceled=true`, }); + capture('plan_upgraded', { price_id: priceId }); return res.data.data; } catch (err) { console.error(err); diff --git a/src/modules/billing/tests/billing.store.unit.tests.js b/src/modules/billing/tests/billing.store.unit.tests.js index 53f42764a..b5cec5238 100644 --- a/src/modules/billing/tests/billing.store.unit.tests.js +++ b/src/modules/billing/tests/billing.store.unit.tests.js @@ -11,10 +11,17 @@ vi.mock('../../../lib/services/axios', () => ({ }, })); +// Mock analytics helper +const mockCapture = vi.fn(); +vi.mock('../../../lib/helpers/analytics', () => ({ + capture: (...args) => mockCapture(...args), +})); + describe('Billing Store', () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); + mockCapture.mockClear(); }); it('should initialize with default state', () => { @@ -105,6 +112,7 @@ describe('Billing Store', () => { const result = await store.createCheckout('price_123'); expect(result).toEqual(mockCheckout); expect(store.loading).toBe(false); + expect(mockCapture).toHaveBeenCalledWith('plan_upgraded', { price_id: 'price_123' }); }); it('should send correct payload with priceId and redirect URLs', async () => { diff --git a/src/modules/organizations/stores/organizations.store.js b/src/modules/organizations/stores/organizations.store.js index 632199296..1faee608f 100644 --- a/src/modules/organizations/stores/organizations.store.js +++ b/src/modules/organizations/stores/organizations.store.js @@ -7,6 +7,7 @@ import axios from '../../../lib/services/axios'; import config from '../../../lib/services/config'; import { useAuthStore } from '../../auth/stores/auth.store'; import { updateAbilities } from '../../../lib/helpers/ability'; +import { capture } from '../../../lib/helpers/analytics'; /** * @desc Build the base API URL from config. @@ -61,6 +62,7 @@ export const useOrganizationsStore = defineStore('organizations', { const created = res.data.data; this.currentOrganization = created; this.organizations = [created, ...this.organizations]; + capture('org_created', { organization_id: created._id || created.id, name: created.name }); return created; }, @@ -296,6 +298,7 @@ export const useOrganizationsStore = defineStore('organizations', { async inviteMember(organizationId, email) { const api = apiBase(); const res = await axios.post(`${api}/organizations/${organizationId}/invites`, { email }); + capture('invitation_sent', { organization_id: organizationId, email }); return res.data.data; }, diff --git a/src/modules/organizations/tests/organizations.store.unit.tests.js b/src/modules/organizations/tests/organizations.store.unit.tests.js index 178713952..30aaf4e69 100644 --- a/src/modules/organizations/tests/organizations.store.unit.tests.js +++ b/src/modules/organizations/tests/organizations.store.unit.tests.js @@ -44,12 +44,19 @@ vi.mock('../../../lib/helpers/ability', () => ({ updateAbilities: vi.fn(), })); +// Mock analytics helper +const mockCapture = vi.fn(); +vi.mock('../../../lib/helpers/analytics', () => ({ + capture: (...args) => mockCapture(...args), +})); + const API = 'http://localhost:3000/api'; describe('Organizations Store', () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); + mockCapture.mockClear(); mockAuthStore.user = null; mockAuthStore.cookieExpire = null; posthog.__loaded = false; @@ -110,6 +117,7 @@ describe('Organizations Store', () => { expect(store.currentOrganization).toEqual(newOrg); expect(store.organizations).toContainEqual(newOrg); expect(result).toEqual(newOrg); + expect(mockCapture).toHaveBeenCalledWith('org_created', { organization_id: '1', name: 'New Org' }); }); }); @@ -570,6 +578,7 @@ describe('Organizations Store', () => { expect(axios.post).toHaveBeenCalledWith(`${API}/organizations/org1/invites`, { email: 'test@example.com' }); expect(result).toEqual(mockInvite); + expect(mockCapture).toHaveBeenCalledWith('invitation_sent', { organization_id: 'org1', email: 'test@example.com' }); }); });