Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ jobs:
- name: Verify Phase 0-to-Phase 1A migration compatibility
run: ./supabase/scripts/migration-compatibility-test.sh

- name: Verify profile-core-to-experience migration compatibility
run: ./supabase/scripts/work-experience-migration-compatibility-test.sh

- name: Reset database from empty for test suite
run: supabase db reset

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ hackathons, scholarships, and other resume-building opportunities; compares them
detailed saved profile; explains where the student is competitive and where they are not; and
tracks the full lifecycle from "found it" to "applied" to "interviewed."

Phase 0 is implemented, and the first Phase 1A profile-core/education slice adds routed manual
profile and primary-education workflows: a React/TypeScript/Vite frontend, a local Supabase stack
(Postgres/Auth/PostgREST), the initial `profiles`/`education_entries` schema with Row Level
Security, and CI. See [Local development setup](#local-development-setup) below to run it.
Phase 0 is implemented. Phase 1A currently provides routed manual profile, primary-education,
and experience/research workflows: a React/TypeScript/Vite frontend, a local Supabase stack
(Postgres/Auth/PostgREST), `profiles`, `education_entries`, and `work_experience` protected by
Row Level Security, and CI. Projects, links, skills, preferences, and resume features remain
deferred. See [Local development setup](#local-development-setup) below to run it.

This repository is public for portfolio, education, and review purposes — see
[License status](#license-status) and [Contributing](#contributing) below before assuming more
Expand Down
5 changes: 5 additions & 0 deletions app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ vi.mock('./lib/educationRepository', () => ({
list: () => Promise.resolve({ data: [], error: null }),
},
}))
vi.mock('./lib/experienceRepository', () => ({
experienceRepository: {
list: () => Promise.resolve({ data: [], error: null }),
},
}))
vi.mock('./lib/profileReviewRepository', () => ({
profileReviewRepository: {
state: () => Promise.resolve({ data: [], error: null }),
Expand Down
8 changes: 8 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { ProfileLayout } from './pages/ProfileLayout'
import { BasicProfilePage } from './pages/BasicProfilePage'
import { EducationEditorPage } from './pages/EducationEditorPage'
import { EducationListPage } from './pages/EducationListPage'
import { ExperienceEditorPage } from './pages/ExperienceEditorPage'
import { ExperienceListPage } from './pages/ExperienceListPage'
import { ProfilePage } from './pages/ProfilePage'
import { SignInPage } from './pages/SignInPage'

Expand Down Expand Up @@ -97,6 +99,12 @@ const router = createBrowserRouter([
path: 'education/:educationId/edit',
element: <EducationEditorPage />,
},
{ path: 'experience', element: <ExperienceListPage /> },
{ path: 'experience/new', element: <ExperienceEditorPage /> },
{
path: 'experience/:experienceId/edit',
element: <ExperienceEditorPage />,
},
],
},
],
Expand Down
8 changes: 4 additions & 4 deletions app/src/AppRouter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { RouterProvider } from 'react-router/dom'
import { describe, expect, it } from 'vitest'

describe('router foundation', () => {
it('uses an in-memory router to honor a bookmarkable route', async () => {
it('uses an in-memory router to honor a bookmarkable nested route', async () => {
const memoryRouter = createMemoryRouter(
[{ path: '/profile/education', element: <h1>Education</h1> }],
{ initialEntries: ['/profile/education'] },
[{ path: '/profile/experience/new', element: <h1>Add experience</h1> }],
{ initialEntries: ['/profile/experience/new'] },
)
render(<RouterProvider router={memoryRouter} />)
expect(
await screen.findByRole('heading', { name: 'Education' }),
await screen.findByRole('heading', { name: 'Add experience' }),
).toBeInTheDocument()
})
})
16 changes: 15 additions & 1 deletion app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ label {
font-size: 0.9rem;
}

input {
input,
select,
textarea {
font: inherit;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
Expand All @@ -65,6 +67,18 @@ input {
color: var(--text);
}

textarea {
min-height: 7rem;
resize: vertical;
}

nav {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0 0 1.5rem;
}

button {
font: inherit;
padding: 0.5rem 0.9rem;
Expand Down
57 changes: 57 additions & 0 deletions app/src/lib/experienceRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { supabase } from './supabaseClient'
import type { ExperienceKind, WorkExperience } from './profileTypes'

export type ExperienceInput = {
experience_kind: ExperienceKind
organization: string
role: string
location: string | null
start_year: number
start_month: number | null
end_year: number | null
end_month: number | null
is_current: boolean
description: string | null
}

const columns =
'id, user_id, experience_kind, organization, role, location, start_year, start_month, end_year, end_month, is_current, description'

export const experienceRepository = {
async list(userId: string) {
return supabase
.from('work_experience')
.select(columns)
.eq('user_id', userId)
.order('is_current', { ascending: false })
.order('start_year', { ascending: false })
.order('start_month', { ascending: false })
.order('created_at', { ascending: false })
.returns<WorkExperience[]>()
},
async get(id: string) {
return supabase
.from('work_experience')
.select(columns)
.eq('id', id)
.maybeSingle<WorkExperience>()
},
async create(userId: string, input: ExperienceInput) {
return supabase
.from('work_experience')
.insert({ user_id: userId, ...input })
.select(columns)
.maybeSingle<WorkExperience>()
},
async update(id: string, input: ExperienceInput) {
return supabase
.from('work_experience')
.update(input)
.eq('id', id)
.select(columns)
.maybeSingle<WorkExperience>()
},
async remove(id: string) {
return supabase.from('work_experience').delete().eq('id', id).select('id')
},
}
38 changes: 31 additions & 7 deletions app/src/lib/profileCompleteness.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
completenessVersion,
evaluateEducationSlice,
evaluateExperienceSlice,
} from './profileCompleteness'

describe('education completeness slice', () => {
describe('experience completeness slice', () => {
it('is versioned and exposes named missing actions without a percentage', () => {
const checks = evaluateEducationSlice(
const checks = evaluateExperienceSlice(
[],
[
{
Expand All @@ -20,8 +20,9 @@ describe('education completeness slice', () => {
reviewed_content_revision: null,
},
],
[],
)
expect(completenessVersion).toBe('profile-completeness/v2-slice-education')
expect(completenessVersion).toBe('profile-completeness/v2-slice-experience')
expect(
checks.find((check) => check.id === 'primary_education'),
).toMatchObject({
Expand All @@ -35,12 +36,12 @@ describe('education completeness slice', () => {

it('never treats missing, null, or stale review state as current', () => {
expect(
evaluateEducationSlice([], []).find(
evaluateExperienceSlice([], [], []).find(
(check) => check.id === 'basic_profile_review',
)?.outcome,
).toBe('unconfirmed')
expect(
evaluateEducationSlice(
evaluateExperienceSlice(
[],
[
{
Expand All @@ -49,10 +50,11 @@ describe('education completeness slice', () => {
reviewed_content_revision: null,
},
],
[],
).find((check) => check.id === 'basic_profile_review')?.outcome,
).toBe('unconfirmed')
expect(
evaluateEducationSlice(
evaluateExperienceSlice(
[],
[
{
Expand All @@ -61,7 +63,29 @@ describe('education completeness slice', () => {
reviewed_content_revision: 1,
},
],
[],
).find((check) => check.id === 'basic_profile_review')?.outcome,
).toBe('unconfirmed')
})

it('reports an empty reviewed experience section as present and deferred work as neutral', () => {
const checks = evaluateExperienceSlice(
[],
[
{
section_key: 'experience',
content_revision: 0,
reviewed_content_revision: 0,
},
],
[],
)
expect(
checks.find((check) => check.id === 'experience_review'),
).toMatchObject({ availability: 'implemented', outcome: 'present' })
expect(checks.find((check) => check.id === 'projects')).toMatchObject({
availability: 'not_implemented',
outcome: null,
})
})
})
53 changes: 48 additions & 5 deletions app/src/lib/profileCompleteness.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
import type { EducationEntry, SectionState } from './profileTypes'
import type {
EducationEntry,
SectionState,
WorkExperience,
} from './profileTypes'

export const completenessVersion =
'profile-completeness/v2-slice-education' as const
'profile-completeness/v2-slice-experience' as const
export type CompletenessOutcome = 'present' | 'missing' | 'unconfirmed'
export type CompletenessCheckId =
| 'basic_profile_review'
| 'primary_education'
| 'degree_year'
| 'graduation_timing'
| 'education_review'
| 'experience_review'
| 'projects'
| 'skills'
| 'languages'
| 'preferences'
| 'eligibility'
| 'targets'

export interface CompletenessCheck {
id: CompletenessCheckId
outcome: CompletenessOutcome
availability: 'implemented' | 'not_implemented'
outcome: CompletenessOutcome | null
action: string
href: string
href: string | null
}

function reviewed(state: SectionState[], section: SectionState['section_key']) {
Expand All @@ -26,32 +38,41 @@ function reviewed(state: SectionState[], section: SectionState['section_key']) {
)
}

export function evaluateEducationSlice(
export function evaluateExperienceSlice(
entries: EducationEntry[],
state: SectionState[],
experience: WorkExperience[],
): CompletenessCheck[] {
// Projects are deferred, so experience rows do not yet drive the final
// experience-or-project evidence-strength check. Keep the evaluator input
// explicit for the next slice rather than duplicating overview logic.
void experience
const primary = entries.find((entry) => entry.is_primary)
return [
{
id: 'basic_profile_review',
availability: 'implemented',
outcome: reviewed(state, 'basic_profile') ? 'present' : 'unconfirmed',
action: 'Review basic profile',
href: '/profile/basic',
},
{
id: 'primary_education',
availability: 'implemented',
outcome: primary ? 'present' : 'missing',
action: 'Add or select primary education',
href: '/profile/education',
},
{
id: 'degree_year',
availability: 'implemented',
outcome: primary?.degree_year ? 'present' : 'missing',
action: 'Add degree year',
href: '/profile/education',
},
{
id: 'graduation_timing',
availability: 'implemented',
outcome:
primary?.expected_graduation_month && primary?.expected_graduation_year
? 'present'
Expand All @@ -61,9 +82,31 @@ export function evaluateEducationSlice(
},
{
id: 'education_review',
availability: 'implemented',
outcome: reviewed(state, 'education') ? 'present' : 'unconfirmed',
action: 'Review education',
href: '/profile/education',
},
{
id: 'experience_review',
availability: 'implemented',
outcome: reviewed(state, 'experience') ? 'present' : 'unconfirmed',
action: 'Review experience',
href: '/profile/experience',
},
...[
['projects', 'Projects are not yet available.'],
['skills', 'Skills are not yet available.'],
['languages', 'Languages are not yet available.'],
['preferences', 'Preferences are not yet available.'],
['eligibility', 'Work eligibility is not yet available.'],
['targets', 'Targets are not yet available.'],
].map(([id, action]) => ({
id: id as CompletenessCheckId,
availability: 'not_implemented' as const,
outcome: null,
action,
href: null,
})),
]
}
8 changes: 4 additions & 4 deletions app/src/lib/profileReviewRepository.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { supabase } from './supabaseClient'
import type { SectionState } from './profileTypes'
import type { SectionKey, SectionState } from './profileTypes'

export function reviewStatus(
state: SectionState[] | null,
section: SectionState['section_key'],
section: SectionKey,
): 'current' | 'stale' | 'not_reviewed' {
const item = state?.find((entry) => entry.section_key === section)
if (!item || item.reviewed_content_revision === null) return 'not_reviewed'
Expand All @@ -28,7 +28,7 @@ export const profileReviewRepository = {
if (reviews.error) return { data: null, error: reviews.error }
return {
data: (revisions.data ?? []).map((revision) => ({
section_key: revision.section_key as SectionState['section_key'],
section_key: revision.section_key as SectionKey,
content_revision: revision.content_revision,
reviewed_content_revision:
reviews.data?.find(
Expand All @@ -38,7 +38,7 @@ export const profileReviewRepository = {
error: null,
}
},
async review(sectionKey: SectionState['section_key']) {
async review(sectionKey: SectionKey) {
return supabase.rpc('review_profile_section', {
requested_section_key: sectionKey,
})
Expand Down
Loading