Skip to content

Commit 88e5c51

Browse files
feat: add supabase publishable events (#115)
* feat: add supabase pblishable events * fix: remove undeclared package from lock * refactor: make attempt_id indexable
1 parent 96c7122 commit 88e5c51

File tree

11 files changed

+204
-12
lines changed

11 files changed

+204
-12
lines changed

.github/workflows/publish.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ jobs:
323323
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
324324
FREECODECAMP_API: ${{ env.FREECODECAMP_API }}
325325
ENVIRONMENT: ${{inputs.environment}}
326+
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
327+
SUPABASE_PUBLISHABLE: ${{ secrets.SUPABASE_PUBLISHABLE }}
326328
with:
327329
releaseId: ${{ needs.create-release.outputs.release_id }}
328330
args: ${{ steps.build-args.outputs.args }} ${{ env.TAURI_VERBOSE_FLAG }}

bun.lock

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@freecodecamp/ui": "4.3.0",
2020
"@sentry/react": "10.27.0",
2121
"@sentry/vite-plugin": "4.6.1",
22+
"@supabase/supabase-js": "2.90.0",
2223
"@tanstack/react-query": "5.90.11",
2324
"@tanstack/react-router": "1.139.12",
2425
"@tauri-apps/api": "2.9.1",

sample.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ AZURE_TENANT_ID=azure-tenant-id
99
AZURE_CERTIFICATE_URI=https://prd-exam-environment.vault.azure.net/certificates/prd-exam-environment
1010
AZURE_CLIENT_SECRET=azure-client-secret
1111
AZURE_CLIENT_ID=azure-client-id
12+
13+
SUPABASE_URL=""
14+
SUPABASE_PUBLISHABLE=""

src-tauri/capabilities/default.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
},
3434
{
3535
"url": "https://api.github.com"
36+
},
37+
{
38+
"url": "https://*.supabase.co"
3639
}
3740
]
3841
},

src/components/question-set-form.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Box, Divider, Text } from "@chakra-ui/react";
22
import { QuizQuestion } from "@freecodecamp/ui";
3-
import { useEffect, useRef } from "react";
3+
import { SyntheticEvent, useEffect, useRef } from "react";
44

55
import { Answers, FullQuestion, UserExamAttempt } from "../utils/types";
66
import { AudioPlayer } from "./audio-player";
77
import { parseMarkdown } from "../utils/markdown";
88
import { PrismFormatted } from "./prism-formatted";
9-
import { logger } from "@sentry/react";
9+
import { captureEvent, createEvent, EventKind } from "../utils/superbase";
1010

