Last active
December 12, 2025 12:40
-
-
Save hrdyjan1/09123bcaed70e553e7622944e0633372 to your computer and use it in GitHub Desktop.
Anoto
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| createContext, | |
| useCallback, | |
| useContext, | |
| useEffect, | |
| useMemo, | |
| useRef, | |
| useState, | |
| } from 'react'; | |
| import AsyncStorage from '@react-native-async-storage/async-storage'; | |
| export const TriggerName = { | |
| /* ------------------------------------------------- | |
| * Handwriting / Pen connection | |
| * ------------------------------------------------- */ | |
| HandwritingDataReceived: 'onboarding/handwriting/data-received', | |
| HandwritingStreamConfirmed: 'onboarding/handwriting/stream-confirmed', // "Looks good" tapped | |
| /* ------------------------------------------------- | |
| * Transcript | |
| * ------------------------------------------------- */ | |
| PageDetailsOpened: 'onboarding/transcript/page-details-opened', | |
| TranscriptViewed: 'onboarding/transcript/viewed', | |
| ReturnedToMainScreen: 'onboarding/transcript/returned-to-main', | |
| /* ------------------------------------------------- | |
| * Offline & sync | |
| * ------------------------------------------------- */ | |
| OfflineWritingDetected: 'onboarding/sync/offline-writing-detected', | |
| OfflineSyncCompleted: 'onboarding/sync/offline-sync-completed', | |
| /* ------------------------------------------------- | |
| * Desktop access (tracked via backend / cross-device) | |
| * ------------------------------------------------- */ | |
| NotesAccessedOnDesktop: 'onboarding/desktop/notes-accessed', | |
| /* ------------------------------------------------- | |
| * Sharing | |
| * ------------------------------------------------- */ | |
| NotesSharedToApp: 'onboarding/share/to-app', | |
| NotesSharedToWeb: 'onboarding/share/to-web', | |
| /* ------------------------------------------------- | |
| * Export | |
| * ------------------------------------------------- */ | |
| PdfExported: 'onboarding/export/pdf', | |
| /* ------------------------------------------------- | |
| * Quin | |
| * ------------------------------------------------- */ | |
| QuinOpened: 'onboarding/quin/opened', | |
| QuinMessageSent: 'onboarding/quin/message-sent', | |
| /* ------------------------------------------------- | |
| * Tags | |
| * ------------------------------------------------- */ | |
| TagAdded: 'onboarding/tag/added', | |
| /* ------------------------------------------------- | |
| * Search | |
| * ------------------------------------------------- */ | |
| NotesSearched: 'onboarding/search/performed', | |
| } as const; | |
| type TriggerOption = (typeof TriggerName)[keyof typeof TriggerName]; | |
| type Requirement = | |
| | { | |
| kind: 'trigger'; | |
| option: TriggerOption; | |
| predicate?: (meta?: Record<string, unknown>) => boolean; | |
| } | |
| | { kind: 'allOf'; requirements: Requirement[] } | |
| | { kind: 'anyOf'; requirements: Requirement[] }; | |
| type TaskDefinition = { | |
| id: string; | |
| title: string; | |
| description: string; | |
| requirements: Requirement; | |
| }; | |
| type Achievement = { | |
| id: string; | |
| title: string; | |
| taskIds: string[]; | |
| }; | |
| const TASKS = { | |
| basics_stream_handwriting: { | |
| id: 'basics_stream_handwriting', | |
| title: 'Stream your handwriting', | |
| description: 'Start writing and confirm it works.', | |
| requirements: { | |
| kind: 'allOf', | |
| requirements: [ | |
| { kind: 'trigger', option: TriggerName.HandwritingDataReceived }, | |
| { kind: 'trigger', option: TriggerName.HandwritingStreamConfirmed }, | |
| ], | |
| }, | |
| }, | |
| basics_get_transcript: { | |
| id: 'basics_get_transcript', | |
| title: 'Get a transcript', | |
| description: 'Open page details and view a transcript.', | |
| requirements: { | |
| kind: 'allOf', | |
| requirements: [ | |
| { kind: 'trigger', option: TriggerName.PageDetailsOpened }, | |
| { kind: 'trigger', option: TriggerName.TranscriptViewed }, | |
| { kind: 'trigger', option: TriggerName.ReturnedToMainScreen }, | |
| ], | |
| }, | |
| }, | |
| basics_write_offline_and_sync: { | |
| id: 'basics_write_offline_and_sync', | |
| title: 'Write offline and sync', | |
| description: 'Write while offline and later sync successfully.', | |
| requirements: { kind: 'trigger', option: TriggerName.OfflineSyncCompleted }, | |
| }, | |
| basics_access_notes_on_desktop: { | |
| id: 'basics_access_notes_on_desktop', | |
| title: 'Access your notes on desktop', | |
| description: 'Access notes on desktop (tracked via backend).', | |
| requirements: { | |
| kind: 'trigger', | |
| option: TriggerName.NotesAccessedOnDesktop, | |
| }, | |
| }, | |
| basics_share_notes_to_app: { | |
| id: 'basics_share_notes_to_app', | |
| title: 'Share notes to app', | |
| description: 'Share a note to another app.', | |
| requirements: { kind: 'trigger', option: TriggerName.NotesSharedToApp }, | |
| }, | |
| basics_share_notes_to_web: { | |
| id: 'basics_share_notes_to_web', | |
| title: 'Share notes to web', | |
| description: 'Create a web share link or share to web.', | |
| requirements: { kind: 'trigger', option: TriggerName.NotesSharedToWeb }, | |
| }, | |
| basics_export_pdf: { | |
| id: 'basics_export_pdf', | |
| title: 'Export to PDF', | |
| description: 'Export notes as a PDF.', | |
| requirements: { kind: 'trigger', option: TriggerName.PdfExported }, | |
| }, | |
| basics_chat_with_quin: { | |
| id: 'basics_chat_with_quin', | |
| title: 'Chat with Quin', | |
| description: 'Open Quin and send a message.', | |
| requirements: { | |
| kind: 'allOf', | |
| requirements: [ | |
| { kind: 'trigger', option: TriggerName.QuinOpened }, | |
| { kind: 'trigger', option: TriggerName.QuinMessageSent }, | |
| ], | |
| }, | |
| }, | |
| basics_add_tag: { | |
| id: 'basics_add_tag', | |
| title: 'Add a tag', | |
| description: 'Add a tag to a note.', | |
| requirements: { kind: 'trigger', option: TriggerName.TagAdded }, | |
| }, | |
| basics_search_notes: { | |
| id: 'basics_search_notes', | |
| title: 'Search your notes', | |
| description: 'Search within notes.', | |
| requirements: { kind: 'trigger', option: TriggerName.NotesSearched }, | |
| }, | |
| } satisfies Record<string, TaskDefinition>; | |
| const ACHIEVEMENTS = { | |
| basics: { | |
| id: 'basics', | |
| title: 'Basics', | |
| taskIds: Object.keys(TASKS), | |
| } satisfies Achievement, | |
| }; | |
| type TaskCompletion = { | |
| taskId: string; | |
| completedAt: string; | |
| evidence?: Record<string, unknown>; | |
| }; | |
| type AchievementCompletion = { achievementId: string; completedAt: string }; | |
| type OnboardingState = { | |
| taskCompletions: Record<string, TaskCompletion>; | |
| achievementCompletions: Record<string, AchievementCompletion>; | |
| settings: { promptBoxDismissed: boolean }; | |
| }; | |
| function getDefaultOnboardingState() { | |
| return { | |
| taskCompletions: {}, | |
| achievementCompletions: {}, | |
| settings: { promptBoxDismissed: false }, | |
| }; | |
| } | |
| /* ------------------------------------------------- | |
| * Trigger recording (runtime) | |
| * ------------------------------------------------- */ | |
| type TriggerRecorder = { | |
| record(trigger: Trigger): void; | |
| has(option: TriggerOption): boolean; | |
| reset(): void; | |
| getMeta(option: TriggerOption): Record<string, unknown> | undefined; | |
| }; | |
| type Trigger = { | |
| option: TriggerOption; | |
| metadata?: Record<string, string | number | boolean>; | |
| }; | |
| function createTriggerRecorder() { | |
| const seen = new Set<TriggerOption>(); | |
| const lastMeta = new Map< | |
| TriggerOption, | |
| Record<string, unknown> | undefined | |
| >(); | |
| return { | |
| record(trigger: Trigger) { | |
| seen.add(trigger.option); | |
| lastMeta.set(trigger.option, trigger.metadata); | |
| }, | |
| has(option: TriggerOption) { | |
| return seen.has(option); | |
| }, | |
| getMeta(option: TriggerOption) { | |
| return lastMeta.get(option); | |
| }, | |
| reset() { | |
| seen.clear(); | |
| lastMeta.clear(); | |
| }, | |
| }; | |
| } | |
| /* ------------------------------------------------- | |
| * Trigger recording (runtime) | |
| * ------------------------------------------------- */ | |
| function matchesRequirement( | |
| requirement: Requirement, | |
| recorder: TriggerRecorder | |
| ): boolean { | |
| switch (requirement.kind) { | |
| case 'trigger': { | |
| if (!recorder.has(requirement.option)) return false; | |
| if (!requirement.predicate) return true; | |
| const meta = recorder.getMeta(requirement.option); | |
| return requirement.predicate(meta); | |
| } | |
| case 'allOf': | |
| return requirement.requirements.every((_requirement) => | |
| matchesRequirement(_requirement, recorder) | |
| ); | |
| case 'anyOf': | |
| return requirement.requirements.some((_requirement) => | |
| matchesRequirement(_requirement, recorder) | |
| ); | |
| } | |
| } | |
| function computeAchievementComplete( | |
| state: OnboardingState, | |
| achievement: Achievement | |
| ) { | |
| return achievement.taskIds.every((taskId) => | |
| Boolean(state.taskCompletions[taskId]) | |
| ); | |
| } | |
| /* ------------------------------------------------- | |
| * Trigger recording (runtime) | |
| * ------------------------------------------------- */ | |
| type ToastEvent = | |
| | { kind: 'task_completed'; taskId: string } | |
| | { kind: 'achievement_completed'; achievementId: string }; | |
| type OnboardingContextValue = { | |
| state: OnboardingState; | |
| onboardingCompleted: boolean; | |
| recommendedTask: TaskDefinition | null; | |
| toastQueue: ToastEvent[]; | |
| popToast(): void; | |
| emitAction(trigger: Trigger): Promise<void>; | |
| completeTask( | |
| taskId: string, | |
| context?: Record<string, unknown> | |
| ): Promise<void>; | |
| dismissPromptBox(): Promise<void>; | |
| resetForUser(userId: string): Promise<void>; // load state pro nového uživatele | |
| }; | |
| const OnboardingContext = createContext<OnboardingContextValue | null>(null); | |
| function useOnboarding() { | |
| const context = useContext(OnboardingContext); | |
| if (!context) throw new Error('OnboardingProvider missing'); | |
| return context; | |
| } | |
| /* ------------------------------------------------- | |
| * Onboarding provider | |
| * ------------------------------------------------- */ | |
| function OnboardingProvider({ | |
| userId, | |
| children, | |
| }: { | |
| userId: string; | |
| children: React.ReactNode; | |
| }) { | |
| const opRef = useRef<Promise<void>>(Promise.resolve()); | |
| const enqueue = useCallback(<T,>(fn: () => Promise<T>) => { | |
| const next = opRef.current.then(fn, fn); | |
| opRef.current = next.then( | |
| () => undefined, | |
| () => undefined | |
| ); | |
| return next; | |
| }, []); | |
| const [toastQueue, setToastQueue] = useState<ToastEvent[]>([]); | |
| const recorderRef = useRef<TriggerRecorder>(createTriggerRecorder()); | |
| const [state, setState] = useState<OnboardingState>(() => | |
| getDefaultOnboardingState() | |
| ); | |
| const stateRef = useRef(state); | |
| useEffect(() => { | |
| stateRef.current = state; | |
| }, [state]); | |
| const storageKey = `onboarding:v1:${userId}`; | |
| const load = useCallback(async () => { | |
| const raw = await AsyncStorage.getItem(storageKey); | |
| if (!raw) { | |
| setState(getDefaultOnboardingState()); | |
| return; | |
| } | |
| try { | |
| const parsed = JSON.parse(raw) as OnboardingState; | |
| setState({ | |
| ...getDefaultOnboardingState(), | |
| ...parsed, | |
| settings: { | |
| ...getDefaultOnboardingState().settings, | |
| ...parsed.settings, | |
| }, | |
| }); | |
| } catch { | |
| setState(getDefaultOnboardingState()); | |
| } | |
| }, [storageKey]); | |
| const persist = useCallback( | |
| async (next: OnboardingState) => { | |
| await AsyncStorage.setItem(storageKey, JSON.stringify(next)); | |
| }, | |
| [storageKey] | |
| ); | |
| useEffect(() => { | |
| recorderRef.current.reset(); | |
| load(); | |
| }, [load]); | |
| const onboardingCompleted = Boolean(state.achievementCompletions['basics']); | |
| const recommendedTaskId = useMemo(() => { | |
| if (onboardingCompleted) return null; | |
| return Object.keys(TASKS).find((id) => !state.taskCompletions[id]) ?? null; | |
| }, [onboardingCompleted, state.taskCompletions]); | |
| const recommendedTask = recommendedTaskId | |
| ? TASKS[recommendedTaskId as keyof typeof TASKS] | |
| : null; | |
| const popToast = useCallback(() => { | |
| setToastQueue((q) => q.slice(1)); | |
| }, []); | |
| const completeTask = useCallback( | |
| (taskId: string, context?: Record<string, unknown>) => | |
| enqueue(async () => { | |
| const current = stateRef.current; | |
| // idempotence (už na aktuálním stavu) | |
| if (current.taskCompletions[taskId]) return; | |
| let next: OnboardingState = { | |
| ...current, | |
| taskCompletions: { | |
| ...current.taskCompletions, | |
| [taskId]: { | |
| taskId, | |
| completedAt: new Date().toISOString(), | |
| evidence: context, | |
| }, | |
| }, | |
| }; | |
| const newlyCompletedAchievements: string[] = []; | |
| for (const achievement of Object.values(ACHIEVEMENTS)) { | |
| if (next.achievementCompletions[achievement.id]) continue; | |
| if (computeAchievementComplete(next, achievement)) { | |
| next = { | |
| ...next, | |
| achievementCompletions: { | |
| ...next.achievementCompletions, | |
| [achievement.id]: { | |
| achievementId: achievement.id, | |
| completedAt: new Date().toISOString(), | |
| }, | |
| }, | |
| }; | |
| newlyCompletedAchievements.push(achievement.id); | |
| } | |
| } | |
| // update state + ref synchronně | |
| setState(next); | |
| stateRef.current = next; | |
| await persist(next); | |
| setToastQueue((queue) => [ | |
| ...queue, | |
| { kind: 'task_completed', taskId }, | |
| ...newlyCompletedAchievements.map((achievementId) => ({ | |
| kind: 'achievement_completed' as const, | |
| achievementId, | |
| })), | |
| ]); | |
| }), | |
| [enqueue, persist] | |
| ); | |
| const emitAction = useCallback( | |
| (trigger: Trigger) => | |
| enqueue(async () => { | |
| const recorder = recorderRef.current; | |
| recorder.record(trigger); | |
| const current = stateRef.current; | |
| for (const task of Object.values(TASKS)) { | |
| if (current.taskCompletions[task.id]) continue; | |
| if (matchesRequirement(task.requirements, recorder)) { | |
| await completeTask(task.id); | |
| // completeTask už je taky v queue, takže se nic nepřekryje | |
| } | |
| } | |
| }), | |
| [enqueue, completeTask] | |
| ); | |
| const dismissPromptBox = useCallback(async () => { | |
| const next: OnboardingState = { | |
| ...state, | |
| settings: { ...state.settings, promptBoxDismissed: true }, | |
| }; | |
| setState(next); | |
| await persist(next); | |
| }, [persist, state]); | |
| const resetForUser = useCallback( | |
| async (_userId: string) => { | |
| recorderRef.current.reset(); | |
| await load(); | |
| }, | |
| [load] | |
| ); | |
| const value: OnboardingContextValue = { | |
| state, | |
| onboardingCompleted, | |
| recommendedTask, | |
| toastQueue, | |
| popToast, | |
| emitAction, | |
| completeTask, | |
| dismissPromptBox, | |
| resetForUser, | |
| }; | |
| return ( | |
| <OnboardingContext.Provider value={value}> | |
| {children} | |
| </OnboardingContext.Provider> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment