-
Notifications
You must be signed in to change notification settings - Fork 0
feat: the data that should have been in Postgres, and the tables to hold it #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6e232a8
feat(content): the worked examples the decks fetch from an empty table
amielnoy 98bc2e9
feat(db): one schema file for every table the API owns
amielnoy d5c665c
feat(auth): an account row, and the activity the counters were throwi…
amielnoy 50c12ab
feat(progress): progress that follows the reader, not the browser
amielnoy 787502f
feat(ci): test history that accumulates instead of overwriting itself
amielnoy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,16 @@ | ||
| import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; | ||
| import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; | ||
| import { readValidated, writeValidated } from '../lib/storage'; | ||
|
|
||
| export type ToolId = 'resume' | 'interview' | 'practice'; | ||
|
|
||
| interface AcademyProgress { | ||
| resumeStarted: boolean; | ||
| resumeCompleted: boolean; | ||
| interviewStarted: boolean; | ||
| interviewAnswers: number; | ||
| interviewCompleted: boolean; | ||
| practiceCompleted: string[]; | ||
| lecturesViewed: string[]; | ||
| lastTool: ToolId | null; | ||
| } | ||
| import { useOptionalAuth } from './AuthContext'; | ||
| import { | ||
| fetchProgress, | ||
| pushProgress, | ||
| validateProgress, | ||
| MAX_PROGRESS_IDS, | ||
| type AcademyProgress, | ||
| type ToolId, | ||
| } from '../lib/progressApi'; | ||
|
|
||
| export type { ToolId }; | ||
|
|
||
| interface ProgressContextValue extends AcademyProgress { | ||
| startTool: (tool: ToolId) => void; | ||
|
|
@@ -36,38 +34,47 @@ const EMPTY_PROGRESS: AcademyProgress = { | |
| }; | ||
|
|
||
| function loadProgress(): AcademyProgress { | ||
| // A stored value and a response body are the same kind of thing here — both | ||
| // arrive from outside and both land in the state the site renders from — so | ||
| // both go through the one validator. | ||
| return ( | ||
| readValidated<AcademyProgress>(localStorage, STORAGE_KEY, validateProgress) ?? EMPTY_PROGRESS | ||
| ); | ||
| } | ||
|
|
||
| /** Order-insensitive on the two set columns, because the server's union is. */ | ||
| function sameProgress(a: AcademyProgress, b: AcademyProgress): boolean { | ||
| const sameIds = (left: string[], right: string[]) => | ||
| left.length === right.length && | ||
| [...left].sort().join('\u0000') === [...right].sort().join('\u0000'); | ||
| return ( | ||
| readValidated<AcademyProgress>(localStorage, STORAGE_KEY, raw => { | ||
| const parsed = (raw ?? {}) as Partial<AcademyProgress>; | ||
| return { | ||
| resumeStarted: parsed.resumeStarted === true, | ||
| resumeCompleted: parsed.resumeCompleted === true, | ||
| interviewStarted: parsed.interviewStarted === true, | ||
| interviewAnswers: | ||
| Number.isSafeInteger(parsed.interviewAnswers) && Number(parsed.interviewAnswers) >= 0 | ||
| ? Number(parsed.interviewAnswers) | ||
| : 0, | ||
| interviewCompleted: parsed.interviewCompleted === true, | ||
| practiceCompleted: Array.isArray(parsed.practiceCompleted) | ||
| ? parsed.practiceCompleted | ||
| .filter((id): id is string => typeof id === 'string') | ||
| .slice(0, 500) | ||
| : [], | ||
| lecturesViewed: Array.isArray(parsed.lecturesViewed) | ||
| ? parsed.lecturesViewed.filter((id): id is string => typeof id === 'string').slice(0, 500) | ||
| : [], | ||
| lastTool: ['resume', 'interview', 'practice'].includes(parsed.lastTool || '') | ||
| ? (parsed.lastTool as ToolId) | ||
| : null, | ||
| }; | ||
| }) ?? EMPTY_PROGRESS | ||
| a.resumeStarted === b.resumeStarted && | ||
| a.resumeCompleted === b.resumeCompleted && | ||
| a.interviewStarted === b.interviewStarted && | ||
| a.interviewAnswers === b.interviewAnswers && | ||
| a.interviewCompleted === b.interviewCompleted && | ||
| a.lastTool === b.lastTool && | ||
| sameIds(a.practiceCompleted, b.practiceCompleted) && | ||
| sameIds(a.lecturesViewed, b.lecturesViewed) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * How long a burst of local changes is allowed to settle before it is synced. | ||
| * | ||
| * Answering an interview question updates progress on every answer; without | ||
| * this, so does a request. | ||
| */ | ||
| const SYNC_DELAY_MS = 1_000; | ||
|
|
||
| const ProgressContext = createContext<ProgressContextValue | null>(null); | ||
|
|
||
| export function ProgressProvider({ children }: { children: React.ReactNode }) { | ||
| const [progress, setProgress] = useState<AcademyProgress>(loadProgress); | ||
| // Optional on purpose: progress is tracked for everyone and synced only for | ||
| // someone with an account, so a tree without an AuthProvider is a normal one. | ||
| const user = useOptionalAuth()?.user ?? null; | ||
| const signedIn = user !== null; | ||
|
|
||
| const update = useCallback((fn: (current: AcademyProgress) => AcademyProgress) => { | ||
| setProgress(current => { | ||
|
|
@@ -77,6 +84,57 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) { | |
| }); | ||
| }, []); | ||
|
|
||
| /** | ||
| * Take a copy that came back from the server, if it says anything new. | ||
| * | ||
| * The guard is what stops the sync below from running forever: adopting the | ||
| * server's answer sets state, setting state re-runs the effect, and the next | ||
| * push returns the same union. Comparing before adopting makes the second | ||
| * round a no-op instead of the next lap. | ||
| */ | ||
| const adopt = useCallback((remote: AcademyProgress | null) => { | ||
| if (!remote) return; | ||
| setProgress(current => { | ||
| if (sameProgress(current, remote)) return current; | ||
| writeValidated(localStorage, STORAGE_KEY, remote); | ||
| return remote; | ||
| }); | ||
| }, []); | ||
|
|
||
| // Merge this device's copy into the stored one, and adopt the union. Runs on | ||
| // sign-in and after every local change that settles, and it is the only write | ||
| // path: the response is the merge, so one call both saves and refreshes. | ||
| useEffect(() => { | ||
| if (!signedIn) return; | ||
| const controller = new AbortController(); | ||
| const timer = window.setTimeout(() => { | ||
| void pushProgress(progress, controller.signal).then(adopt); | ||
| }, SYNC_DELAY_MS); | ||
|
Comment on lines
+107
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Progress crosses user accounts ProgressProvider keeps one global localStorage/state copy and pushes it whenever any user becomes signed in, so signing out user A and signing in user B merges A's progress into B's server record. Neither the auth transition nor the sync effect resets or namespaces progress by identity. Agent Prompt
|
||
| return () => { | ||
| window.clearTimeout(timer); | ||
| controller.abort(); | ||
| }; | ||
| }, [signedIn, progress, adopt]); | ||
|
|
||
| // The other half of "follows you between devices": progress made on a phone | ||
| // is already stored, and this is the moment the laptop is worth telling. A | ||
| // read rather than a merge, because nothing changed here while the tab was | ||
| // in the background. | ||
| useEffect(() => { | ||
| if (!signedIn) return; | ||
| const controller = new AbortController(); | ||
| const refresh = () => { | ||
| if (document.visibilityState === 'visible') { | ||
| void fetchProgress(controller.signal).then(adopt); | ||
| } | ||
| }; | ||
| document.addEventListener('visibilitychange', refresh); | ||
| return () => { | ||
| document.removeEventListener('visibilitychange', refresh); | ||
| controller.abort(); | ||
| }; | ||
| }, [signedIn, adopt]); | ||
|
|
||
| const startTool = useCallback( | ||
| (tool: ToolId) => | ||
| update(current => ({ | ||
|
|
@@ -128,7 +186,7 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) { | |
| lastTool: 'practice', | ||
| practiceCompleted: current.practiceCompleted.includes(id) | ||
| ? current.practiceCompleted | ||
| : [...current.practiceCompleted, id], | ||
| : [...current.practiceCompleted, id].slice(-MAX_PROGRESS_IDS), | ||
| })), | ||
| [update], | ||
| ); | ||
|
|
@@ -139,7 +197,7 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) { | |
| ...current, | ||
| lecturesViewed: current.lecturesViewed.includes(id) | ||
| ? current.lecturesViewed | ||
| : [...current.lecturesViewed, id], | ||
| : [...current.lecturesViewed, id].slice(-MAX_PROGRESS_IDS), | ||
| })), | ||
| [update], | ||
| ); | ||
|
|
||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| /** | ||
| * Progress, on the server, for a reader who is signed in. | ||
| * | ||
| * `localStorage` stays the thing the page renders from. This is the copy that | ||
| * survives a cleared browser and follows someone from their laptop to their | ||
| * phone, and every call here is allowed to fail: a signed-out reader, a | ||
| * deployment with no database, an offline moment — all of them mean "no remote | ||
| * copy right now", which is the same answer the site has always worked with. | ||
| * | ||
| * The write is a merge rather than a replace, and the server returns the union. | ||
| * That is what makes two devices safe: whichever one syncs second does not | ||
| * discard what the first one recorded. See `database.merge_progress`. | ||
| */ | ||
|
|
||
| export type ToolId = 'resume' | 'interview' | 'practice'; | ||
|
|
||
| export interface AcademyProgress { | ||
| resumeStarted: boolean; | ||
| resumeCompleted: boolean; | ||
| interviewStarted: boolean; | ||
| interviewAnswers: number; | ||
| interviewCompleted: boolean; | ||
| practiceCompleted: string[]; | ||
| lecturesViewed: string[]; | ||
| lastTool: ToolId | null; | ||
| } | ||
|
|
||
| /** The same bound the API and the database apply, applied before the request. */ | ||
| export const MAX_PROGRESS_IDS = 500; | ||
|
|
||
| const TOOLS: ToolId[] = ['resume', 'interview', 'practice']; | ||
|
|
||
| /** | ||
| * A response is input, exactly as a stored value is. | ||
| * | ||
| * This one arrives from our own API, but it lands in React state that the whole | ||
| * site renders from, and the shape is the only thing standing between a bad | ||
| * response and a render crash. Anything unrecognised reads as absent, so the | ||
| * caller keeps what it already had. | ||
| */ | ||
| export function validateProgress(value: unknown): AcademyProgress | null { | ||
| if (typeof value !== 'object' || value === null) return null; | ||
| const parsed = value as Partial<AcademyProgress>; | ||
| const ids = (list: unknown): string[] => | ||
| Array.isArray(list) | ||
| ? list.filter((id): id is string => typeof id === 'string').slice(0, MAX_PROGRESS_IDS) | ||
| : []; | ||
| return { | ||
| resumeStarted: parsed.resumeStarted === true, | ||
| resumeCompleted: parsed.resumeCompleted === true, | ||
| interviewStarted: parsed.interviewStarted === true, | ||
| interviewAnswers: | ||
| Number.isSafeInteger(parsed.interviewAnswers) && Number(parsed.interviewAnswers) >= 0 | ||
| ? Number(parsed.interviewAnswers) | ||
| : 0, | ||
| interviewCompleted: parsed.interviewCompleted === true, | ||
| practiceCompleted: ids(parsed.practiceCompleted), | ||
| lecturesViewed: ids(parsed.lecturesViewed), | ||
| lastTool: TOOLS.includes(parsed.lastTool as ToolId) ? (parsed.lastTool as ToolId) : null, | ||
| }; | ||
| } | ||
|
|
||
| async function progressFrom(response: Response): Promise<AcademyProgress | null> { | ||
| if (!response.ok) return null; | ||
| const body = (await response.json()) as { progress?: unknown }; | ||
| return validateProgress(body?.progress); | ||
| } | ||
|
|
||
| /** The stored copy, or null when there is not one to be had. */ | ||
| export async function fetchProgress(signal?: AbortSignal): Promise<AcademyProgress | null> { | ||
| try { | ||
| return await progressFrom( | ||
| await fetch('/api/progress', { credentials: 'include', cache: 'no-store', signal }), | ||
| ); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** Merges this device's copy into the stored one and returns the union. */ | ||
| export async function pushProgress( | ||
| progress: AcademyProgress, | ||
| signal?: AbortSignal, | ||
| ): Promise<AcademyProgress | null> { | ||
| try { | ||
| return await progressFrom( | ||
| await fetch('/api/progress', { | ||
| method: 'PUT', | ||
| credentials: 'include', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| ...progress, | ||
| practiceCompleted: progress.practiceCompleted.slice(0, MAX_PROGRESS_IDS), | ||
| lecturesViewed: progress.lecturesViewed.slice(0, MAX_PROGRESS_IDS), | ||
| }), | ||
| signal, | ||
| }), | ||
| ); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
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
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3. Stale refresh erases progress
🐞 Bug≡ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools