Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions .claude/skills/event-tracking/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
---
name: event-tracking
description: >
Add event tracking calls to Vue/Nuxt components in the Insights app using the useTrackEvent composable.
Use this skill whenever a developer asks to "track" a user action or page view, "add analytics", "instrument" a
component, "fire an event", implement an event from the events catalog, or wire up tracking for anything in
the Insights frontend — even if they just say "track when the user clicks X" or "add tracking to this page".
---

# Event Tracking — Insights App

## What this skill does

Helps you add the right `trackEvent()` call to the right place in the codebase, using the catalog-approved event definitions.

## File structure

```
frontend/app/components/shared/types/events/
├── index.ts # EventType, EventFeature enums; EventDefinition interface;
│ # EventKey union type; aggregated EVENT_DEFINITIONS record
└── collections.ts # CollectionsEventKey enum + COLLECTIONS_EVENT_DEFINITIONS
<feature>.ts # (future) FeatureEventKey enum + FEATURE_EVENT_DEFINITIONS

frontend/composables/useTrackEvent.ts # just the tracking function
```

**Adding events for a new feature:** create `events/<feature>.ts`, define its `<Feature>EventKey` enum and `<FEATURE>_EVENT_DEFINITIONS` record, then re-export both from `index.ts`.

**`index.ts` shape:**
```ts
export type EventKey = CollectionsEventKey // | FutureFeatureEventKey | ...
export const EVENT_DEFINITIONS: Record<EventKey, EventDefinition> = {
...COLLECTIONS_EVENT_DEFINITIONS,
// ...FUTURE_FEATURE_EVENT_DEFINITIONS,
}
```

## How to use

Always use the **feature-specific key enum** (e.g. `CollectionsEventKey`), not the union `EventKey` type. The composable itself only needs `useTrackEvent` — the key enum comes from the feature's events file.

```ts
import { useTrackEvent } from '~~/composables/useTrackEvent';
import { CollectionsEventKey } from '~/components/shared/types/events/collections';

const { trackEvent } = useTrackEvent()

trackEvent({
key: CollectionsEventKey.CREATE_COLLECTION,
properties: { collectionId, isPrivate }, // catalog-defined fields only — optional
})
```

`name`, `type`, `description`, and `feature` are looked up automatically from `EVENT_DEFINITIONS` — **never pass them in the call**.

`source` (current URL) and `entrySource` (referrer) are captured **automatically** — never pass them manually.

The call is always fire-and-forget: errors are caught inside the composable and never bubble up.

## Step-by-step workflow

### 1. Read the events catalog

Always start by reading `references/events-catalog.md` to find the event that matches what the developer described. The catalog is organized by feature section (e.g. "Community Collections"). Note the `enum value` and `properties` columns — use those exactly.

If no catalog entry matches, note this to the developer and suggest the closest match or a new entry following the conventions at the bottom of the catalog.

### 2. Identify the target file and feature

Infer the feature from context (e.g. "track when the user creates a collection" → Community Collections). This tells you which key enum to import:

| Feature | Key enum | Import path |
|---------|----------|-------------|
| Community Collections | `CollectionsEventKey` | `~/components/shared/types/events/collections` |
| (future features) | `<Feature>EventKey` | `~/components/shared/types/events/<feature>` |

Read the target component or page file before making any changes. You need to understand:
- Whether `useTrackEvent` is already imported
- For **feature** events: which function handles the user action
- For **page** events: whether `onMounted` already exists

### 3. Insert the tracking call

#### Feature events

Place `trackEvent(...)` inside the action handler, **after** the main logic succeeds.

```ts
const handleCreateCollection = async () => {
const result = await createCollection(...)

trackEvent({
key: CollectionsEventKey.CREATE_COLLECTION,
properties: { collectionId: result.id, isPrivate: form.isPrivate },
})
}
```

For abandonment events, fire when the user dismisses or navigates away without completing the flow — typically on modal close before the success state is reached.

#### Page events

Place inside `onMounted()`. Add to an existing `onMounted` if one already exists.

```ts
onMounted(() => {
trackEvent({ key: CollectionsEventKey.VIEW_DISCOVER_COLLECTIONS })
})
```

#### Events that need async data (e.g. `viewerType`)

If the event requires data that loads asynchronously (e.g. collection details needed to determine `viewerType`), use `watch` with `{ once: true }` instead of `onMounted`:

```ts
watch(data, (collection) => {
if (!collection) return
trackEvent({
key: CollectionsEventKey.VIEW_COLLECTION,
properties: {
collectionId: collection.id,
viewerType: collection.ssoUserId && user.value?.sub === collection.ssoUserId ? 'owner' : 'guest',
},
})
}, { once: true })
```

### 4. Add the imports (if missing)

```ts
import { useTrackEvent } from '~~/composables/useTrackEvent';
import { CollectionsEventKey } from '~/components/shared/types/events/collections';
```

Then destructure at the top level of `<script setup>`:

