Co-led the technical build of ABCmouse Parent Center — a SvelteKit parent hub for child progress, profiles, and account settings that runs on the web and inside the native app webview. Owned progress reporting, settings navigation, design-system components, store architecture, and automated test coverage.
Owned the progress report end to end — the Parent Center UI and the PHP service-layer endpoints that power it. Assembled lifetime and period completion stats, a recent-activity feed, and per-category learning-path progress (units, lessons, current/next unit). On the client, designed skeleton, empty, and error states so the page stays clear while data loads or when a child has no activity yet.
// Portfolio-safe mocks mirroring the report + path response shapes
type ProgressStatus = 'not_started' | 'in_progress' | 'completed';
type RecentActivity = {
activityId: number;
title: string;
category: string;
imagePath: string;
activityType: string;
completedAt: string; // Y-m-d H:i:s
};
type UserReport = {
userUuid: string;
childName: string;
lifetime: {
activitiesCompleted: number;
learningPathActivitiesCompleted: number;
};
// Defaults to last 7 days; optional custom start/end range
period: {
startDate: string; // Y-m-d
endDate: string;
totals: {
activitiesCompleted: number;
learningPathActivitiesCompleted: number;
minutesPlayed: number;
};
};
recentActivities: RecentActivity[]; // capped (e.g. 20)
};
type LessonProgress = {
lessonId: string;
title: string;
status: ProgressStatus;
};
type UnitSummary = {
unitNumber: number;
unitId: string;
title: string;
lessonsCompleted: number;
lessonsTotal: number;
status: ProgressStatus;
};
type UnitProgress = {
summary: UnitSummary;
lessons: LessonProgress[];
};
type PathProgress = {
category: 'reading' | 'math';
grade: string;
gradeDisplayName: string;
gradeDescription: string;
progressPercent: number;
lessonsCompleted: number;
lessonsTotal: number;
unitsCompleted: number;
unitsTotal: number;
currentUnit: UnitProgress | null;
nextUnit: UnitSummary | null;
units: UnitProgress[];
};
type LearningPathProgress = {
userUuid: string;
paths: PathProgress[];
};
const userReport: UserReport = {
userUuid: 'child_01',
childName: 'Alex',
lifetime: {
activitiesCompleted: 142,
learningPathActivitiesCompleted: 98
},
period: {
startDate: '2026-03-10',
endDate: '2026-03-16',
totals: {
activitiesCompleted: 12,
learningPathActivitiesCompleted: 8,
minutesPlayed: 95
}
},
recentActivities: [
{
activityId: 1001,
title: 'Letter Sounds',
category: 'Reading',
imagePath: '/mock/letter-sounds.png',
activityType: 'game',
completedAt: '2026-03-16 18:20:00'
}
]
};
const learningPath: LearningPathProgress = {
userUuid: 'child_01',
paths: [
{
category: 'reading',
grade: 'k',
gradeDisplayName: 'Kindergarten',
gradeDescription: 'Build letter sounds and early reading confidence.',
progressPercent: 37.5,
lessonsCompleted: 6,
lessonsTotal: 16,
unitsCompleted: 1,
unitsTotal: 4,
currentUnit: {
summary: {
unitNumber: 2,
unitId: 'unit_abc',
title: 'Short Vowels',
lessonsCompleted: 1,
lessonsTotal: 4,
status: 'in_progress'
},
lessons: [
{
lessonId: 'lesson_01',
title: 'A as in Apple',
status: 'completed'
},
{
lessonId: 'lesson_02',
title: 'E as in Egg',
status: 'in_progress'
}
]
},
nextUnit: {
unitNumber: 3,
unitId: 'unit_def',
title: 'Blending',
lessonsCompleted: 0,
lessonsTotal: 4,
status: 'not_started'
},
units: [/* … */]
}
]
};Architected a responsive settings shell — stacked navigation on mobile, sidebar rail plus
content panel on desktop — and used a single route with ?view=
sub-views for deeplinkable account and profile flows. Shared back-navigation config keeps
multi-step flows (edit profile, membership details) consistent without nesting a deep route
tree.
// Generic recreation of the view-map pattern
const settingsViews = {
account: ['overview', 'update-email', 'membership'],
'child-profiles': ['list', 'add-child', 'edit-child'],
'app-settings': ['overview', 'language']
} as const;
type Section = keyof typeof settingsViews;
type ViewFor<S extends Section> = (typeof settingsViews)[S][number];
function settingsHref(section: Section, view: ViewFor<Section>) {
return `/settings/${section}?view=${view}`;
}
// Example: /settings/child-profiles?view=edit-child
settingsHref('child-profiles', 'edit-child');Implemented add and edit child-profile flows with display name, birth date, and reading/math level pickers presented as bottom sheets, plus confirmation dialogs when a level change would affect the learning path. Kept profile forms driven by typed view models so validation and UI stay aligned.
type ChildProfile = {
id: string;
displayName: string;
birthMonth: number | null;
birthYear: number | null;
readingLevelKey: string | null;
mathLevelKey: string | null;
};
type GradeOption = { key: string; label: string };
const gradeOptions: GradeOption[] = [
{ key: 'prek', label: 'Pre-K' },
{ key: 'k', label: 'Kindergarten' },
{ key: 'grade-1', label: '1st Grade' }
];
const draft: ChildProfile = {
id: 'child_01',
displayName: 'Alex',
birthMonth: 4,
birthYear: 2019,
readingLevelKey: 'k',
mathLevelKey: 'prek'
};Helped establish a Figma-sourced token pipeline into Tailwind so spacing, color, and type stayed consistent across Parent Center. Built and reused shared primitives — buttons, inputs, dialogs, sheets, and layout shells — so feature routes composed UI instead of reinventing patterns.
/* Genericized token → utility mapping */
:root {
--color-surface: #0f172a;
--color-accent: #2dd4bf;
--space-panel: 1.5rem;
--radius-card: 0.75rem;
}
/* Feature pages compose shared primitives */
<SettingsPanel title="Child profiles">
<TextField label="Display name" bind:value={name} />
<BottomSheet open={levelSheetOpen}>
<GradePicker options={gradeOptions} onSelect={setLevel} />
</BottomSheet>
</SettingsPanel>Structured client state around a base store and action pattern so UI components called typed actions instead of wiring fetch logic inline. Cross-cutting concerns — loading flags, retry, and debounce — lived on the action layer, which kept progress, profile, and settings views thinner and easier to test.
// Generic recreation — invented names, not production source
class BaseStore<T> {
constructor(private state: T) {}
get() { return this.state; }
patch(partial: Partial<T>) {
this.state = { ...this.state, ...partial };
}
}
type ReportState = {
report: UserReport | null;
isLoading: boolean;
error: string | null;
};
class LoadChildReport {
constructor(private store: BaseStore<ReportState>) {}
async run(childId: string) {
this.store.patch({ isLoading: true, error: null });
try {
const report = await fetchReport(childId); // mocked in portfolio
this.store.patch({ report, isLoading: false });
} catch {
this.store.patch({
isLoading: false,
error: 'Unable to load progress'
});
}
}
}Supported dual deployment: the same Parent Center UI runs as a standalone web app and as an embedded webview inside the native app. A thin bridge layer handled close-to-app navigation, device settings (microphone, notifications, language), and in-app vs browser presentation differences without leaking platform details into feature pages.
// Generic bridge sketch
type BridgeEvent =
| { type: 'CLOSE_PARENT_CENTER' }
| { type: 'SET_LOCALE'; locale: string }
| { type: 'SET_MIC_ENABLED'; enabled: boolean };
function postToNative(event: BridgeEvent) {
window.parent?.postMessage({ source: 'parent-center', event }, '*');
}
function isInApp(): boolean {
return document.documentElement.dataset.runtime === 'webview';
}
// Feature UI branches on runtime, not platform APIs
if (isInApp()) {
postToNative({ type: 'CLOSE_PARENT_CENTER' });
} else {
history.back();
}Covered critical parent flows with Vitest unit and component tests for mappers, stores, and UI states, plus Playwright end-to-end coverage for settings navigation and profile editing. Used page-object helpers and fixtures so suite setup stayed readable as the surface area grew.
// Illustrative E2E structure (fictional selectors)
class ChildProfilesPage {
constructor(private page: Page) {}
async openEdit(childName: string) {
await this.page.getByRole('button', { name: childName }).click();
await this.page.getByRole('link', { name: 'Edit profile' }).click();
}
async setDisplayName(name: string) {
await this.page.getByLabel('Display name').fill(name);
await this.page.getByRole('button', { name: 'Save' }).click();
}
}
test('parent can rename a child profile', async ({ page }) => {
const profiles = new ChildProfilesPage(page);
await page.goto('/settings/child-profiles?view=list');
await profiles.openEdit('Alex');
await profiles.setDisplayName('Alex R.');
await expect(page.getByText('Alex R.')).toBeVisible();
});