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
4 changes: 2 additions & 2 deletions docs/reference/modernization-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,8 @@ Survey scope: Go 1.18 through 1.26. Re-run Part A on a 1.27+ toolchain bump.
| 5 | `✓ shipped #1589` | 3a#6 | `refactor(frontend): Object.groupBy for LockGroup membersByGroup` (2 sites; needs #1) |
| 6 | `skipped (retired — FP)` | 3a#5 | `refactor(frontend): error cause chaining on catch-rethrow` — retired, see Retired table (0 wrap-rethrow-without-cause sites) |
| 7 | `✓ shipped #1591` | 3a#9 | `refactor(frontend): satisfies on config/route maps` (targeted) |
| 8 | `PR open #1592` (graph.ts slice) | 3a#8 | `refactor(frontend): readonly on cache/store/API types` (type-level, large surface; split per-shape — `graph.ts` slice = #1592, `app-types.ts` composites still open) |
| 9 | | 3a#7 | `refactor(frontend): React Query queryOptions + skipToken` (HIGH value, largest/most semantic — split as needed) |
| 8 | `✓ shipped #1592` (graph.ts) `+ app-types` | 3a#8 | `refactor(frontend): readonly on cache/store/API types` — done in 2 slices: `graph.ts` = #1592, `app-types.ts` composites (Camper/BunkRequest/Constraint/SolverRun/DragItem/BunkWithCampers) = this PR. Both ~0 cascade (graph 0, app-types 2 trivial: `buildCamperRows` param widen + `RequestForm` useState copy) |
| 9 | `next` | 3a#7 | `refactor(frontend): React Query queryOptions + skipToken` (HIGH value, largest/most semantic — split as needed) |