```ts
const { trackEvent } = useTrackEvent()
```

The `~~` alias points to `frontend/`, `~` points to `frontend/app/`. Neither is auto-imported.

### 5. Verify

- No TypeScript errors introduced
- The tracking call does not change the control flow of existing logic
- Properties only include fields listed in the catalog for that event

## Common patterns

### Properties with runtime values

```ts
// Good
properties: { collectionId: collection.id, isPrivate: collection.isPrivate }

// Bad
properties: { collectionId: 'collectionId', isPrivate: 'isPrivate' }
```

### Tracking after async operations

Place after the `await` so it only fires on success:

```ts
await updateCollection(payload)
trackEvent({ key: CollectionsEventKey.UPDATE_COLLECTION, properties: { collectionId, changedFields } })
```

### Multiple events in one handler

Track each separately in the right order.

## Adding events for a new feature

1. Create `frontend/app/components/shared/types/events/<feature>.ts`:
```ts
import { EventFeature, EventType, type EventDefinition } from '.';

export enum MyFeatureEventKey {
DO_THING = 'do-thing',
}

export const MY_FEATURE_EVENT_DEFINITIONS: Record<MyFeatureEventKey, EventDefinition> = {
[MyFeatureEventKey.DO_THING]: {
key: MyFeatureEventKey.DO_THING,
type: EventType.FEATURE,
name: 'Do thing',
feature: EventFeature.MY_FEATURE,
},
};
```

2. In `index.ts`, extend the union and spread into `EVENT_DEFINITIONS`:
```ts
export type EventKey = CollectionsEventKey | MyFeatureEventKey
export const EVENT_DEFINITIONS = {
...COLLECTIONS_EVENT_DEFINITIONS,
...MY_FEATURE_EVENT_DEFINITIONS,
}
```

3. Add `EventFeature.MY_FEATURE` to the `EventFeature` enum in `index.ts`.

4. Add the new events to `references/events-catalog.md` under a new section.
46 changes: 46 additions & 0 deletions .claude/skills/event-tracking/references/events-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Insights Events Catalog

All approved events for the Insights application. Use the exact `enum value` when calling `trackEvent`. Only pass `properties` fields listed below — no extras.

---

## Community Collections

**Key enum:** `CollectionsEventKey` from `~/components/shared/types/events/collections`

| enum value | key | type | name | properties |
|------------|-----|------|------|------------|
| `CREATE_COLLECTION` | `create-collection` | `feature` | `Create collection` | `newCollectionId`, `isPrivate` |
| `UPDATE_COLLECTION` | `update-collection` | `feature` | `Update collection` | `collectionId`, `changedFields` (e.g. `['name', 'privacy']`) |
| `DELETE_COLLECTION` | `delete-collection` | `feature` | `Delete collection` | `collectionId` |
| `SHARE_COLLECTION` | `share-collection` | `feature` | `Share collection` | `collectionId`, `shareMethod` |
| `VIEW_COLLECTION` | `view-collection` | `feature` | `View collection` | `collectionId` |
| `LIKE_COLLECTION` | `like-collection` | `feature` | `Like collection` | `collectionId` |
| `DISLIKE_COLLECTION` | `dislike-collection` | `feature` | `Dislike collection` | `collectionId` |
| `VIEW_DISCOVER_COLLECTIONS` | `view-discover-collections` | `page` | `View Discover Collections` | — |
| `VIEW_CURATED_COLLECTIONS` | `view-curated-collections` | `page` | `View Curated Collections` | — |
| `VIEW_COMMUNITY_COLLECTIONS` | `view-community-collections` | `page` | `View Community Collections` | — |
| `VIEW_MY_COLLECTIONS` | `view-my-collections` | `page` | `View My Collections` | — |
| `DUPLICATE_COLLECTION` | `duplicate-collection` | `feature` | `Duplicate collections` | `sourceCollectionId`, `newCollectionId` |
| `ADD_PROJECT_TO_COLLECTION` | `add-project-to-collection` | `feature` | `Add project to collection` | `collectionId`, `projectId` |
| `ABANDONED_COLLECTION_CREATION` | `abandoned-collection-creation` | `feature` | `Abandoned collection creation` | — |
| `ABANDONED_COLLECTION_DUPLICATION` | `abandoned-collection-duplication` | `feature` | `Abandoned collection duplication` | — |
| `ABANDONED_COLLECTION_EDITION` | `abandoned-collection-edition` | `feature` | `Abandoned collection edition` | `sourceCollectionId` |

---

## Adding new events

When implementing an event not yet in this catalog, inform the developer so the catalog can be updated.

**Naming conventions:**
- `key`: kebab-case, verb-noun format (e.g. `create-collection`)
- `type`: `'page'` for page views, `'feature'` for user interactions
- `feature`: the product area name (e.g. `'Community Collections'`)
- enum value: SCREAMING_SNAKE_CASE matching the key (e.g. `CREATE_COLLECTION`)

