Skip to content

Commit 9b4a19d

Browse files
author
ComputelessComputer
committed
refactor: clean up onboarding survey flow
Replace the survey trigger with a typed survey state API, split the modal from the controller, and add prompt coverage plus devtool controls.
1 parent 6aa4c37 commit 9b4a19d

14 files changed

Lines changed: 745 additions & 2 deletions

File tree

apps/desktop/src-tauri/src/commands.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::AppExt;
22
use crate::embedded_cli::EmbeddedCliStatus;
3+
use crate::ext::OnboardingSurveyState;
34

45
#[tauri::command]
56
#[specta::specta]
@@ -110,6 +111,38 @@ pub async fn set_recently_opened_sessions<R: tauri::Runtime>(
110111
app.set_recently_opened_sessions(v)
111112
}
112113

114+
#[tauri::command]
115+
#[specta::specta]
116+
pub async fn get_onboarding_survey_state<R: tauri::Runtime>(
117+
app: tauri::AppHandle<R>,
118+
) -> Result<OnboardingSurveyState, String> {
119+
app.get_onboarding_survey_state()
120+
}
121+
122+
#[tauri::command]
123+
#[specta::specta]
124+
pub async fn record_onboarding_survey_launch<R: tauri::Runtime>(
125+
app: tauri::AppHandle<R>,
126+
) -> Result<OnboardingSurveyState, String> {
127+
app.record_onboarding_survey_launch()
128+
}
129+
130+
#[tauri::command]
131+
#[specta::specta]
132+
pub async fn finish_onboarding_survey<R: tauri::Runtime>(
133+
app: tauri::AppHandle<R>,
134+
) -> Result<OnboardingSurveyState, String> {
135+
app.finish_onboarding_survey()
136+
}
137+
138+
#[tauri::command]
139+
#[specta::specta]
140+
pub async fn reset_onboarding_survey<R: tauri::Runtime>(
141+
app: tauri::AppHandle<R>,
142+
) -> Result<OnboardingSurveyState, String> {
143+
app.reset_onboarding_survey()
144+
}
145+
113146
#[tauri::command]
114147
#[specta::specta]
115148
pub async fn get_char_v1p1_preview<R: tauri::Runtime>(

apps/desktop/src-tauri/src/ext.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
use crate::StoreKey;
22
use tauri_plugin_store2::{ScopedStore, Store2PluginExt};
3+
4+
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, specta::Type)]
5+
#[serde(default, rename_all = "camelCase")]
6+
pub struct OnboardingSurveyState {
7+
pub launch_count: u32,
8+
pub done: bool,
9+
}
10+
311
pub trait AppExt<R: tauri::Runtime> {
412
fn desktop_store(&self) -> Result<ScopedStore<R, crate::StoreKey>, String>;
513

@@ -18,6 +26,12 @@ pub trait AppExt<R: tauri::Runtime> {
1826
fn get_recently_opened_sessions(&self) -> Result<Option<String>, String>;
1927
fn set_recently_opened_sessions(&self, v: String) -> Result<(), String>;
2028

29+
fn get_onboarding_survey_state(&self) -> Result<OnboardingSurveyState, String>;
30+
fn set_onboarding_survey_state(&self, state: OnboardingSurveyState) -> Result<(), String>;
31+
fn record_onboarding_survey_launch(&self) -> Result<OnboardingSurveyState, String>;
32+
fn finish_onboarding_survey(&self) -> Result<OnboardingSurveyState, String>;
33+
fn reset_onboarding_survey(&self) -> Result<OnboardingSurveyState, String>;
34+
2135
fn get_char_v1p1_preview(&self) -> Result<bool, String>;
2236
fn set_char_v1p1_preview(&self, v: bool) -> Result<(), String>;
2337
}
@@ -115,6 +129,47 @@ impl<R: tauri::Runtime, T: tauri::Manager<R>> AppExt<R> for T {
115129
store.save().map_err(|e| e.to_string())
116130
}
117131

132+
#[tracing::instrument(skip_all)]
133+
fn get_onboarding_survey_state(&self) -> Result<OnboardingSurveyState, String> {
134+
let store = self.desktop_store()?;
135+
store
136+
.get(StoreKey::OnboardingSurvey)
137+
.map(|opt| opt.unwrap_or_default())
138+
.map_err(|e| e.to_string())
139+
}
140+
141+
#[tracing::instrument(skip_all)]
142+
fn set_onboarding_survey_state(&self, state: OnboardingSurveyState) -> Result<(), String> {
143+
let store = self.desktop_store()?;
144+
store
145+
.set(StoreKey::OnboardingSurvey, state)
146+
.map_err(|e| e.to_string())?;
147+
store.save().map_err(|e| e.to_string())
148+
}
149+
150+
#[tracing::instrument(skip_all)]
151+
fn record_onboarding_survey_launch(&self) -> Result<OnboardingSurveyState, String> {
152+
let mut state = self.get_onboarding_survey_state()?;
153+
state.launch_count = state.launch_count.saturating_add(1);
154+
self.set_onboarding_survey_state(state.clone())?;
155+
Ok(state)
156+
}
157+
158+
#[tracing::instrument(skip_all)]
159+
fn finish_onboarding_survey(&self) -> Result<OnboardingSurveyState, String> {
160+
let mut state = self.get_onboarding_survey_state()?;
161+
state.done = true;
162+
self.set_onboarding_survey_state(state.clone())?;
163+
Ok(state)
164+
}
165+
166+
#[tracing::instrument(skip_all)]
167+
fn reset_onboarding_survey(&self) -> Result<OnboardingSurveyState, String> {
168+
let state = OnboardingSurveyState::default();
169+
self.set_onboarding_survey_state(state.clone())?;
170+
Ok(state)
171+
}
172+
118173
#[tracing::instrument(skip_all)]
119174
fn get_char_v1p1_preview(&self) -> Result<bool, String> {
120175
if cfg!(feature = "new") {

apps/desktop/src-tauri/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,10 @@ fn make_specta_builder<R: tauri::Runtime>() -> tauri_specta::Builder<R> {
378378
commands::set_pinned_tabs::<tauri::Wry>,
379379
commands::get_recently_opened_sessions::<tauri::Wry>,
380380
commands::set_recently_opened_sessions::<tauri::Wry>,
381+
commands::get_onboarding_survey_state::<tauri::Wry>,
382+
commands::record_onboarding_survey_launch::<tauri::Wry>,
383+
commands::finish_onboarding_survey::<tauri::Wry>,
384+
commands::reset_onboarding_survey::<tauri::Wry>,
381385
commands::get_char_v1p1_preview::<tauri::Wry>,
382386
commands::set_char_v1p1_preview::<tauri::Wry>,
383387
commands::check_embedded_cli::<tauri::Wry>,

apps/desktop/src-tauri/src/store.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub enum StoreKey {
55
OnboardingNeeded2,
66
DismissedToasts,
77
OnboardingLocal,
8+
OnboardingSurvey,
89
TinybaseValues,
910
PinnedTabs,
1011
RecentlyOpenedSessions,

apps/desktop/src/services/event-listeners.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
12
import { render } from "@testing-library/react";
23
import { beforeEach, describe, expect, test, vi } from "vitest";
34

@@ -100,7 +101,11 @@ describe("EventListeners notification events", () => {
100101
};
101102
useSettingsStoreMock.mockReturnValue(settingsStore as never);
102103

103-
render(<EventListeners />);
104+
render(
105+
<QueryClientProvider client={new QueryClient()}>
106+
<EventListeners />
107+
</QueryClientProvider>,
108+
);
104109

105110
await vi.waitFor(() =>
106111
expect(notificationListenMock).toHaveBeenCalledTimes(1),

apps/desktop/src/services/event-listeners.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "~/store/tinybase/store/sessions";
1616
import * as settings from "~/store/tinybase/store/settings";
1717
import { useTabs } from "~/store/zustand/tabs";
18+
import { OnboardingSurveyPrompt } from "~/survey/prompt";
1819

1920
function parseIgnoredPlatforms(value: unknown) {
2021
if (typeof value !== "string") {
@@ -196,5 +197,5 @@ export function EventListeners() {
196197
useUpdaterEvents();
197198
useNotificationEvents();
198199

199-
return null;
200+
return <OnboardingSurveyPrompt />;
200201
}

apps/desktop/src/sidebar/devtool.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { cn } from "@hypr/utils";
99
import { getLatestVersion } from "~/changelog";
1010
import * as main from "~/store/tinybase/store/main";
1111
import { useTabs } from "~/store/zustand/tabs";
12+
import { OnboardingSurveyDialog } from "~/survey/dialog";
13+
import {
14+
useOnboardingSurveyState,
15+
useResetOnboardingSurvey,
16+
} from "~/survey/state";
1217
import { commands } from "~/types/tauri.gen";
1318

1419
export function DevtoolView() {
@@ -17,6 +22,7 @@ export function DevtoolView() {
1722
<div className="flex flex-1 flex-col gap-2 overflow-y-auto px-1 py-2">
1823
<NavigationCard />
1924
<ToastsCard />
25+
<SurveyCard />
2026
<CountdownTestCard />
2127
<ErrorTestCard />
2228
</div>
@@ -284,6 +290,62 @@ function CountdownTestCard() {
284290
);
285291
}
286292

293+
function SurveyCard() {
294+
const [previewOpen, setPreviewOpen] = useState(false);
295+
const { data: surveyState, refetch, isFetching } = useOnboardingSurveyState();
296+
const resetMutation = useResetOnboardingSurvey();
297+
298+
const btnClass = cn([
299+
"w-full rounded-md px-2.5 py-1.5",
300+
"text-left text-xs font-medium",
301+
"border border-neutral-200 text-neutral-700",
302+
"cursor-pointer transition-colors",
303+
"hover:border-neutral-300 hover:bg-neutral-50",
304+
"disabled:cursor-not-allowed disabled:opacity-40",
305+
]);
306+
307+
return (
308+
<DevtoolCard title="Survey">
309+
<div className="flex flex-col gap-1.5">
310+
<div className="text-xs text-neutral-500">
311+
Launch count: {surveyState.launchCount} | Done:{" "}
312+
{surveyState.done ? "yes" : "no"}
313+
</div>
314+
<button
315+
type="button"
316+
onClick={() => void refetch()}
317+
disabled={isFetching}
318+
className={btnClass}
319+
>
320+
Refresh State
321+
</button>
322+
<button
323+
type="button"
324+
onClick={() => resetMutation.mutate()}
325+
disabled={resetMutation.isPending}
326+
className={btnClass}
327+
>
328+
Reset Survey State
329+
</button>
330+
<button
331+
type="button"
332+
onClick={() => setPreviewOpen(true)}
333+
className={btnClass}
334+
>
335+
Preview Survey Modal
336+
</button>
337+
</div>
338+
{previewOpen ? (
339+
<OnboardingSurveyDialog
340+
open={previewOpen}
341+
onOpenChange={setPreviewOpen}
342+
onSubmit={() => setPreviewOpen(false)}
343+
/>
344+
) : null}
345+
</DevtoolCard>
346+
);
347+
}
348+
287349
function ErrorTestCard() {
288350
const [shouldThrow, setShouldThrow] = useState(false);
289351

apps/desktop/src/survey/config.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { AnalyticsPayload } from "@hypr/plugin-analytics";
2+
3+
export const ONBOARDING_SURVEY_ID = "onboarding_survey_v1";
4+
5+
export type SurveyQuestion = {
6+
id: string;
7+
prompt: string;
8+
options: string[];
9+
multiSelect: boolean;
10+
};
11+
12+
export type SurveyResponses = Partial<Record<string, string[]>>;
13+
14+
export const onboardingSurveyQuestions: SurveyQuestion[] = [
15+
{
16+
id: "how_found_us",
17+
prompt: "How did you find us?",
18+
options: [
19+
"Google search",
20+
"GitHub",
21+
"AI search (ChatGPT, Perplexity, etc.)",
22+
"Friend / colleague",
23+
"Other",
24+
],
25+
multiSelect: false,
26+
},
27+
{
28+
id: "why_char",
29+
prompt: "Why did you choose Char over other options?",
30+
options: [
31+
"I want my data stored locally / privacy matters",
32+
"I want to choose my own AI provider",
33+
"It's open source",
34+
"I was looking for a free AI meeting tool",
35+
"Other",
36+
],
37+
multiSelect: true,
38+
},
39+
{
40+
id: "role",
41+
prompt: "What's your role?",
42+
options: [
43+
"Engineer / Developer",
44+
"Founder / Technical leader",
45+
"Legal / Healthcare / Finance",
46+
"Product / Design / Ops",
47+
"Other",
48+
],
49+
multiSelect: false,
50+
},
51+
{
52+
id: "current_note_taking",
53+
prompt: "How are you currently taking notes?",
54+
options: [
55+
"I'm not / pen & paper",
56+
"Manually in an app (Apple Notes, Notion, Google Docs, etc.)",
57+
"AI tool that joins the call (Otter, Fireflies, etc.)",
58+
"AI tool without a bot (Granola, Jamie, etc.)",
59+
"Other",
60+
],
61+
multiSelect: true,
62+
},
63+
];
64+
65+
function surveyResponseKey(index: number) {
66+
return index === 0 ? "$survey_response" : `$survey_response_${index}`;
67+
}
68+
69+
function formatSurveyResponse(question: SurveyQuestion, answers: string[]) {
70+
if (!question.multiSelect) {
71+
return answers[0] ?? "";
72+
}
73+
74+
return answers.join(", ");
75+
}
76+
77+
export function buildOnboardingSurveySubmittedPayload(
78+
responses: SurveyResponses,
79+
): AnalyticsPayload {
80+
const payload: AnalyticsPayload = {
81+
event: "survey sent",
82+
$survey_id: ONBOARDING_SURVEY_ID,
83+
};
84+
85+
onboardingSurveyQuestions.forEach((question, index) => {
86+
payload[surveyResponseKey(index)] = formatSurveyResponse(
87+
question,
88+
responses[question.id] ?? [],
89+
);
90+
});
91+
92+
return payload;
93+
}
94+
95+
export function buildOnboardingSurveyDismissedPayload(): AnalyticsPayload {
96+
return {
97+
event: "survey dismissed",
98+
$survey_id: ONBOARDING_SURVEY_ID,
99+
};
100+
}

0 commit comments

Comments
 (0)