Skip to content

Instantly share code, notes, and snippets.

@hrdyjan1
Last active December 12, 2025 12:40
Show Gist options
  • Select an option

  • Save hrdyjan1/09123bcaed70e553e7622944e0633372 to your computer and use it in GitHub Desktop.

Select an option

Save hrdyjan1/09123bcaed70e553e7622944e0633372 to your computer and use it in GitHub Desktop.
Anoto
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