diff --git a/docs/reference/modernization-backlog.md b/docs/reference/modernization-backlog.md index 4a0b82670..c57c6ab3d 100644 --- a/docs/reference/modernization-backlog.md +++ b/docs/reference/modernization-backlog.md @@ -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. diff --git a/frontend/src/components/RequestForm.tsx b/frontend/src/components/RequestForm.tsx index 565ba183a..d2d370828 100644 --- a/frontend/src/components/RequestForm.tsx +++ b/frontend/src/components/RequestForm.tsx @@ -16,7 +16,8 @@ interface RequestFormProps { export default function RequestForm({ campers, constraint, onSubmit, onCancel }: RequestFormProps) { const viewingYear = useYear() const [type, setType] = useState(constraint?.type ?? 'pair_together') - const [selectedCampers, setSelectedCampers] = useState(constraint?.campers ?? []) + // Local mutable working copy; the source `constraint.campers` is readonly. + const [selectedCampers, setSelectedCampers] = useState([...(constraint?.campers ?? [])]) const [metadata, setMetadata] = useState>(constraint?.metadata ?? {}) const handleSubmit = (e: FormEvent) => { diff --git a/frontend/src/types/app-types.ts b/frontend/src/types/app-types.ts index 82617a5d0..573b46cc8 100644 --- a/frontend/src/types/app-types.ts +++ b/frontend/src/types/app-types.ts @@ -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` 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 { @@ -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 - 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> + 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 } } @@ -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 // JSON metadata field - ai_reasoning?: { - csv_source_field?: string - [key: string]: unknown + readonly age_preference_target?: string // 'older' or 'younger' + readonly metadata?: Readonly> // 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 - 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> + 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 + readonly constraint_type?: string + readonly type?: ConstraintType + readonly session?: string + readonly campers?: readonly string[] + readonly metadata?: Readonly> // Expanded fields - expand?: { - session?: CampSessionsResponse - campers?: Camper[] + readonly expand?: { + readonly session?: CampSessionsResponse + readonly campers?: readonly Camper[] } } @@ -190,48 +197,48 @@ export type SavedScenario = SavedScenariosResponse - 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> + 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 } diff --git a/frontend/src/utils/csvExportHelpers.ts b/frontend/src/utils/csvExportHelpers.ts index a2a3e5797..49aa6b6fc 100644 --- a/frontend/src/utils/csvExportHelpers.ts +++ b/frontend/src/utils/csvExportHelpers.ts @@ -70,7 +70,7 @@ export interface BuildCamperRowsOptions { * @returns - Array of string[] where each inner array is one CSV data row */ export function buildCamperRows( - campers: Camper[], + campers: readonly Camper[], sessions: Session[], options: BuildCamperRowsOptions = {} ): string[][] {