Ranking is easiest→hardest with the unblocker (#1) first per Part B's tie-breaker. The HIGH-impact-but-large rows (error cause, React Query) sit later because they carry test/semantic surface, not because they're low value.

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/RequestForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ interface RequestFormProps {
export default function RequestForm({ campers, constraint, onSubmit, onCancel }: RequestFormProps) {
const viewingYear = useYear()
const [type, setType] = useState<ConstraintType>(constraint?.type ?? 'pair_together')
const [selectedCampers, setSelectedCampers] = useState<string[]>(constraint?.campers ?? [])
// Local mutable working copy; the source `constraint.campers` is readonly.
const [selectedCampers, setSelectedCampers] = useState<string[]>([...(constraint?.campers ?? [])])
const [metadata, setMetadata] = useState<Record<string, unknown>>(constraint?.metadata ?? {})

const handleSubmit = (e: FormEvent) => {
Expand Down
317 changes: 162 additions & 155 deletions frontend/src/types/app-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
/**
* Application-specific types for UI components and transformations
* These types compose, extend, or alias the auto-generated PocketBase types
*
* Hand-written cache/API shapes carry `readonly` fields (and `readonly T[]` /
* `Readonly<Record>` on collections): these flow through the React Query cache
* and dozens of components, so the compiler forbids accidental in-place
* mutation of a shared instance. Defensive copies (`[...arr]`, `.map(...)`) are
* unaffected; the generated PB aliases (`Session`, `Bunk`, `SavedScenario`)
* are intentionally left mutable — their immutability belongs in codegen.
*/

import type {
Expand All @@ -15,85 +22,85 @@ import type {
* Used throughout the frontend for displaying and managing camper information
*/
export interface Camper {
id: string // Composite ID: "person_id:session_id"
attendee_id?: string // PocketBase attendee ID if needed
name: string
age: number
grade: number
gender: 'M' | 'F' | 'NB'
session_cm_id: number // CampMinder session ID
assigned_bunk_cm_id?: number // CampMinder bunk ID
assigned_bunk?: string // PocketBase bunk ID relation
person_cm_id: number // CampMinder person ID
created: string
updated: string
readonly id: string // Composite ID: "person_id:session_id"
readonly attendee_id?: string // PocketBase attendee ID if needed
readonly name: string
readonly age: number
readonly grade: number
readonly gender: 'M' | 'F' | 'NB'
readonly session_cm_id: number // CampMinder session ID
readonly assigned_bunk_cm_id?: number // CampMinder bunk ID
readonly assigned_bunk?: string // PocketBase bunk ID relation
readonly person_cm_id: number // CampMinder person ID
readonly created: string
readonly updated: string
// Additional fields from CampMinder
first_name?: string
last_name?: string
preferred_name?: string
birthdate?: string
years_at_camp?: number
last_year_attended?: number
school?: string
pronouns?: string
email?: string
parent_email?: string
phone?: string
tags?: string[]
socialize_with_best?: string
socialize_with_best_explain?: string
readonly first_name?: string
readonly last_name?: string
readonly preferred_name?: string
readonly birthdate?: string
readonly years_at_camp?: number
readonly last_year_attended?: number
readonly school?: string
readonly pronouns?: string
readonly email?: string
readonly parent_email?: string
readonly phone?: string
readonly tags?: readonly string[]
readonly socialize_with_best?: string
readonly socialize_with_best_explain?: string
// Additional V2 fields
lead_date?: string
tshirt_size?: string
camp_grade_name?: string
school_grade_name?: string
readonly lead_date?: string
readonly tshirt_size?: string
readonly camp_grade_name?: string
readonly school_grade_name?: string
// V2 Schema fields
gender_identity_id?: number
gender_identity_name?: string
gender_identity_write_in?: string
gender_pronoun_id?: number
gender_pronoun_name?: string
gender_pronoun_write_in?: string
household_id?: number
primary_household_id?: string
alternate_household_id?: string
external_id?: string
primary_email?: string
secondary_email?: string
bunking_requests?: Array<{
id?: string
type?: string
requested_person_id?: number
is_first_requested?: boolean
[key: string]: unknown
}> // Array of bunking requests
custom_fields?: Record<string, unknown>
attendee_status?: string
attendee_created?: string
attendee_updated?: string
last_updated_utc?: string
readonly gender_identity_id?: number
readonly gender_identity_name?: string
readonly gender_identity_write_in?: string
readonly gender_pronoun_id?: number
readonly gender_pronoun_name?: string
readonly gender_pronoun_write_in?: string
readonly household_id?: number
readonly primary_household_id?: string
readonly alternate_household_id?: string
readonly external_id?: string
readonly primary_email?: string
readonly secondary_email?: string
readonly bunking_requests?: readonly {
readonly id?: string
readonly type?: string
readonly requested_person_id?: number
readonly is_first_requested?: boolean
readonly [key: string]: unknown
}[] // Array of bunking requests
readonly custom_fields?: Readonly<Record<string, unknown>>
readonly attendee_status?: string
readonly attendee_created?: string
readonly attendee_updated?: string
readonly last_updated_utc?: string
// Expanded fields
expand?: {
session?: CampSessionsResponse | null
assigned_bunk?: BunksResponse | null
tags?: Array<{
id: string
name: string
category: string | null
is_seasonal?: boolean
}>
person_tag_assignments?: Array<{
id: string
expand?: {
tag?: {
id: string
name: string
category: string | null
is_seasonal?: boolean
readonly expand?: {
readonly session?: CampSessionsResponse | null
readonly assigned_bunk?: BunksResponse | null
readonly tags?: readonly {
readonly id: string
readonly name: string
readonly category: string | null
readonly is_seasonal?: boolean
}[]
readonly person_tag_assignments?: readonly {
readonly id: string
readonly expand?: {
readonly tag?: {
readonly id: string
readonly name: string
readonly category: string | null
readonly is_seasonal?: boolean
}
}
}>
attendee?: AttendeesResponse
}[]
readonly attendee?: AttendeesResponse
}
}

Expand All @@ -115,66 +122,66 @@ export type Session = CampSessionsResponse
export type Bunk = BunksResponse

export interface BunkRequest {
id: string
requester_id: number // CampMinder person ID
requestee_id?: number | null // CampMinder person ID
request_type: 'bunk_with' | 'not_bunk_with' | 'age_preference'
is_first_requested?: boolean
year: number
session_id: number // CampMinder session ID
status: 'resolved' | 'pending' | 'declined'
original_text?: string
confidence_score?: number
parse_notes?: string
socialize_explain?: string
source_field?: string // CSV field this came from (bunk_with, not_bunk_with, manual, etc.)
is_reciprocal?: boolean
manual_notes?: string
readonly id: string
readonly requester_id: number // CampMinder person ID
readonly requestee_id?: number | null // CampMinder person ID
readonly request_type: 'bunk_with' | 'not_bunk_with' | 'age_preference'
readonly is_first_requested?: boolean
readonly year: number
readonly session_id: number // CampMinder session ID
readonly status: 'resolved' | 'pending' | 'declined'
readonly original_text?: string
readonly confidence_score?: number
readonly parse_notes?: string
readonly socialize_explain?: string
readonly source_field?: string // CSV field this came from (bunk_with, not_bunk_with, manual, etc.)
readonly is_reciprocal?: boolean
readonly manual_notes?: string
// Age preference specific
age_preference_target?: string // 'older' or 'younger'
metadata?: Record<string, unknown> // JSON metadata field
ai_reasoning?: {
csv_source_field?: string
[key: string]: unknown
readonly age_preference_target?: string // 'older' or 'younger'
readonly metadata?: Readonly<Record<string, unknown>> // JSON metadata field
readonly ai_reasoning?: {
readonly csv_source_field?: string
readonly [key: string]: unknown
}
// Additional fields from DB
confidence_level?: string
keywords_found?: Record<string, unknown>
can_be_dropped?: boolean
is_placeholder?: boolean
requires_manual_review?: boolean
manual_review_reason?: string
was_dropped_for_spread?: boolean
readonly confidence_level?: string
readonly keywords_found?: Readonly<Record<string, unknown>>
readonly can_be_dropped?: boolean
readonly is_placeholder?: boolean
readonly requires_manual_review?: boolean
readonly manual_review_reason?: string
readonly was_dropped_for_spread?: boolean
// Merge tracking — empty string or undefined means not merged; non-empty is the PB record ID it was merged into
merged_into?: string
created: string
updated: string
readonly merged_into?: string
readonly created: string
readonly updated: string
}

export type ConstraintType = 'pair_together' | 'keep_apart' | 'age_preference' | 'bunk_preference'

export interface Constraint {
id: string
description: string // Required in DB
session_id: number // CampMinder session ID
scope?: 'global' | 'single' | 'pair'
severity?: 'hard' | 'soft'
single_camper_id?: number // CampMinder person ID for single constraints
pair_camper1_id?: number // CampMinder person ID for pair constraints
pair_camper2_id?: number // CampMinder person ID for pair constraints
year: number
created: string
updated: string
readonly id: string
readonly description: string // Required in DB
readonly session_id: number // CampMinder session ID
readonly scope?: 'global' | 'single' | 'pair'
readonly severity?: 'hard' | 'soft'
readonly single_camper_id?: number // CampMinder person ID for single constraints
readonly pair_camper1_id?: number // CampMinder person ID for pair constraints
readonly pair_camper2_id?: number // CampMinder person ID for pair constraints
readonly year: number
readonly created: string
readonly updated: string
// Legacy fields for backward compatibility
constraint_type?: string
type?: ConstraintType
session?: string
campers?: string[]
metadata?: Record<string, unknown>
readonly constraint_type?: string
readonly type?: ConstraintType
readonly session?: string
readonly campers?: readonly string[]
readonly metadata?: Readonly<Record<string, unknown>>
// Expanded fields
expand?: {
session?: CampSessionsResponse
campers?: Camper[]
readonly expand?: {
readonly session?: CampSessionsResponse
readonly campers?: readonly Camper[]
}
}

Expand All @@ -190,48 +197,48 @@ export type SavedScenario = SavedScenariosResponse<unknown, { session?: CampSess
* Kept in app-types because it represents a solver API response shape.
*/
export interface SolverRun {
id: string
session: string
status: 'pending' | 'running' | 'completed' | 'failed'
constraints_snapshot?: Record<string, unknown>
locked_bunks?: string[]
results?: {
assignments: Array<{
camper_id: string
bunk_id: string
}>
stats: {
total_campers: number
assigned_campers: number
satisfied_constraints: number
total_constraints: number
solve_time_ms: number
readonly id: string
readonly session: string
readonly status: 'pending' | 'running' | 'completed' | 'failed'
readonly constraints_snapshot?: Readonly<Record<string, unknown>>
readonly locked_bunks?: readonly string[]
readonly results?: {
readonly assignments: readonly {
readonly camper_id: string
readonly bunk_id: string
}[]
readonly stats: {
readonly total_campers: number
readonly assigned_campers: number
readonly satisfied_constraints: number
readonly total_constraints: number
readonly solve_time_ms: number
// Request-based stats (newer API)
satisfied_request_count?: number
total_requests?: number
request_validation?: {
impossible_requests: number
affected_campers: number
readonly satisfied_request_count?: number
readonly total_requests?: number
readonly request_validation?: {
readonly impossible_requests: number
readonly affected_campers: number
}
}
}
error_message?: string
started_at?: string
completed_at?: string
created: string
updated: string
readonly error_message?: string
readonly started_at?: string
readonly completed_at?: string
readonly created: string
readonly updated: string
}

// UI-specific types
export interface DragItem {
id: string
type: 'camper'
camper: Camper
sourceBunkId?: string
readonly id: string
readonly type: 'camper'
readonly camper: Camper
readonly sourceBunkId?: string
}

export interface BunkWithCampers extends Bunk {
campers: Camper[]
occupancy: number
utilization: number
readonly campers: readonly Camper[]
readonly occupancy: number
readonly utilization: number
}
Loading
Loading