1111
type QuestionTypeFormProps = {
1212
fullQuestion: FullQuestion;
@@ -21,16 +21,17 @@ export function QuestionSetForm({
2121
setNewSelectedAnswers,
2222
examAttempt,
2323
}: QuestionTypeFormProps) {
24-
const captionsRef = useRef<HTMLDetailsElement>(null);
24+
const lastTrackedId = useRef<string | null>(null);
2525

26-
useEffect(() => {
27-
if (captionsRef.current?.open) {
28-
logger.info("captions opened", {
29-
exam: examAttempt.examId,
30-
question: fullQuestion.id,
31-
});
26+
function captionsToggled(e: SyntheticEvent<HTMLDetailsElement, Event>) {
27+
if (e.currentTarget.open) {
28+
captureEvent(
29+
createEvent(EventKind.CAPTIONS_OPENED, examAttempt.id, {
30+
question: fullQuestion.id,
31+
})
32+
);
3233
}
33-
}, [captionsRef.current?.open]);
34+
}
3435

3536
useEffect(() => {
3637
setNewSelectedAnswers(
@@ -42,6 +43,15 @@ export function QuestionSetForm({
4243
)
4344
.map((a) => a.id)
4445
);
46+
47+
if (lastTrackedId.current !== fullQuestion.id) {
48+
captureEvent(
49+
createEvent(EventKind.QUESTION_VISIT, examAttempt.id, {
50+
question: fullQuestion.id,
51+
})
52+
);
53+
lastTrackedId.current = fullQuestion.id;
54+
}
4555
}, [fullQuestion]);
4656

4757
return (
@@ -56,13 +66,13 @@ export function QuestionSetForm({
5666
<Divider />
5767
</>
5868
)}
59-
{fullQuestion.audio && (
69+
{!!fullQuestion.audio?.url && (
6070
<Box mb={"2em"} mt={"2em"}>
6171
<Text>Please listen to the following audio fragment:</Text>
6272
{/* NOTE: `fullQuestion` is passed to cause the whole component to rerender - correctly resetting the audio */}
6373
<AudioPlayer fullQuestion={fullQuestion} />
6474
{fullQuestion.audio.captions && (
65-
<details style={{ cursor: "pointer" }} ref={captionsRef}>
75+
<details style={{ cursor: "pointer" }} onToggle={captionsToggled}>
6676
<summary>Show captions</summary>
6777
<Box marginTop="1em">
6878
<PrismFormatted

src/components/use-app-focus.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { getCurrentWindow } from "@tauri-apps/api/window";
2+
import { useEffect } from "react";
3+
4+
export type AppFocusProps = {
5+
onFocusChanged: (focused: boolean) => void;
6+
};
7+
8+
/**
9+
* A hook to attach a callback for whenever the app window's focus changes.
10+
*
11+
* As the focus event handler is immediately attached, a ref can be used to prevent always running on focus changes and prevent unnecessary rerenders.
12+
*
13+
* **Usage**
14+
*
15+
* ```tsx
16+
* function myComponent() {
17+
* const runFocusRef = useRef(false);
18+
*
19+
* function onFocusChanged(focused: boolean) {
20+
* if (runFocusRef.current) {
21+
* console.log(focused);
22+
* }
23+
* }
24+
* useAppFocus({onFocusChanged});
25+
*
26+
* return (
27+
* <button
28+
* onClick={() => {
29+
* runFocusRef.current = !runFocusRef.current;
30+
* }}
31+
* >
32+
* Toogle focus runner
33+
* </button>
34+
* );
35+
* }
36+
* ```
37+
*
38+
* TODO: Consider returning `listen` and `unlisten` functions
39+
*
40+
*/
41+
export function useAppFocus({ onFocusChanged }: AppFocusProps) {
42+
// TODO: Once useEffectiveEvent is stabalised
43+
// const onChange = useEffectiveEvent(onFocusChanged);
44+
45+
async function listen() {
46+
const unlisten = await getCurrentWindow().onFocusChanged(
47+
({ payload: focused }) => {
48+
onFocusChanged(focused);
49+
}
50+
);
51+
52+
return unlisten;
53+
}
54+
55+
useEffect(() => {
56+
// TODO: What to do if unlisten function is not yet attached to ref?
57+
const unlisten = listen();
58+
59+
return () => {
60+
// If component is unmounted, listener MUST be unlistened
61+
unlisten?.then((u) => u());
62+
};
63+
}, [onFocusChanged]);
64+
}

src/pages/exam.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { getErrorMessage } from "../utils/errors";
3232
import { ExamSubmissionModal } from "../components/exam-submission-modal";
3333
import { QuestionSubmissionErrorModal } from "../components/question-submission-error-modal";
3434
import { produce } from "immer";
35+
import { useAppFocus } from "../components/use-app-focus";
36+
import { captureEvent, createEvent, EventKind } from "../utils/superbase";
3537

3638
export function Exam() {
3739
const { examId } = ExamRoute.useParams();
@@ -268,6 +270,23 @@ export function Exam() {
268270
[questions, activeQuestionId]
269271
);
270272

273+
const onFocusChanged = useCallback(
274+
(focused: boolean) => {
275+
if (!examAttempt || !fullQuestion) return;
276+
277+
const eventKind = focused ? EventKind.FOCUS : EventKind.BLUR;
278+
279+
captureEvent(
280+
createEvent(eventKind, examAttempt.id, {
281+
question: fullQuestion.id,
282+
})
283+
);
284+
},
285+
[examAttempt, fullQuestion]
286+
);
287+
288+
useAppFocus({ onFocusChanged });
289+
271290
if (examQuery.isPending) {
272291
return (
273292
<Box overflowY="hidden">

src/utils/superbase.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { createClient } from "@supabase/supabase-js";
2+
import { fetch } from "@tauri-apps/plugin-http";
3+
4+
export const supabase = createClient(
5+
__SUPABASE_URL__,
6+
__SUPABASE_PUBLISHABLE__,
7+
{
8+
global: {
9+
// @ts-expect-error Unknown error
10+
fetch: fetch.bind(globalThis),
11+
},
12+
},
13+
);
14+
15+
export const EventKind = {
16+
CAPTIONS_OPENED: "CAPTIONS_OPENED",
17+
QUESTION_VISIT: "QUESTION_VISIT",
18+
FOCUS: "FOCUS",
19+
BLUR: "BLUR",
20+
EXAM_EXIT: "EXAM_EXIT",
21+
} as const;
22+
23+
type Meta = Record<string, unknown>;
24+
25+
interface Event {
26+
kind: keyof typeof EventKind;
27+
// timestamp: Date;
28+
meta: Meta | null;
29+
// ObjectId
30+
attempt_id: string;
31+
}
32+
33+
export async function captureEvent(event: Event) {
34+
try {
35+
const res = await supabase.from("events").insert(event);
36+
console.debug(
37+
event,
38+
res.count,
39+
res.data,
40+
res.error,
41+
res.status,
42+
res.statusText,
43+
);
44+
} catch (e) {
45+
console.log(e);
46+
}
47+
}
48+
49+
export function createEvent(
50+
kind: Event["kind"],
51+
attempt_id: Event["attempt_id"],
52+
meta: Event["meta"] = null,
53+
): Event {
54+
return { kind, meta, attempt_id };
55+
}

src/vite-env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ declare const __APP_VERSION__: string;
33
declare const __SENTRY_DSN__: string;
44
declare const __FREECODECAMP_API__: string;
55
declare const __ENVIRONMENT__: string;
6+
declare const __SUPABASE_URL__: string;
7+
declare const __SUPABASE_PUBLISHABLE__: string;

0 commit comments

Comments
 (0)