From 3e059a3118e96de279a2da97deecef84792e670f Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 23 Jul 2026 15:05:49 -0300 Subject: [PATCH 01/10] track Annotation workflow and edit-mode actions with PostHog events --- .../AnnotationDetailsPanel.svelte | 2 +- .../SelectedAnnotations.svelte | 13 ++- .../src/lib/components/Header/Header.svelte | 43 +++++++++- .../BrushToolPopUp/BrushToolPopUp.svelte | 23 +++++- .../SampleDetailsAnnotationSegment.svelte | 2 +- .../SampleDetailsToolbar.svelte | 25 +++++- .../SampleObjectDetectionRect.svelte | 13 +++ .../SampleSegmentationMaskRect.svelte | 14 ++++ .../useCreateAnnotation.test.ts | 41 +++++++++- .../useCreateAnnotation.ts | 9 +++ .../useDeleteAnnotation.test.ts | 55 +++++++++++++ .../useDeleteAnnotation.ts | 8 +- .../src/lib/hooks/usePostHog.ts | 1 + .../useUpdateAnnotationsMutation.test.ts | 81 +++++++++++++++++++ .../useUpdateAnnotationsMutation.ts | 15 ++++ 15 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts create mode 100644 lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts diff --git a/lightly_studio_view/src/lib/components/AnnotationDetails/AnnotationDetailsPanel/AnnotationDetailsPanel.svelte b/lightly_studio_view/src/lib/components/AnnotationDetails/AnnotationDetailsPanel/AnnotationDetailsPanel.svelte index 96502ee42a..ffef3a6e5c 100644 --- a/lightly_studio_view/src/lib/components/AnnotationDetails/AnnotationDetailsPanel/AnnotationDetailsPanel.svelte +++ b/lightly_studio_view/src/lib/components/AnnotationDetails/AnnotationDetailsPanel/AnnotationDetailsPanel.svelte @@ -32,7 +32,7 @@ const handleDeleteAnnotation = async () => { try { - await deleteAnnotation(annotation.sample_id); + await deleteAnnotation(annotation.sample_id, annotation.annotation_type); toast.success('Annotation deleted successfully'); diff --git a/lightly_studio_view/src/lib/components/AnnotationsGrid/SelectedAnnotations/SelectedAnnotations.svelte b/lightly_studio_view/src/lib/components/AnnotationsGrid/SelectedAnnotations/SelectedAnnotations.svelte index 1cb93fe1a4..d2c23309d8 100644 --- a/lightly_studio_view/src/lib/components/AnnotationsGrid/SelectedAnnotations/SelectedAnnotations.svelte +++ b/lightly_studio_view/src/lib/components/AnnotationsGrid/SelectedAnnotations/SelectedAnnotations.svelte @@ -6,6 +6,7 @@ import { useAnnotationLabels } from '$lib/hooks/useAnnotationLabels/useAnnotationLabels'; import LabelNotFound from '$lib/components/LabelNotFound/LabelNotFound.svelte'; import { getSelectionItems } from '$lib/components/SelectList/getSelectionItems'; + import { usePostHog } from '$lib/hooks/usePostHog'; type Props = { selectedAnnotations: Array; @@ -17,6 +18,16 @@ const { selectedAnnotations, onSelect, disabled, isLoading, collectionId }: Props = $props(); + const { trackEvent } = usePostHog(); + + const handleSelect = (item: { value: string; label: string }) => { + trackEvent('annotations_bulk_labeled', { + collection_id: collectionId, + annotation_count: selectedAnnotations.length + }); + onSelect(item); + }; + const result = useAnnotationLabels(() => ({ collectionId })); const items = $derived(getSelectionItems(result.data || [])); @@ -37,7 +48,7 @@ name="annotation-label" placeholder="Select or create a class" label="Select a class" - {onSelect} + onSelect={handleSelect} {isLoading} {disabled} > diff --git a/lightly_studio_view/src/lib/components/Header/Header.svelte b/lightly_studio_view/src/lib/components/Header/Header.svelte index aee66a3622..7183fcc3d7 100644 --- a/lightly_studio_view/src/lib/components/Header/Header.svelte +++ b/lightly_studio_view/src/lib/components/Header/Header.svelte @@ -15,6 +15,7 @@ import UserAvatar from '$lib/components/UserAvatar/UserAvatar.svelte'; import useAuth from '$lib/hooks/useAuth/useAuth'; import { hasMinimumRole } from '$lib/hooks/useAuth/hasMinimumRole'; + import { usePostHog } from '$lib/hooks/usePostHog'; let { collection }: { collection: CollectionView } = $props(); @@ -33,6 +34,8 @@ const { setIsEditingMode, isEditingMode, reversibleActions, executeReversibleAction } = page.data.globalStorage; + const { trackEvent } = usePostHog(); + const handleKeyDown = (event: KeyboardEvent) => { if (isInputElement(event.target)) { return; @@ -42,15 +45,37 @@ return; } if (event.key === get(settingsStore).key_toggle_edit_mode) { + if (!$isEditingMode) { + trackEvent('edit_mode_started', { + collection_id: collection.collection_id, + triggered_by: 'keyboard_shortcut' + }); + } else { + trackEvent('edit_mode_finished', { + collection_id: collection.collection_id, + triggered_by: 'keyboard_shortcut' + }); + } setIsEditingMode(!$isEditingMode); } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { - executeUndoAction(); + const action = $reversibleActions[0]; + if (action) { + trackEvent('edit_undo', { + collection_id: collection.collection_id, + triggered_by: 'keyboard_shortcut' + }); + void executeReversibleAction(action.id); + } } }; const executeUndoAction = async () => { const latestAction = $reversibleActions[0]; if (latestAction) { + trackEvent('edit_undo', { + collection_id: collection.collection_id, + triggered_by: 'click' + }); await executeReversibleAction(latestAction.id); } }; @@ -100,7 +125,13 @@ @@ -135,7 +150,7 @@ ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted'} " - onclick={() => setBrushMode('eraser')} + onclick={() => activateBrushMode('eraser', 'click')} > diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsAnnotationSegment/SampleDetailsAnnotationSegment.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsAnnotationSegment/SampleDetailsAnnotationSegment.svelte index 41fe88e631..e0675e3837 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsAnnotationSegment/SampleDetailsAnnotationSegment.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsAnnotationSegment/SampleDetailsAnnotationSegment.svelte @@ -172,7 +172,7 @@ refetch }); - await deleteAnnotation(annotationId); + await deleteAnnotation(annotationId, annotation.annotation_type); toast.success('Annotation deleted successfully'); refetch(); if (annotationLabelContext.annotationId === annotationId) { diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsToolbar/SampleDetailsToolbar.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsToolbar/SampleDetailsToolbar.svelte index 7fdb9cd5da..97835a305e 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsToolbar/SampleDetailsToolbar.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsToolbar/SampleDetailsToolbar.svelte @@ -10,10 +10,13 @@ import CursorToolbarButton from '../CursorToolbarButton/CursorToolbarButton.svelte'; import DragToolbarButton from '../DragToolbarButton/DragToolbarButton.svelte'; import { useSettings } from '$lib/hooks/useSettings'; + import { usePostHog } from '$lib/hooks/usePostHog'; + import { page } from '$app/state'; const { showSegmentationTool = true }: { showSegmentationTool?: boolean } = $props(); const { settingsStore } = useSettings(); + const { trackEvent } = usePostHog(); let isSpacePressed = false; const onKeyDown = (e: KeyboardEvent) => { @@ -32,11 +35,11 @@ onClickCursor(); } else if (key === $settingsStore.key_toolbar_bounding_box) { e.preventDefault(); - onClickBoundingBox(); + activateBoundingBox('keyboard_shortcut'); } else if (key === $settingsStore.key_toolbar_segmentation_mask) { if (!showSegmentationTool) return; e.preventDefault(); - onClickBrush(); + activateBrush('keyboard_shortcut'); } else if (key === $settingsStore.key_toolbar_drag) { e.preventDefault(); onClickDrag(); @@ -109,15 +112,22 @@ } }); - const onClickBoundingBox = () => { + const activateBoundingBox = (triggeredBy: 'click' | 'keyboard_shortcut') => { if (annotationLabelContext.isOnAnnotationDetailsView) return; + trackEvent('annotation_tool_selected', { + collection_id: page.params.collection_id, + tool: 'bounding-box', + triggered_by: triggeredBy + }); setStatus('bounding-box'); setAnnotationType(AnnotationType.OBJECT_DETECTION); setAnnotationId(null); setLastCreatedAnnotationId(null); }; + const onClickBoundingBox = () => activateBoundingBox('click'); + const onClickCursor = () => { setStatus('cursor'); }; @@ -126,14 +136,21 @@ setStatus('drag'); }; - const onClickBrush = () => { + const activateBrush = (triggeredBy: 'click' | 'keyboard_shortcut') => { if (!showSegmentationTool) return; + trackEvent('annotation_tool_selected', { + collection_id: page.params.collection_id, + tool: 'brush', + triggered_by: triggeredBy + }); setStatus('brush'); setAnnotationType(AnnotationType.SEGMENTATION_MASK); if (!annotationLabelContext.isOnAnnotationDetailsView) setAnnotationId(null); setLastCreatedAnnotationId(null); }; + + const onClickBrush = () => activateBrush('click');
diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleObjectDetectionRect/SampleObjectDetectionRect.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleObjectDetectionRect/SampleObjectDetectionRect.svelte index f58d2b03b7..3350bf76b7 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleObjectDetectionRect/SampleObjectDetectionRect.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleObjectDetectionRect/SampleObjectDetectionRect.svelte @@ -21,6 +21,7 @@ import SelectClassDialog from '$lib/components/SelectClassDialog/SelectClassDialog.svelte'; import { getBoundingBox } from '$lib/components/SampleAnnotation/utils'; import type { PendingChange } from '../pendingChange'; + import { usePostHog } from '$lib/hooks/usePostHog'; type D3Event = D3DragEvent; @@ -52,6 +53,8 @@ let temporaryBbox = $state(null); let shouldDisableInteraction = $state(false); + let drawStartFired = false; + const { trackEvent } = usePostHog(); const labels = useAnnotationLabels(() => ({ collectionId })); const { @@ -84,6 +87,7 @@ const cancelDrag = () => { setIsDrawing(false); temporaryBbox = null; + drawStartFired = false; }; const datasetId = $derived(page.params.dataset_id!); @@ -105,6 +109,14 @@ // Remove focus from any selected annotation. setAnnotationId(null); setIsDrawing(true); + if (!drawStartFired) { + trackEvent('annotation_draw_started', { + collection_id: collectionId, + tool: 'bounding-box', + parent_sample_type: page.params.collection_type + }); + drawStartFired = true; + } // Get mouse position relative to the SVG element const svgRect = interactionRect!.getBoundingClientRect(); const clientX = event.sourceEvent.clientX; @@ -156,6 +168,7 @@ cancelDrag(); startPoint = null; + drawStartFired = false; }); rectSelection.call(dragBehavior); diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleSegmentationMaskRect/SampleSegmentationMaskRect.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleSegmentationMaskRect/SampleSegmentationMaskRect.svelte index 708ab8128f..f232d4b65e 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleSegmentationMaskRect/SampleSegmentationMaskRect.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleSegmentationMaskRect/SampleSegmentationMaskRect.svelte @@ -19,6 +19,7 @@ useDeleteAnnotation } from '$lib/hooks'; import { page } from '$app/state'; + import { usePostHog } from '$lib/hooks/usePostHog'; import type { PendingChange } from '../pendingChange'; import SampleAnnotationRect from '../SampleAnnotationRect/SampleAnnotationRect.svelte'; import SelectClassDialog from '$lib/components/SelectClassDialog/SelectClassDialog.svelte'; @@ -59,6 +60,9 @@ setAnnotationId } = useAnnotationLabelContext(); + const { trackEvent } = usePostHog(); + let drawStartFired = $state(false); + const { deleteAnnotation } = useDeleteAnnotation({ collectionId }); const labels = useAnnotationLabels(() => ({ collectionId })); @@ -213,6 +217,7 @@ const handleStrokeComplete = (e: PointerEvent) => { releasePointerCapture(e); + drawStartFired = false; resetPreviewState({ clearDrawing: false }); const targetAnnotation = resolveSelectedAnnotation(); @@ -252,6 +257,7 @@ const handleStrokeCancel = (e: PointerEvent) => { releasePointerCapture(e); + drawStartFired = false; resetPreviewState(); }; @@ -347,6 +353,14 @@ return; } + if (!drawStartFired) { + trackEvent('annotation_draw_started', { + collection_id: collectionId, + tool: 'brush', + parent_sample_type: page.params.collection_type + }); + drawStartFired = true; + } setIsDrawing(true); lastBrushPoint = point; isPreviewVisible = false; diff --git a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts index dcfdc2b82a..c27e06a595 100644 --- a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts +++ b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts @@ -12,6 +12,15 @@ vi.mock('@tanstack/svelte-query', async (importOriginal) => { return { ...actual, createMutation: vi.fn(), useQueryClient: vi.fn() }; }); +const trackEvent = vi.fn(); +vi.mock('$lib/hooks/usePostHog', () => ({ + usePostHog: () => ({ trackEvent }) +})); + +vi.mock('$app/state', () => ({ + page: { params: { collection_type: 'images' } } +})); + describe('useCreateAnnotation', () => { const invalidateQueries = vi.fn(); @@ -25,7 +34,11 @@ describe('useCreateAnnotation', () => { it('invalidates the annotation counts and the source list after a successful create', async () => { vi.mocked(createMutation).mockReturnValue({ mutate: (_vars: unknown, opts: { onSuccess: (data: unknown) => void }) => { - opts.onSuccess({ sample_id: 'created-annotation' }); + opts.onSuccess({ + sample_id: 'created-annotation', + annotation_type: 'object_detection', + annotation_label: { annotation_label_name: 'car' } + }); } } as unknown as ReturnType); @@ -43,4 +56,30 @@ describe('useCreateAnnotation', () => { queryKey: readAnnotationCollectionsQueryKey({ path: { collection_id: 'col-1' } }) }); }); + + it('fires annotation_created with correct properties on success', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: (data: unknown) => void }) => { + opts.onSuccess({ + sample_id: 'created-annotation', + annotation_type: 'object_detection', + annotation_label: { annotation_label_name: 'car' } + }); + } + } as unknown as ReturnType); + + const { createAnnotation } = useCreateAnnotation({ collectionId: 'col-1' }); + await createAnnotation({ + parent_sample_id: 's1', + annotation_type: 'object_detection', + annotation_label_id: 'l1' + } as AnnotationCreateInput); + + expect(trackEvent).toHaveBeenCalledWith('annotation_created', { + collection_id: 'col-1', + annotation_type: 'object_detection', + parent_sample_type: 'images', + label_name: 'car' + }); + }); }); diff --git a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.ts b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.ts index e82e613fe2..8ff44a4a8c 100644 --- a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.ts +++ b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.ts @@ -8,10 +8,13 @@ import { } from '$lib/api/lightly_studio_local/@tanstack/svelte-query.gen'; import { createMutation, useQueryClient } from '@tanstack/svelte-query'; import { useImageAnnotationCountsQueryKey } from '$lib/hooks/useImageAnnotationCounts/useImageAnnotationCounts'; +import { usePostHog } from '$lib/hooks/usePostHog'; +import { page } from '$app/state'; export const useCreateAnnotation = ({ collectionId }: { collectionId: string }) => { const mutation = createMutation(() => createAnnotationMutation()); const client = useQueryClient(); + const { trackEvent } = usePostHog(); const refetch = () => { client.invalidateQueries({ @@ -36,6 +39,12 @@ export const useCreateAnnotation = ({ collectionId }: { collectionId: string }) { onSuccess: (data) => { refetch(); + trackEvent('annotation_created', { + collection_id: collectionId, + annotation_type: data.annotation_type, + parent_sample_type: page.params.collection_type, + label_name: data.annotation_label.annotation_label_name + }); resolve(data); }, onError: (error) => { diff --git a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts new file mode 100644 index 0000000000..7fc6b853d8 --- /dev/null +++ b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMutation, useQueryClient } from '@tanstack/svelte-query'; +import { useDeleteAnnotation } from './useDeleteAnnotation'; + +vi.mock('@tanstack/svelte-query', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createMutation: vi.fn(), useQueryClient: vi.fn() }; +}); + +const trackEvent = vi.fn(); +vi.mock('$lib/hooks/usePostHog', () => ({ + usePostHog: () => ({ trackEvent }) +})); + +describe('useDeleteAnnotation', () => { + const invalidateQueries = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useQueryClient).mockReturnValue({ + invalidateQueries + } as unknown as ReturnType); + }); + + it('fires annotation_deleted with collection_id on success', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { deleteAnnotation } = useDeleteAnnotation({ collectionId: 'col-1' }); + await deleteAnnotation('ann-1'); + + expect(trackEvent).toHaveBeenCalledWith('annotation_deleted', { + collection_id: 'col-1' + }); + }); + + it('includes annotation_type when provided', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { deleteAnnotation } = useDeleteAnnotation({ collectionId: 'col-1' }); + await deleteAnnotation('ann-1', 'object_detection'); + + expect(trackEvent).toHaveBeenCalledWith('annotation_deleted', { + collection_id: 'col-1', + annotation_type: 'object_detection' + }); + }); +}); diff --git a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts index 4c9fdd357c..3f999a0932 100644 --- a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts +++ b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts @@ -1,11 +1,13 @@ import { deleteAnnotationMutation } from '$lib/api/lightly_studio_local/@tanstack/svelte-query.gen'; import { createMutation, useQueryClient } from '@tanstack/svelte-query'; import { useImageAnnotationCountsQueryKey } from '$lib/hooks/useImageAnnotationCounts/useImageAnnotationCounts'; +import { usePostHog } from '$lib/hooks/usePostHog'; export const useDeleteAnnotation = ({ collectionId }: { collectionId: string }) => { const mutation = createMutation(() => deleteAnnotationMutation()); const client = useQueryClient(); + const { trackEvent } = usePostHog(); const refetch = () => { client.invalidateQueries({ @@ -13,7 +15,7 @@ export const useDeleteAnnotation = ({ collectionId }: { collectionId: string }) }); }; - const deleteAnnotation = (annotationId: string) => + const deleteAnnotation = (annotationId: string, annotationType?: string) => new Promise((resolve, reject) => { mutation.mutate( { @@ -25,6 +27,10 @@ export const useDeleteAnnotation = ({ collectionId }: { collectionId: string }) { onSuccess: () => { refetch(); + trackEvent('annotation_deleted', { + collection_id: collectionId, + ...(annotationType && { annotation_type: annotationType }) + }); resolve(); }, onError: (error) => { diff --git a/lightly_studio_view/src/lib/hooks/usePostHog.ts b/lightly_studio_view/src/lib/hooks/usePostHog.ts index a018ae9f20..dc35abe39a 100644 --- a/lightly_studio_view/src/lib/hooks/usePostHog.ts +++ b/lightly_studio_view/src/lib/hooks/usePostHog.ts @@ -64,6 +64,7 @@ export const usePostHog = () => { */ const trackEvent = (eventName: string, properties?: Record) => { if (!initialized) return; + console.log(eventName, properties) posthog.capture(eventName, properties); }; diff --git a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts new file mode 100644 index 0000000000..26ca4c4fe0 --- /dev/null +++ b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMutation, useQueryClient } from '@tanstack/svelte-query'; +import { useUpdateAnnotationsMutation } from './useUpdateAnnotationsMutation'; + +vi.mock('@tanstack/svelte-query', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createMutation: vi.fn(), useQueryClient: vi.fn() }; +}); + +const trackEvent = vi.fn(); +vi.mock('$lib/hooks/usePostHog', () => ({ + usePostHog: () => ({ trackEvent }) +})); + +describe('useUpdateAnnotationsMutation', () => { + const invalidateQueries = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useQueryClient).mockReturnValue({ + invalidateQueries + } as unknown as ReturnType); + }); + + it('fires annotation_label_updated when a single update with label_name succeeds', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { updateAnnotations } = useUpdateAnnotationsMutation({ collectionId: 'col-1' }); + await updateAnnotations([ + { annotation_id: 'ann-1', collection_id: 'col-1', label_name: 'dog' } + ]); + + expect(trackEvent).toHaveBeenCalledWith('annotation_label_updated', { + collection_id: 'col-1', + label_name: 'dog' + }); + }); + + it('fires annotations_bulk_labeled for multiple updates with label_names', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { updateAnnotations } = useUpdateAnnotationsMutation({ collectionId: 'col-1' }); + await updateAnnotations([ + { annotation_id: 'ann-1', collection_id: 'col-1', label_name: 'dog' }, + { annotation_id: 'ann-2', collection_id: 'col-1', label_name: 'cat' } + ]); + + expect(trackEvent).toHaveBeenCalledWith('annotations_bulk_labeled', { + collection_id: 'col-1', + annotation_count: 2, + label_names: ['dog', 'cat'] + }); + }); + + it('does not fire when no inputs include a label_name', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { updateAnnotations } = useUpdateAnnotationsMutation({ collectionId: 'col-1' }); + await updateAnnotations([ + { + annotation_id: 'ann-1', + collection_id: 'col-1', + bounding_box: { x: 0, y: 0, width: 10, height: 10 } + } + ]); + + expect(trackEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts index cd21a82a39..065b08dd9b 100644 --- a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts +++ b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts @@ -2,11 +2,13 @@ import { type AnnotationUpdateInput } from '$lib/api/lightly_studio_local'; import { updateAnnotationsMutation } from '$lib/api/lightly_studio_local/@tanstack/svelte-query.gen'; import { createMutation, useQueryClient } from '@tanstack/svelte-query'; import { useImageAnnotationCountsQueryKey } from '$lib/hooks/useImageAnnotationCounts/useImageAnnotationCounts'; +import { usePostHog } from '$lib/hooks/usePostHog'; export const useUpdateAnnotationsMutation = ({ collectionId }: { collectionId: string }) => { const mutation = createMutation(() => updateAnnotationsMutation()); const client = useQueryClient(); + const { trackEvent } = usePostHog(); const refetch = () => { client.invalidateQueries({ @@ -26,6 +28,19 @@ export const useUpdateAnnotationsMutation = ({ collectionId }: { collectionId: s { onSuccess: () => { refetch(); + const labelInputs = inputs.filter((input) => input.label_name != null); + if (labelInputs.length === 1) { + trackEvent('annotation_label_updated', { + collection_id: collectionId, + label_name: labelInputs[0].label_name + }); + } else if (labelInputs.length > 1) { + trackEvent('annotations_bulk_labeled', { + collection_id: collectionId, + annotation_count: labelInputs.length, + label_names: labelInputs.map((input) => input.label_name) + }); + } resolve(); }, onError: (error) => { From 000930139560a08b454ccb757bc4f582d1f561c8 Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 23 Jul 2026 15:15:49 -0300 Subject: [PATCH 02/10] emit bulk update --- .../useUpdateAnnotationsMutation.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts index 065b08dd9b..cd2e479eab 100644 --- a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts +++ b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts @@ -29,16 +29,17 @@ export const useUpdateAnnotationsMutation = ({ collectionId }: { collectionId: s onSuccess: () => { refetch(); const labelInputs = inputs.filter((input) => input.label_name != null); - if (labelInputs.length === 1) { + if (inputs.length === 1) { trackEvent('annotation_label_updated', { collection_id: collectionId, - label_name: labelInputs[0].label_name + annotation_id: inputs[0].annotation_id, + label_name: labelInputs[0]?.label_name }); - } else if (labelInputs.length > 1) { + } else if (inputs.length > 1) { trackEvent('annotations_bulk_labeled', { collection_id: collectionId, - annotation_count: labelInputs.length, - label_names: labelInputs.map((input) => input.label_name) + annotation_ids: inputs.map((input) => input.annotation_id), + annotation_count: inputs.length }); } resolve(); From a8edff0f082180c0ba64b0ead9ef0b2cc35f4d3b Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 23 Jul 2026 15:22:55 -0300 Subject: [PATCH 03/10] fix tests --- .../useCreateAnnotation.test.ts | 2 +- .../useUpdateAnnotationsMutation.test.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts index c27e06a595..d8f2d10b70 100644 --- a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts +++ b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts @@ -12,7 +12,7 @@ vi.mock('@tanstack/svelte-query', async (importOriginal) => { return { ...actual, createMutation: vi.fn(), useQueryClient: vi.fn() }; }); -const trackEvent = vi.fn(); +const { trackEvent } = vi.hoisted(() => ({ trackEvent: vi.fn() })); vi.mock('$lib/hooks/usePostHog', () => ({ usePostHog: () => ({ trackEvent }) })); diff --git a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts index 26ca4c4fe0..86e6102c1f 100644 --- a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts +++ b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts @@ -7,7 +7,7 @@ vi.mock('@tanstack/svelte-query', async (importOriginal) => { return { ...actual, createMutation: vi.fn(), useQueryClient: vi.fn() }; }); -const trackEvent = vi.fn(); +const { trackEvent } = vi.hoisted(() => ({ trackEvent: vi.fn() })); vi.mock('$lib/hooks/usePostHog', () => ({ usePostHog: () => ({ trackEvent }) })); @@ -36,6 +36,7 @@ describe('useUpdateAnnotationsMutation', () => { expect(trackEvent).toHaveBeenCalledWith('annotation_label_updated', { collection_id: 'col-1', + annotation_id: 'ann-1', label_name: 'dog' }); }); @@ -55,12 +56,12 @@ describe('useUpdateAnnotationsMutation', () => { expect(trackEvent).toHaveBeenCalledWith('annotations_bulk_labeled', { collection_id: 'col-1', - annotation_count: 2, - label_names: ['dog', 'cat'] + annotation_ids: ['ann-1', 'ann-2'], + annotation_count: 2 }); }); - it('does not fire when no inputs include a label_name', async () => { + it('fires annotation_label_updated with label_name undefined when a single update has no label_name', async () => { vi.mocked(createMutation).mockReturnValue({ mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { opts.onSuccess(); @@ -76,6 +77,10 @@ describe('useUpdateAnnotationsMutation', () => { } ]); - expect(trackEvent).not.toHaveBeenCalled(); + expect(trackEvent).toHaveBeenCalledWith('annotation_label_updated', { + collection_id: 'col-1', + annotation_id: 'ann-1', + label_name: undefined + }); }); }); From 1231d205c5e14b07093f082864c1b2c26cb72d96 Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 23 Jul 2026 15:24:59 -0300 Subject: [PATCH 04/10] fix duplicate event --- .../SelectedAnnotations/SelectedAnnotations.svelte | 13 +------------ lightly_studio_view/src/lib/hooks/usePostHog.ts | 1 - 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/lightly_studio_view/src/lib/components/AnnotationsGrid/SelectedAnnotations/SelectedAnnotations.svelte b/lightly_studio_view/src/lib/components/AnnotationsGrid/SelectedAnnotations/SelectedAnnotations.svelte index d2c23309d8..1cb93fe1a4 100644 --- a/lightly_studio_view/src/lib/components/AnnotationsGrid/SelectedAnnotations/SelectedAnnotations.svelte +++ b/lightly_studio_view/src/lib/components/AnnotationsGrid/SelectedAnnotations/SelectedAnnotations.svelte @@ -6,7 +6,6 @@ import { useAnnotationLabels } from '$lib/hooks/useAnnotationLabels/useAnnotationLabels'; import LabelNotFound from '$lib/components/LabelNotFound/LabelNotFound.svelte'; import { getSelectionItems } from '$lib/components/SelectList/getSelectionItems'; - import { usePostHog } from '$lib/hooks/usePostHog'; type Props = { selectedAnnotations: Array; @@ -18,16 +17,6 @@ const { selectedAnnotations, onSelect, disabled, isLoading, collectionId }: Props = $props(); - const { trackEvent } = usePostHog(); - - const handleSelect = (item: { value: string; label: string }) => { - trackEvent('annotations_bulk_labeled', { - collection_id: collectionId, - annotation_count: selectedAnnotations.length - }); - onSelect(item); - }; - const result = useAnnotationLabels(() => ({ collectionId })); const items = $derived(getSelectionItems(result.data || [])); @@ -48,7 +37,7 @@ name="annotation-label" placeholder="Select or create a class" label="Select a class" - onSelect={handleSelect} + {onSelect} {isLoading} {disabled} > diff --git a/lightly_studio_view/src/lib/hooks/usePostHog.ts b/lightly_studio_view/src/lib/hooks/usePostHog.ts index dc35abe39a..a018ae9f20 100644 --- a/lightly_studio_view/src/lib/hooks/usePostHog.ts +++ b/lightly_studio_view/src/lib/hooks/usePostHog.ts @@ -64,7 +64,6 @@ export const usePostHog = () => { */ const trackEvent = (eventName: string, properties?: Record) => { if (!initialized) return; - console.log(eventName, properties) posthog.capture(eventName, properties); }; From 476ef308e3234a301a0f242f9094d357965c8577 Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 23 Jul 2026 15:34:35 -0300 Subject: [PATCH 05/10] annotation type require --- .../SampleDetailsClassificationSegment.svelte | 2 +- .../SampleEraserRect/SampleEraserRect.svelte | 2 +- .../src/lib/components/VideoDetails/VideoDetails.svelte | 2 +- .../useDeleteAnnotation/useDeleteAnnotation.test.ts | 9 +++++---- .../lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts | 4 ++-- .../src/lib/hooks/useSegmentationMaskBrush.ts | 2 +- .../lib/services/addAnnotationCreateToUndoStack.test.ts | 2 +- .../src/lib/services/addAnnotationCreateToUndoStack.ts | 4 ++-- 8 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsClassificationSegment/SampleDetailsClassificationSegment.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsClassificationSegment/SampleDetailsClassificationSegment.svelte index 94cc039398..bea5b6f50f 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsClassificationSegment/SampleDetailsClassificationSegment.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsClassificationSegment/SampleDetailsClassificationSegment.svelte @@ -107,7 +107,7 @@ refetch }); - await deleteAnnotation(annotationId); + await deleteAnnotation(annotationId, annotation.annotation_type); toast.success('Classification deleted successfully'); refetch(); } catch (error) { diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleEraserRect/SampleEraserRect.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleEraserRect/SampleEraserRect.svelte index 79d0d4460a..391ee6462f 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleEraserRect/SampleEraserRect.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleEraserRect/SampleEraserRect.svelte @@ -187,7 +187,7 @@ refetch }); - await deleteAnnotation(annotation!.sample_id); + await deleteAnnotation(annotation!.sample_id, annotation!.annotation_type); toast.success('Annotation deleted successfully'); if (annotationLabelContext.isOnAnnotationDetailsView) return gotoNextAnnotation(); diff --git a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte index b4608e3138..a438f879e8 100644 --- a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte +++ b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte @@ -122,7 +122,7 @@ async function handleEventDelete(event: VideoEvent) { try { - await deleteAnnotation(event.id); + await deleteAnnotation(event.id, AnnotationType.CLASSIFICATION); onVideoUpdate(); toast.success('Event deleted'); } catch (error) { diff --git a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts index 7fc6b853d8..8c9307576f 100644 --- a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts +++ b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts @@ -22,7 +22,7 @@ describe('useDeleteAnnotation', () => { } as unknown as ReturnType); }); - it('fires annotation_deleted with collection_id on success', async () => { + it('fires annotation_deleted with collection_id and annotation_type on success', async () => { vi.mocked(createMutation).mockReturnValue({ mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { opts.onSuccess(); @@ -30,14 +30,15 @@ describe('useDeleteAnnotation', () => { } as unknown as ReturnType); const { deleteAnnotation } = useDeleteAnnotation({ collectionId: 'col-1' }); - await deleteAnnotation('ann-1'); + await deleteAnnotation('ann-1', 'classification'); expect(trackEvent).toHaveBeenCalledWith('annotation_deleted', { - collection_id: 'col-1' + collection_id: 'col-1', + annotation_type: 'classification' }); }); - it('includes annotation_type when provided', async () => { + it('includes the provided annotation_type in the event', async () => { vi.mocked(createMutation).mockReturnValue({ mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { opts.onSuccess(); diff --git a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts index 3f999a0932..ab2d57aeaa 100644 --- a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts +++ b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts @@ -15,7 +15,7 @@ export const useDeleteAnnotation = ({ collectionId }: { collectionId: string }) }); }; - const deleteAnnotation = (annotationId: string, annotationType?: string) => + const deleteAnnotation = (annotationId: string, annotationType: string) => new Promise((resolve, reject) => { mutation.mutate( { @@ -29,7 +29,7 @@ export const useDeleteAnnotation = ({ collectionId }: { collectionId: string }) refetch(); trackEvent('annotation_deleted', { collection_id: collectionId, - ...(annotationType && { annotation_type: annotationType }) + annotation_type: annotationType }); resolve(); }, diff --git a/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.ts b/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.ts index ecbdd9723e..134bee866d 100644 --- a/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.ts +++ b/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.ts @@ -34,7 +34,7 @@ export function useSegmentationMaskBrush({ refetch: () => void; /** Must be a stable reference (not recreated on re-renders) to ensure undo closures * call the live mutation rather than a disposed one. */ - deleteAnnotation: (annotationId: string) => Promise; + deleteAnnotation: (annotationId: string, annotationType: string) => Promise; onAnnotationCreated?: () => void; /** Called when no label is currently selected. Should show a class-picker and resolve with * the chosen class, or null if the user cancelled. */ diff --git a/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.test.ts b/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.test.ts index e661a9652a..74de76cece 100644 --- a/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.test.ts +++ b/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.test.ts @@ -49,7 +49,7 @@ describe('addAnnotationCreateToUndoStack', () => { const action = addReversibleAction.mock.calls[0][0] as ReversibleAction; await action.execute(); - expect(deleteAnnotation).toHaveBeenCalledWith('annotation-123'); + expect(deleteAnnotation).toHaveBeenCalledWith('annotation-123', 'object_detection'); expect(refetch).toHaveBeenCalled(); }); diff --git a/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.ts b/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.ts index 6a7c6b3343..f835dbfb00 100644 --- a/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.ts +++ b/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.ts @@ -13,7 +13,7 @@ export const addAnnotationCreateToUndoStack = ({ }: { annotation: AnnotationView; addReversibleAction: (action: ReversibleAction) => void; - deleteAnnotation: (annotationId: string) => Promise; + deleteAnnotation: (annotationId: string, annotationType: string) => Promise; refetch: () => void; onUndo?: ReversibleActionCallback; /** Called after deletion succeeds, before refetch. Use to clean up UI state @@ -21,7 +21,7 @@ export const addAnnotationCreateToUndoStack = ({ onDelete?: ReversibleActionCallback; }) => { const execute = async () => { - await deleteAnnotation(annotation.sample_id); + await deleteAnnotation(annotation.sample_id, annotation.annotation_type); await onUndo?.(); await onDelete?.(); refetch(); From 904d80506d20cefcfbffafac7c21760165b6aeb0 Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 23 Jul 2026 15:52:56 -0300 Subject: [PATCH 06/10] fix import factor out undo events --- .../src/lib/components/Header/Header.svelte | 74 +++++++------------ .../BrushToolPopUp/BrushToolPopUp.svelte | 2 +- .../SampleDetailsToolbar.svelte | 2 +- .../SampleObjectDetectionRect.svelte | 2 +- .../SampleSegmentationMaskRect.svelte | 4 +- lightly_studio_view/src/lib/hooks/index.ts | 1 + .../useCreateAnnotation.ts | 2 +- .../useDeleteAnnotation.ts | 2 +- .../hooks/useSegmentationMaskBrush.test.ts | 5 +- .../useUpdateAnnotationsMutation.ts | 2 +- lightly_studio_view/src/routes/+layout.svelte | 2 +- 11 files changed, 40 insertions(+), 58 deletions(-) diff --git a/lightly_studio_view/src/lib/components/Header/Header.svelte b/lightly_studio_view/src/lib/components/Header/Header.svelte index 7183fcc3d7..b63447ba68 100644 --- a/lightly_studio_view/src/lib/components/Header/Header.svelte +++ b/lightly_studio_view/src/lib/components/Header/Header.svelte @@ -15,7 +15,7 @@ import UserAvatar from '$lib/components/UserAvatar/UserAvatar.svelte'; import useAuth from '$lib/hooks/useAuth/useAuth'; import { hasMinimumRole } from '$lib/hooks/useAuth/hasMinimumRole'; - import { usePostHog } from '$lib/hooks/usePostHog'; + import { usePostHog } from '$lib/hooks'; let { collection }: { collection: CollectionView } = $props(); @@ -36,6 +36,27 @@ const { trackEvent } = usePostHog(); + type TriggeredBy = 'click' | 'keyboard_shortcut'; + + const setEditMode = (active: boolean, triggeredBy: TriggeredBy) => { + trackEvent(active ? 'edit_mode_started' : 'edit_mode_finished', { + collection_id: collection.collection_id, + triggered_by: triggeredBy + }); + setIsEditingMode(active); + }; + + const executeUndoAction = async (triggeredBy: TriggeredBy) => { + const latestAction = $reversibleActions[0]; + if (latestAction) { + trackEvent('edit_undo', { + collection_id: collection.collection_id, + triggered_by: triggeredBy + }); + await executeReversibleAction(latestAction.id); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { if (isInputElement(event.target)) { return; @@ -45,38 +66,9 @@ return; } if (event.key === get(settingsStore).key_toggle_edit_mode) { - if (!$isEditingMode) { - trackEvent('edit_mode_started', { - collection_id: collection.collection_id, - triggered_by: 'keyboard_shortcut' - }); - } else { - trackEvent('edit_mode_finished', { - collection_id: collection.collection_id, - triggered_by: 'keyboard_shortcut' - }); - } - setIsEditingMode(!$isEditingMode); + setEditMode(!$isEditingMode, 'keyboard_shortcut'); } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { - const action = $reversibleActions[0]; - if (action) { - trackEvent('edit_undo', { - collection_id: collection.collection_id, - triggered_by: 'keyboard_shortcut' - }); - void executeReversibleAction(action.id); - } - } - }; - - const executeUndoAction = async () => { - const latestAction = $reversibleActions[0]; - if (latestAction) { - trackEvent('edit_undo', { - collection_id: collection.collection_id, - triggered_by: 'click' - }); - await executeReversibleAction(latestAction.id); + void executeUndoAction('keyboard_shortcut'); } }; @@ -111,7 +103,7 @@