diff --git a/src/App.tsx b/src/App.tsx index 0e339141..107546c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,7 @@ import { openNoteInNewWindow } from './utils/openNoteWindow' import { isNoteWindow, getNoteWindowParams } from './utils/windowMode' import { GitRequiredModal } from './components/GitRequiredModal' import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner' +import { trackEvent } from './lib/telemetry' import './App.css' // Type declarations for mock content storage and test overrides @@ -118,6 +119,14 @@ function App() { useVaultConfig(resolvedPath) const { settings, loaded: settingsLoaded, saveSettings } = useSettings() useTelemetry(settings, settingsLoaded) + + const vaultOpenedRef = useRef('') + useEffect(() => { + if (vault.entries.length > 0 && gitRepoState !== 'checking' && resolvedPath !== vaultOpenedRef.current) { + vaultOpenedRef.current = resolvedPath + trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length }) + } + }, [vault.entries.length, gitRepoState, resolvedPath]) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) const autoSync = useAutoSync({ @@ -331,6 +340,7 @@ function App() { const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml' const target = isTauri() ? invoke : mockInvoke await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition }) + trackEvent('view_created') await vault.reloadViews() setToastMessage(`View "${definition.name}" created`) handleSetSelection({ kind: 'view', filename }) diff --git a/src/components/RawEditorView.tsx b/src/components/RawEditorView.tsx index ceef2368..8f451f98 100644 --- a/src/components/RawEditorView.tsx +++ b/src/components/RawEditorView.tsx @@ -1,4 +1,5 @@ import { useRef, useState, useCallback, useEffect, useMemo } from 'react' +import { trackEvent } from '../lib/telemetry' import type { EditorView } from '@codemirror/view' import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment' @@ -136,6 +137,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, changes: { from: 0, to: doc.length, insert: newText }, selection: { anchor: newCursor }, }) + trackEvent('wikilink_inserted') setAutocomplete(null) if (debounceRef.current) clearTimeout(debounceRef.current) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 6b10f31d..5cdc7f1e 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react' import { GitHubDeviceFlow } from './GitHubDeviceFlow' import type { Settings } from '../types' +import { trackEvent } from '../lib/telemetry' interface SettingsPanelProps { open: boolean @@ -152,6 +153,10 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit { + const prevAnalytics = settings.analytics_enabled ?? false + const newAnalytics = analytics + if (!prevAnalytics && newAnalytics) trackEvent('telemetry_opted_in') + if (prevAnalytics && !newAnalytics) trackEvent('telemetry_opted_out') onSave(buildSettings()) onClose() } diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 4fe39db7..07f67df8 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -1,4 +1,5 @@ import { useEffect, useCallback, useMemo, useRef } from 'react' +import { trackEvent } from '../lib/telemetry' import { useCreateBlockNote, SuggestionMenuController } from '@blocknote/react' import { BlockNoteView } from '@blocknote/mantine' import { useEditorTheme } from '../hooks/useTheme' @@ -87,6 +88,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange { type: 'wikilink' as const, props: { target } }, " ", ]) + trackEvent('wikilink_inserted') }, [editor]) const getWikilinkItems = useCallback(async (query: string): Promise => { diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index 94d1f212..1ff0eb41 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import type { ViewMode } from './useViewMode' +import { trackEvent } from '../lib/telemetry' interface KeyboardActions { onQuickOpen: () => void @@ -104,6 +105,7 @@ export function useAppKeyboard({ // Cmd+Shift+F: full-text search (distinct from Cmd+F browser find) if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') { e.preventDefault() + trackEvent('search_used') onSearch() return } diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts index ca4abb5e..43a9e775 100644 --- a/src/hooks/useAutoSync.ts +++ b/src/hooks/useAutoSync.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types' +import { trackEvent } from '../lib/telemetry' const DEFAULT_INTERVAL_MS = 5 * 60_000 @@ -219,5 +220,10 @@ export function useAutoSync({ const pausePull = useCallback(() => { pauseRef.current = true }, []) const resumePull = useCallback(() => { pauseRef.current = false }, []) - return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync: performPull, pullAndPush, pausePull, resumePull, handlePushRejected } + const triggerSync = useCallback(() => { + trackEvent('sync_triggered') + performPull() + }, [performPull]) + + return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected } } diff --git a/src/hooks/useCommitFlow.ts b/src/hooks/useCommitFlow.ts index b11a09d3..3ccfa6e2 100644 --- a/src/hooks/useCommitFlow.ts +++ b/src/hooks/useCommitFlow.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react' import type { GitPushResult } from '../types' +import { trackEvent } from '../lib/telemetry' interface CommitFlowConfig { savePending: () => Promise @@ -25,6 +26,7 @@ export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, s await savePending() const result = await commitAndPush(message) if (result.status === 'ok') { + trackEvent('commit_made') setToastMessage('Committed and pushed') } else if (result.status === 'rejected') { setToastMessage('Committed, but push rejected — remote has new commits. Pull first.') diff --git a/src/hooks/useDeleteActions.ts b/src/hooks/useDeleteActions.ts index 679216d5..c2aefb2a 100644 --- a/src/hooks/useDeleteActions.ts +++ b/src/hooks/useDeleteActions.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' +import { trackEvent } from '../lib/telemetry' interface ConfirmDeleteState { title: string @@ -50,7 +51,10 @@ export function useDeleteActions({ onConfirm: async () => { setConfirmDelete(null) const ok = await deleteNoteFromDisk(path) - if (ok) setToastMessage('Note permanently deleted') + if (ok) { + trackEvent('note_deleted') + setToastMessage('Note permanently deleted') + } }, }) }, [deleteNoteFromDisk, setToastMessage]) diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index 172de314..4adbbdab 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import type { VaultEntry } from '../types' import type { FrontmatterOpOptions } from './frontmatterOps' +import { trackEvent } from '../lib/telemetry' interface EntryActionsConfig { entries: VaultEntry[] @@ -32,6 +33,7 @@ export function useEntryActions({ // Optimistic: update UI immediately, write to disk async with rollback on failure const trashedAt = Date.now() / 1000 updateEntry(path, { trashed: true, trashedAt }) + trackEvent('note_trashed') setToastMessage('Note moved to trash') const now = new Date().toISOString().slice(0, 10) try { @@ -64,6 +66,7 @@ export function useEntryActions({ await onBeforeAction?.(path) // Optimistic: update UI immediately, write to disk async with rollback on failure updateEntry(path, { archived: true }) + trackEvent('note_archived') setToastMessage('Note archived') try { await handleUpdateFrontmatter(path, '_archived', true, { silent: true }) @@ -129,6 +132,7 @@ export function useEntryActions({ const entry = entries.find((e) => e.path === path) if (!entry) return if (entry.favorite) { + trackEvent('note_unfavorited') updateEntry(path, { favorite: false, favoriteIndex: null }) try { await handleDeleteProperty(path, '_favorite', { silent: true }) @@ -139,6 +143,7 @@ export function useEntryActions({ setToastMessage('Failed to unfavorite — rolled back') } } else { + trackEvent('note_favorited') const maxIndex = entries.filter((e) => e.favorite).reduce((max, e) => Math.max(max, e.favoriteIndex ?? 0), 0) const newIndex = maxIndex + 1 updateEntry(path, { favorite: true, favoriteIndex: newIndex }) diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index afb2e9c5..18ebf3b5 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core' import { isTauri, addMockEntry } from '../mock-tauri' import type { VaultEntry } from '../types' import { resolveEntry } from '../utils/wikilink' +import { trackEvent } from '../lib/telemetry' export interface NewEntryParams { path: string @@ -250,6 +251,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab const handleCreateNote = useCallback((title: string, type: string) => { const template = resolveTemplate(entries, type) persistNew(resolveNewNote(title, type, config.vaultPath, template)) + trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' }) }, [entries, persistNew, config.vaultPath]) const handleCreateNoteImmediate = useCallback((type?: string) => { @@ -257,6 +259,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current, openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending, }, type) + trackEvent('note_created', { has_type: type ? 1 : 0, creation_path: type ? 'type_section' : 'cmd_n' }) }, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) const handleCreateNoteForRelationship = useCallback((title: string): Promise => { @@ -269,7 +272,10 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath]) - const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName, config.vaultPath)), [persistNew, config.vaultPath]) + const handleCreateType = useCallback((typeName: string) => { + persistNew(resolveNewType(typeName, config.vaultPath)) + trackEvent('type_created') + }, [persistNew, config.vaultPath]) /** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */ const createTypeEntrySilent = useCallback(async (typeName: string): Promise => { diff --git a/src/hooks/useRawMode.ts b/src/hooks/useRawMode.ts index 2d07d723..e3655ec3 100644 --- a/src/hooks/useRawMode.ts +++ b/src/hooks/useRawMode.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect } from 'react' import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore' +import { trackEvent } from '../lib/telemetry' interface UseRawModeParams { activeTabPath: string | null @@ -32,6 +33,7 @@ export function useRawMode({ activeTabPath, onFlushPending, onBeforeRawEnd }: Us const rawMode = rawEnabled && activeTabPath !== null const handleToggleRaw = useCallback(async () => { + trackEvent('raw_mode_toggled') if (rawEnabled) { onBeforeRawEnd?.() setRawEnabled(false) diff --git a/src/hooks/useVaultSwitcher.ts b/src/hooks/useVaultSwitcher.ts index b1bb8833..4e5126cf 100644 --- a/src/hooks/useVaultSwitcher.ts +++ b/src/hooks/useVaultSwitcher.ts @@ -4,6 +4,7 @@ import { isTauri, mockInvoke } from '../mock-tauri' import { pickFolder } from '../utils/vault-dialog' import { loadVaultList, saveVaultList } from '../utils/vaultListStore' import type { VaultOption } from '../components/StatusBar' +import { trackEvent } from '../lib/telemetry' export type { PersistedVaultList } from '../utils/vaultListStore' @@ -91,6 +92,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) }, []) const switchVault = useCallback((path: string) => { + trackEvent('vault_switched') setVaultPath(path) onSwitchRef.current() }, []) diff --git a/src/lib/telemetry.test.ts b/src/lib/telemetry.test.ts index b6b271ba..5c4c9b78 100644 --- a/src/lib/telemetry.test.ts +++ b/src/lib/telemetry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { _scrubPathsForTest as scrubPaths } from './telemetry' +import { _scrubPathsForTest as scrubPaths, trackEvent } from './telemetry' describe('telemetry scrubPaths', () => { it('redacts macOS absolute paths', () => { @@ -29,3 +29,17 @@ describe('telemetry scrubPaths', () => { expect(scrubPaths(input)).toBe('Failed copying to ') }) }) + +describe('trackEvent', () => { + it('does not throw when PostHog is not initialized', () => { + expect(() => trackEvent('test_event', { count: 1 })).not.toThrow() + }) + + it('accepts event name with no properties', () => { + expect(() => trackEvent('note_created')).not.toThrow() + }) + + it('accepts event name with string and number properties', () => { + expect(() => trackEvent('note_created', { has_type: 1, creation_path: 'cmd_n' })).not.toThrow() + }) +})