diff --git a/packages/shared/src/analytics/consent.ts b/packages/shared/src/analytics/consent.ts new file mode 100644 index 00000000..82c58dda --- /dev/null +++ b/packages/shared/src/analytics/consent.ts @@ -0,0 +1,14 @@ +import type { ConsentState } from "./types.js"; + +export function shouldShowConsent(consent: ConsentState, serverEnabled: boolean): boolean { + if (!serverEnabled) return false; + if (consent.analyticsEnabled !== null) return false; + if (consent.analyticsConsentShownAt === null) return true; + if (consent.analyticsConsentRemindAt === null) return false; + return Date.now() >= consent.analyticsConsentRemindAt; +} + +export function isConsentEnabled(consent: ConsentState, serverEnabled: boolean): boolean { + if (!serverEnabled) return false; + return consent.analyticsEnabled === true; +} diff --git a/packages/shared/src/analytics/events.ts b/packages/shared/src/analytics/events.ts new file mode 100644 index 00000000..0dac225e --- /dev/null +++ b/packages/shared/src/analytics/events.ts @@ -0,0 +1,38 @@ +export const ANALYTICS_EVENTS = { + TOOL_USED: "tool_used", + SEARCH: "search", + PIPELINE_EXECUTED: "pipeline_executed", + AI_BUNDLE_ACTION: "ai_bundle_action", +} as const; + +export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS]; + +export interface ToolUsedProperties { + tool_id: string; + status: "completed" | "failed"; + duration_ms: number; + category: string; + is_ai_tool: boolean; + params?: Record; +} + +export interface SearchProperties { + query: string; + results_count: number; + clicked_tool_id?: string; +} + +export interface PipelineExecutedProperties { + step_count: number; + tool_ids: string[]; + is_batch: boolean; + file_count?: number; + duration_ms: number; + status: "completed" | "failed"; +} + +export interface AiBundleActionProperties { + bundle_id: string; + action: "installed" | "uninstalled"; + duration_ms: number; +} diff --git a/packages/shared/src/analytics/types.ts b/packages/shared/src/analytics/types.ts new file mode 100644 index 00000000..fcd4fec0 --- /dev/null +++ b/packages/shared/src/analytics/types.ts @@ -0,0 +1,14 @@ +export interface AnalyticsConfig { + enabled: boolean; + posthogApiKey: string; + posthogHost: string; + sentryDsn: string; + sampleRate: number; + instanceId: string; +} + +export interface ConsentState { + analyticsEnabled: boolean | null; + analyticsConsentShownAt: number | null; + analyticsConsentRemindAt: number | null; +} diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index b4966619..d5db962f 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -301,6 +301,33 @@ export const en = { help: "Help", settings: "Settings", }, + analytics: { + consentTitle: "Make ashim better for you", + consentDescription: + 'Anonymous product analytics — like "crop tool used" or "an error occurred" — help us fix bugs faster and build the tools you actually want.', + consentPrivacy: "Your images never leave your machine. Ever.", + whatShared: "What's shared:", + whatSharedItems: [ + "Which tools you use", + "Error reports (no file data)", + "App version and performance", + ], + whatNever: "What NEVER leaves your machine:", + whatNeverItems: [ + "Your images, PDFs, and files", + "File names and contents", + "Any personal information", + ], + consentProviders: "Data is sent to PostHog (analytics) and Sentry (errors) — both open-source.", + consentChangeable: "You can change this anytime in Settings.", + acceptButton: "Sure, sounds good", + declineButton: "Maybe later", + settingsTitle: "Product Analytics", + settingsDescription: "Share anonymous usage data to help improve ashim.", + settingsPrivacy: "Your images never leave your machine.", + settingsDisabledByAdmin: "Product analytics has been disabled by the server administrator.", + learnMore: "Learn more", + }, } as const; export type TranslationKeys = typeof en; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 848984c6..5e99a350 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,6 @@ +export * from "./analytics/consent.js"; +export * from "./analytics/events.js"; +export * from "./analytics/types.js"; export * from "./constants.js"; export * from "./features.js"; export * from "./i18n/index.js"; diff --git a/tests/unit/analytics-consent.test.ts b/tests/unit/analytics-consent.test.ts new file mode 100644 index 00000000..4a18c146 --- /dev/null +++ b/tests/unit/analytics-consent.test.ts @@ -0,0 +1,123 @@ +import { isConsentEnabled, shouldShowConsent } from "@ashim/shared"; +import { describe, expect, it } from "vitest"; + +describe("shouldShowConsent", () => { + it("returns false when server has analytics disabled", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }, + false, + ), + ).toBe(false); + }); + + it("returns true for a fresh user who has never been asked", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(true); + }); + + it("returns false when user already opted in", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(false); + }); + + it("returns false when user explicitly declined", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: false, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(false); + }); + + it("returns false when remind-at is in the future", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: null, + analyticsConsentShownAt: Date.now() - 86400000, + analyticsConsentRemindAt: Date.now() + 86400000, + }, + true, + ), + ).toBe(false); + }); + + it("returns true when remind-at has passed", () => { + expect( + shouldShowConsent( + { + analyticsEnabled: null, + analyticsConsentShownAt: Date.now() - 86400000 * 8, + analyticsConsentRemindAt: Date.now() - 86400000, + }, + true, + ), + ).toBe(true); + }); +}); + +describe("isConsentEnabled", () => { + it("returns false when server disabled", () => { + expect( + isConsentEnabled( + { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }, + false, + ), + ).toBe(false); + }); + + it("returns false when user has not consented", () => { + expect( + isConsentEnabled( + { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(false); + }); + + it("returns true when user opted in and server enabled", () => { + expect( + isConsentEnabled( + { + analyticsEnabled: true, + analyticsConsentShownAt: Date.now(), + analyticsConsentRemindAt: null, + }, + true, + ), + ).toBe(true); + }); +});