**New feature checklist:**
1. Create `events/<feature>.ts` with the key enum and definitions
2. Re-export from `events/index.ts` (extend `EventKey` union + spread definitions)
3. Add `EventFeature.<FEATURE>` to the enum in `index.ts`
4. Add a new section to this catalog
24 changes: 24 additions & 0 deletions database/migrations/V1775900000__createEventsTable.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Events table for tracking user interactions throughout the application
CREATE TABLE IF NOT EXISTS public.events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
user_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
properties JSONB,
feature TEXT,
source TEXT,
entry_source TEXT
);

-- Indexes for common query patterns
CREATE INDEX idx_events_key ON public.events(key);

CREATE INDEX idx_events_type ON public.events(type);

CREATE INDEX idx_events_user_id ON public.events(user_id);

CREATE INDEX idx_events_feature ON public.events(feature);

CREATE INDEX idx_events_created_at ON public.events(created_at DESC);
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ import { computed, ref, watch } from 'vue';
import type { AddToCollectionProject } from '~/components/modules/collection/store/add-to-collection.store';
import { COLLECTIONS_API_SERVICE } from '~/components/modules/collection/services/collections.api.service';
import { useAuth } from '~~/composables/useAuth';
import { useTrackEvent } from '~~/composables/useTrackEvent';
import { CollectionsEventKey } from '~/components/shared/types/events/collections';
import useToastService from '~/components/uikit/toast/toast.service';
import { ToastTypesEnum } from '~/components/uikit/toast/types/toast.types';
import LfxModal from '~/components/uikit/modal/modal.vue';
Expand Down Expand Up @@ -162,6 +164,7 @@ const isModalOpen = computed({

const { user } = useAuth();
const { showToast } = useToastService();
const { trackEvent } = useTrackEvent();

const selectedCollectionId = ref('');
const isAdding = ref(false);
Expand Down Expand Up @@ -231,6 +234,13 @@ const addToCollection = async () => {
projects: newProjectIds,
});

trackEvent({
key: CollectionsEventKey.ADD_PROJECT_TO_COLLECTION,
properties: {
collectionId: collection.id,
projectId: props.project.id,
},
});
showToast(`${props.project.name} added to ${collection.name}`, ToastTypesEnum.positive);
emit('added');
closeModal();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import { COLLECTIONS_API_SERVICE } from '~/components/modules/collection/service
import useToastService from '~/components/uikit/toast/toast.service';
import { ToastTypesEnum } from '~/components/uikit/toast/types/toast.types';
import type { Collection } from '~~/types/collection';
import { useTrackEvent } from '~~/composables/useTrackEvent';
import { CollectionsEventKey } from '~/components/shared/types/events/collections';

const props = withDefaults(
defineProps<{
Expand All @@ -84,12 +86,14 @@ const isModalOpen = computed({
});

const { showToast } = useToastService();
const { trackEvent } = useTrackEvent();

const step = ref(0);
const form = ref<CreateCollectionForm>({ ...createCollectionTemplate, projects: [] });
const stepRef = ref<{ $v?: { $invalid: boolean; $touch: () => void } } | null>(null);
const isCreating = ref(false);
const isLoadingProjects = ref(false);
const completedSuccessfully = ref(false);

const steps = computed<CreateCollectionStep[]>(() => createCollectionSteps);

Expand Down Expand Up @@ -188,7 +192,27 @@ const createCollection = async () => {
isCreating.value = true;

try {
await COLLECTIONS_API_SERVICE.createCollection(payload);
const result = await COLLECTIONS_API_SERVICE.createCollection(payload);

completedSuccessfully.value = true;

if (isDuplicateMode.value) {
trackEvent({
key: CollectionsEventKey.DUPLICATE_COLLECTION,
properties: {
sourceCollectionId: props.sourceCollection?.id,
newCollectionId: result.id,
},
});
} else {
trackEvent({
key: CollectionsEventKey.CREATE_COLLECTION,
properties: {
isPrivate: payload.isPrivate,
newCollectionId: result.id,
},
});
}
Comment thread
joanagmaia marked this conversation as resolved.
showToast('Collection created successfully!', ToastTypesEnum.positive);
emit('created', form.value);
isModalOpen.value = false;
Expand Down Expand Up @@ -220,6 +244,19 @@ watch(isModalOpen, async (value) => {
await initializeFromSourceCollection();
}
} else {
if (isFormDirty.value && !completedSuccessfully.value) {
trackEvent({
key: isDuplicateMode.value
? CollectionsEventKey.ABANDONED_COLLECTION_DUPLICATION
: CollectionsEventKey.ABANDONED_COLLECTION_CREATION,
properties: isDuplicateMode.value
? {
sourceCollectionId: props.sourceCollection?.id,
}
: undefined,
});
}
completedSuccessfully.value = false;
step.value = 0;
form.value = { ...createCollectionTemplate, projects: [] };
}
Expand Down
Loading
Loading