From e41e0e2a99be402ff30d942baf77dc289c4fcf0f Mon Sep 17 00:00:00 2001 From: rexong Date: Tue, 16 Jun 2026 00:44:34 +0800 Subject: [PATCH 01/94] add plans for student workspace and commercial-licensing readiness --- .gitignore | 3 + .plans/21-commercial-licensing-readiness.md | 82 ++++++ .plans/22-student-workspace.md | 262 ++++++++++++++++++++ .plans/README.md | 2 + 4 files changed, 349 insertions(+) create mode 100644 .plans/21-commercial-licensing-readiness.md create mode 100644 .plans/22-student-workspace.md diff --git a/.gitignore b/.gitignore index ef6067824f2..bb6cd43a6c6 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ node_modules/ *.log .env* !.env.example + +# Auto Claude data directory +.auto-claude/ diff --git a/.plans/21-commercial-licensing-readiness.md b/.plans/21-commercial-licensing-readiness.md new file mode 100644 index 00000000000..a28253697b5 --- /dev/null +++ b/.plans/21-commercial-licensing-readiness.md @@ -0,0 +1,82 @@ +# Commercial Licensing Readiness — TODOs Before Shipping + +> **Not legal advice.** This is an engineering checklist derived from auditing the +> repo's `LICENSE`, the vendored code in `.repos/`, all declared dependencies, and +> `assets/`. Have counsel confirm before commercial release. + +## Summary + +The codebase is a fork of **T3 Code** (© T3 Tools Inc, **MIT**). MIT permits commercial +use, modification, and resale **provided the copyright notice + license text are +retained**. The dependency tree is overwhelmingly permissive (MIT / Apache-2.0 / BSD / +ISC) with **no copyleft (GPL/AGPL/LGPL/SSPL) and no non-commercial licenses**. + +Three categories of work are required before a commercial ship: **(1) rebrand**, +**(2) third-party terms & paid-service review**, **(3) attribution hygiene**. + +--- + +## 1. Rebrand — remove T3 trademark/brand assets (REQUIRED) + +MIT grants *copyright*, **not trademark**. The "T3 Code" / "T3 Tools" name and logos +cannot ship under our product. + +- [ ] Replace/remove brand assets in `assets/prod/`, `assets/dev/`, `assets/nightly/` + (`logo.svg`, `t3-black-*` favicons/icons, `black-*` / `blueprint-*` app icons). +- [ ] Find & replace hardcoded "T3 Code" / "T3 Tools" / "t3" product strings across the + codebase (app titles, window/menu labels, `package.json` names like `@t3tools/*`, + installer/winget/brew/AUR identifiers in `README.md`, update-server URLs). +- [ ] Replace product URLs/domains and Discord/support links. +- [ ] Confirm desktop app metadata (electron-builder appId, product name, publisher) is + rebranded so releases don't ship as "T3 Code". + +## 2. Third-party terms & paid services (REQUIRED REVIEW) + +- [ ] **`@anthropic-ai/claude-agent-sdk`** — the only *proprietary*-licensed dep + (`SEE LICENSE IN README`). Governed by **Anthropic Commercial ToS**; requires paid + API/model access. Confirm our use complies and budget for metered API cost. +- [ ] **Bundled coding-agent CLIs** (Codex, Claude Code, Cursor, OpenCode) — the app is a + GUI wrapper. Each has its **own ToS** that end-users must satisfy. Decide what we + bundle vs. require users to install/authenticate themselves, and document it. +- [ ] **Clerk (`@clerk/*`)** — SDK is MIT (fine to embed), but the **hosted service + requires a paid plan beyond the free tier** at commercial scale. Confirm plan/pricing + or swap auth provider. +- [ ] Confirm we use base **`uniwind`** (MIT) only — **NOT** the separate proprietary + `uniwind-pro` paid package. + +## 3. Attribution & license hygiene (REQUIRED) + +- [ ] **Retain** the existing MIT `LICENSE` (© T3 Tools Inc) and its copyright notice in + distributed artifacts — required by MIT even after rebranding. +- [ ] Generate a `NOTICE` / third-party-licenses file aggregating dependency licenses + (MIT/Apache-2.0/BSD/ISC) and ship it with the app (e.g. `license-checker` / + `oss-attribution-generator` against the installed tree). +- [ ] Apache-2.0 deps (e.g. `@pierre/diffs`, Playwright, vendored `.repos/alchemy-effect`) + require preserving any `NOTICE` content and the license text. +- [ ] Vendored reference code in `.repos/` (`effect-smol` MIT, `alchemy-effect` + Apache-2.0): confirm these are dev-only references and **not bundled** into shipped + artifacts; if bundled, include their LICENSE files. +- [ ] Fonts (DM Sans, JetBrains Mono via fontsource) are **SIL OFL** — commercial-OK, but + include the OFL text if font files are redistributed. + +## 4. Final verification (REQUIRED) + +- [ ] Run a full dependency license scan against the **installed** tree (no `node_modules` + present at audit time — declared-deps analysis only). Confirm zero + GPL/AGPL/LGPL/SSPL/non-commercial licenses reach the shipped bundle. +- [ ] Legal sign-off on the above before public/commercial release. + +--- + +## License facts (reference) + +| Component | License | Commercial | Note | +|---|---|---|---| +| This repo (T3 Code) | MIT (© T3 Tools Inc) | ✅ | Retain notice; trademark NOT granted | +| `.repos/effect-smol` | MIT | ✅ | Reference checkout | +| `.repos/alchemy-effect` | Apache-2.0 | ✅ | Preserve NOTICE | +| Dependencies (135 declared) | MIT / Apache-2.0 / BSD / ISC | ✅ | No copyleft found | +| `@anthropic-ai/claude-agent-sdk` | Proprietary | ⚠️ | Anthropic Commercial ToS + paid API | +| Clerk service | MIT SDK / SaaS | ⚠️ | Paid plan at scale | +| `uniwind` (base) | MIT | ✅ | Avoid proprietary `uniwind-pro` | +| Fonts (DM Sans, JetBrains Mono) | SIL OFL | ✅ | Include OFL if redistributed | diff --git a/.plans/22-student-workspace.md b/.plans/22-student-workspace.md new file mode 100644 index 00000000000..c51d734ff3a --- /dev/null +++ b/.plans/22-student-workspace.md @@ -0,0 +1,262 @@ +# 22 — Student Workspace (Iteration B: Additive Roster Module) + +## Goal + +Give an individual tutor a place to store and manage their students inside the +Atlas harness: name, contact, parents, subjects, school, and address. This is +the first tutoring-domain feature on top of the forked t3code/codex app. + +This plan covers **Iteration B** only: an additive, self-contained "Students" +module that does **not** touch the existing chat/coding-agent functionality. + +## Strategic context (the three iterations) + +- **B — now (this plan):** Students as an isolated module. Store + display + + CRUD. Learn the codebase's persistence / IPC / UI patterns by copying proven + examples. No interaction with the AI chat yet. +- **C — next:** Deep integration. Select a student → the AI chat becomes aware + of their subjects/school/history; student data flows into prompts. A "Chat + about this student" button bridges the dedicated Students page into the + existing chat. The seam is designed for in B but not built. +- **A — eventual:** Strip/repurpose the coding-agent internals entirely around + tutoring. Out of scope for a long time. + +Operative constraint throughout: **ship fast**. Every decision below was made to +favor speed-to-working-feature over architectural purity, while leaving the C +seam clean. + +## Decisions log (resolved during requirements grilling) + +| # | Decision | Choice | +|---|----------|--------| +| 1 | App relationship | **B** — additive isolated module (C next, A eventual) | +| 2 | Persistence layer | **Desktop local JSON file** (`students.json`), copy `DesktopSavedEnvironments` pattern. No server, no DB, no event-sourcing. | +| 3a | Parents | **0-many** array of `{ name, relationship, phone }` | +| 3b | Subjects | **Array of free-text strings** (no controlled vocab) | +| 3c | School | **Single free-text string** (not its own entity) | +| 3d | Contact format | Structured phone (country + number), free text otherwise | +| 4 | Phone | **Country dropdown (SG default / MY / CN) + raw number string.** No libphonenumber. Deep-linkable to WhatsApp/Telegram in C. Applies to student contact and each parent. | +| 5 | Address | **Structured, Singapore-focused**, Google Maps-linkable | +| 5a | Address scope | **Student-level only** (not per-parent) | +| 5b | Address required-ness | **Block optional; if any field filled, postal code (6 digits) required.** Postal-code format validated only when present. | +| 6 | UI surface | **A** — dedicated "Students" top-level route + sidebar nav entry. Fully isolated from chat. | +| 7a | CRUD scope | **Full CRUD** (create/list/view/edit/delete). **No search/pagination/sort beyond alphabetical** this iteration. | +| 7b | IPC granularity | **Coarse get-all / set-all** (`getStudents`/`setStudents`), exactly like `DesktopSavedEnvironments` registry. Last-write-wins is fine for a single user. | +| 8a | Required fields | **Only `name`.** Empty parent rows auto-dropped on save. | +| 8b | ID generation | `crypto.randomUUID()` in the renderer at create time. | +| 8c | Delete | **Confirm dialog** before delete. | +| 8d | Duplicate names | **Allowed** — `id` is the key. | +| 8e | Empty state | Friendly "No students yet — add your first" + New button. | + +Scale assumption: ~10-50 students per tutor, single machine, single user. + +## Data model + +New contracts file: `packages/contracts/src/students.ts`. Use `effect/Schema` +(the codebase's Zod equivalent) to mirror existing contract style. + +```ts +// Branded id, like EnvironmentId in baseSchemas.ts +export const StudentId = Schema.String.pipe(Schema.brand("StudentId")); + +export const PhoneCountry = Schema.Literal("SG", "MY", "CN"); + +export const PhoneNumber = Schema.Struct({ + country: PhoneCountry, // dropdown, defaults to "SG" + number: Schema.String, // raw, unvalidated digits/format +}); + +export const Parent = Schema.Struct({ + name: Schema.String, + relationship: Schema.String, // free text: "Mother", "Father", "Guardian" + phone: Schema.optionalKey(PhoneNumber), +}); + +// Singapore-structured address. Whole block optional; postal code is the +// gate (6 digits) when any field is present. +export const SingaporeAddress = Schema.Struct({ + block: Schema.optionalKey(Schema.String), // "123A" + street: Schema.optionalKey(Schema.String), // "Ang Mo Kio Avenue 6" + building: Schema.optionalKey(Schema.String), // condo/building name + unit: Schema.optionalKey(Schema.String), // "#12-34" + postalCode: Schema.optionalKey(Schema.String),// 6 digits +}); + +export const Student = Schema.Struct({ + id: StudentId, + name: Schema.String, // required (non-empty) + contact: Schema.optionalKey(PhoneNumber), + parents: Schema.Array(Parent), // 0-many + subjects: Schema.Array(Schema.String), // free-text tags + school: Schema.optionalKey(Schema.String), + address: Schema.optionalKey(SingaporeAddress), + createdAt: Schema.String, // ISO string + updatedAt: Schema.String, // ISO string +}); +``` + +Storage document (mirrors `SavedEnvironmentRegistryDocument`): + +```ts +export const StudentRegistryDocument = Schema.Struct({ + version: Schema.optionalKey(Schema.Number), // start at 1 + students: Schema.optionalKey(Schema.Array(Student)), +}); +``` + +Export all of the above from `packages/contracts/src/index.ts`. + +### Validation rules (enforced in the form, not the schema) + +- `name` non-empty → required. +- Postal code: if **any** address field is non-empty, `postalCode` must match + `/^\d{6}$/`. Otherwise address may be fully empty. +- Parent rows that are entirely empty (no name AND no phone number) are dropped + before save. +- No phone format validation (raw string). No name uniqueness check. + +## Architecture & data flow + +Same shape as saved environments. The renderer cannot touch the filesystem; it +calls `window.desktopBridge` methods handled in the Electron main process, which +reads/writes a JSON file under the app state dir. + +``` +Web renderer (React route) + │ localApi.getStudents() / setStudents(list) + ▼ +preload.ts → ipcRenderer.invoke(GET/SET_STUDENTS_CHANNEL) + ▼ +DesktopIpcHandlers.ts → methods/students.ts + ▼ +DesktopStudents service (Effect layer) + ▼ +~/.t3code state dir / students.json (atomic temp-write + rename) +``` + +File location: alongside `saved-environments.json` in the same state dir +(`DesktopEnvironment.ts:184`), i.e. `studentRegistryPath = path.join(stateDir, "students.json")`. + +## Work breakdown (file-by-file) + +### 1. Contracts — `packages/contracts/src/` +- **New** `students.ts` — schemas above. +- **Edit** `index.ts` — export `students.ts`. +- **Edit** `ipc.ts` — add to `DesktopBridge` interface: + ```ts + getStudents: () => Promise; + setStudents: (students: readonly Student[]) => Promise; + ``` + +### 2. Desktop main — persistence +- **Edit** `apps/desktop/src/app/DesktopEnvironment.ts` + - Add `readonly studentRegistryPath: string;` to the interface (near line 49). + - Set `studentRegistryPath: path.join(stateDir, "students.json")` (near line 184). +- **New** `apps/desktop/src/settings/DesktopStudents.ts` + - Copy `DesktopSavedEnvironments.ts` **minus all secret/encryption/SafeStorage + code** (students have no secrets). Keep: `fromLenientJson` decode, atomic + `writeRegistryDocument` (temp + rename), `readRegistryDocument` with + graceful fallback to empty, `Context.Service`, `layer`, and `layerTest`. + - Shape: + ```ts + getRegistry: Effect.Effect; + setRegistry: (students: readonly Student[]) => + Effect.Effect; + ``` + +### 3. Desktop main — IPC +- **Edit** `apps/desktop/src/ipc/channels.ts` + - `export const GET_STUDENTS_CHANNEL = "desktop:get-students";` + - `export const SET_STUDENTS_CHANNEL = "desktop:set-students";` +- **New** `apps/desktop/src/ipc/methods/students.ts` — copy + `methods/savedEnvironments.ts` (the get/set registry handlers only). +- **Edit** `apps/desktop/src/ipc/DesktopIpcHandlers.ts` — register the two + handlers (follow the `getSavedEnvironmentRegistry`/`setSavedEnvironmentRegistry` + registration). +- **Edit** `apps/desktop/src/preload.ts` — expose `getStudents`/`setStudents` + on `desktopBridge` via `ipcRenderer.invoke`. +- **Edit** the desktop foundation layer (where `DesktopSavedEnvironments.layer` + is merged into `Layer.mergeAll(...)`) — add `DesktopStudents.layer`. + +### 4. Web renderer — data access +- **Edit** `apps/web/src/localApi.ts` — add `getStudents`/`setStudents` + wrappers over the bridge, mirroring the saved-environment-registry calls. +- **State:** keep it dead simple — load the roster once on the route mount into + local component state (or a tiny Zustand store `studentsStore.ts` if shared + across the list + detail routes). On any mutation: update in memory → call + `setStudents(wholeList)` → keep the in-memory copy as source of truth. + Coarse get-all/set-all, last-write-wins. + +### 5. Web renderer — routing & UI (Layout A) +- **New** `apps/web/src/routes/students.tsx` — top-level route, mirrors + `settings.tsx`. Two-pane: left = roster list (alphabetical by name) + + `[+ New]`; right = detail/edit pane (the selected student or the empty state). +- **New** `apps/web/src/routes/students.$studentId.tsx` *(optional)* — if the + router style prefers a child route for the selected student; otherwise manage + selection in `students.tsx` local state. Match whatever `settings.*` does. +- **New** `apps/web/src/components/students/` + - `StudentList.tsx` — alphabetical list, selection, empty state (8e). + - `StudentDetail.tsx` — read view with WhatsApp/Telegram + Map links (links + can be stubbed/disabled in B; wired live in C). `[Edit]` `[Delete]`. + - `StudentForm.tsx` — create/edit form. Fields: name (required), contact + (PhoneField), subjects (tag input), school (text), address (AddressFields), + parents (ParentRows). Validation per rules above. + - `PhoneField.tsx` — country ` handleChange("block", event.target.value)} + placeholder="e.g. 123" + /> + + +
+ + handleChange("street", event.target.value)} + placeholder="e.g. Orchard Road" + /> +
+ +
+ + handleChange("building", event.target.value)} + placeholder="e.g. Plaza Singapura" + /> +
+ +
+ + handleChange("unit", event.target.value)} + placeholder="e.g. #01-23" + /> +
+ +
+ + handleChange("postalCode", event.target.value)} + placeholder="e.g. 123456" + aria-invalid={postalCodeError !== undefined} + /> + {postalCodeError && ( + {postalCodeError} + )} +
+ + ); +} diff --git a/apps/web/src/components/students/ParentRows.tsx b/apps/web/src/components/students/ParentRows.tsx new file mode 100644 index 00000000000..a1e5fb5b675 --- /dev/null +++ b/apps/web/src/components/students/ParentRows.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { XIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { PhoneField, type PhoneValue } from "./PhoneField"; + +export interface ParentInfo { + readonly name: string; + readonly relationship: string; + readonly phone: PhoneValue | undefined; +} + +export interface ParentRowsProps { + readonly parents: ReadonlyArray; + readonly onChange: (parents: ReadonlyArray) => void; +} + +export function ParentRows({ parents, onChange }: ParentRowsProps) { + const handleAddParent = () => { + onChange([ + ...parents, + { + name: "", + relationship: "", + phone: undefined, + }, + ]); + }; + + const handleRemoveParent = (index: number) => { + onChange(parents.filter((_, i) => i !== index)); + }; + + const handleUpdateParent = (index: number, updates: Partial) => { + onChange( + parents.map((parent, i) => + i === index + ? { + ...parent, + ...updates, + } + : parent, + ), + ); + }; + + return ( +
+ {parents.map((parent, index) => ( +
+
+ + Parent {index + 1} + + +
+
+
+ + + handleUpdateParent(index, { name: event.target.value }) + } + placeholder="Parent name" + /> +
+
+ + + handleUpdateParent(index, { + relationship: event.target.value, + }) + } + placeholder="e.g. Mother, Father" + /> +
+
+ + handleUpdateParent(index, { phone })} + /> +
+
+
+ ))} + +
+ ); +} diff --git a/apps/web/src/components/students/PhoneField.tsx b/apps/web/src/components/students/PhoneField.tsx new file mode 100644 index 00000000000..f9ed01f97ea --- /dev/null +++ b/apps/web/src/components/students/PhoneField.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Input } from "../ui/input"; +import { + Select, + SelectTrigger, + SelectValue, + SelectPopup, + SelectItem, +} from "../ui/select"; + +const COUNTRIES = [ + { code: "SG", dialCode: "+65" }, + { code: "MY", dialCode: "+60" }, + { code: "CN", dialCode: "+86" }, +] as const; + +export interface PhoneValue { + readonly countryCode: string; + readonly number: string; +} + +export interface PhoneFieldProps { + readonly value: PhoneValue | undefined; + readonly onChange: (value: PhoneValue) => void; + readonly placeholder?: string; +} + +export function PhoneField({ value, onChange, placeholder }: PhoneFieldProps) { + const countryCode = value?.countryCode ?? "+65"; + const number = value?.number ?? ""; + + return ( +
+ + { + onChange({ + countryCode, + number: event.target.value, + }); + }} + placeholder={placeholder ?? "Phone number"} + className="flex-1" + /> +
+ ); +} From c9fccf76cf051c3ae177e3e624c3f03195fe84d8 Mon Sep 17 00:00:00 2001 From: rexong Date: Tue, 16 Jun 2026 01:36:11 +0800 Subject: [PATCH 12/94] auto-claude: subtask-6-2 - Create apps/web/src/components/students/StudentForm.tsx Implemented StudentForm component with: - Create/edit mode support - Name field (required, validated for non-empty trimmed value) - Phone field using PhoneField component - Subjects field (comma-separated input) - School field (optional text) - Address fields using AddressFields component with postal code validation - Parent rows using ParentRows component (empty rows auto-dropped on save) - Notes field (textarea) - Proper UUID generation via StudentId.make(randomUUID()) - Form validation for name and postal code - Tailwind CSS styling consistent with existing forms Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/students/StudentForm.tsx | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 apps/web/src/components/students/StudentForm.tsx diff --git a/apps/web/src/components/students/StudentForm.tsx b/apps/web/src/components/students/StudentForm.tsx new file mode 100644 index 00000000000..62c45c561a6 --- /dev/null +++ b/apps/web/src/components/students/StudentForm.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { StudentId, type Student } from "@t3tools/contracts"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Textarea } from "../ui/textarea"; +import { randomUUID } from "~/lib/utils"; +import { PhoneField, type PhoneValue } from "./PhoneField"; +import { AddressFields, type AddressValue } from "./AddressFields"; +import { ParentRows, type ParentInfo } from "./ParentRows"; + +export interface StudentFormProps { + readonly mode: "create" | "edit"; + readonly initialStudent?: Student; + readonly onSave: (student: Student) => void; + readonly onCancel: () => void; +} + +interface FormState { + readonly name: string; + readonly phone: PhoneValue | undefined; + readonly subjects: string; + readonly school: string; + readonly address: AddressValue | undefined; + readonly parents: ReadonlyArray; + readonly notes: string; +} + +function phoneValueFromContract( + phone: { country: string; number: string } | undefined, +): PhoneValue | undefined { + if (!phone) return undefined; + return { + countryCode: phone.country, + number: phone.number, + }; +} + +function phoneValueToContract( + phone: PhoneValue | undefined, +): + | { + readonly country: string; + readonly number: string; + } + | undefined { + if (!phone || !phone.number.trim()) return undefined; + return { + country: phone.countryCode, + number: phone.number.trim(), + }; +} + +function addressValueFromContract( + address: + | { + readonly block?: string; + readonly street?: string; + readonly building?: string; + readonly unit?: string; + readonly postalCode?: string; + } + | undefined, +): AddressValue | undefined { + if (!address) return undefined; + return { + block: address.block, + street: address.street, + building: address.building, + unit: address.unit, + postalCode: address.postalCode, + }; +} + +function addressValueToContract( + address: AddressValue | undefined, +): + | { + readonly block?: string; + readonly street?: string; + readonly building?: string; + readonly unit?: string; + readonly postalCode?: string; + } + | undefined { + if (!address) return undefined; + const hasValue = + address.block || + address.street || + address.building || + address.unit || + address.postalCode; + if (!hasValue) return undefined; + + const result: { + block?: string; + street?: string; + building?: string; + unit?: string; + postalCode?: string; + } = {}; + + if (address.block) result.block = address.block; + if (address.street) result.street = address.street; + if (address.building) result.building = address.building; + if (address.unit) result.unit = address.unit; + if (address.postalCode) result.postalCode = address.postalCode; + + return Object.keys(result).length > 0 ? result : undefined; +} + +function parentsFromContract( + parents: + | ReadonlyArray<{ + readonly name?: string; + readonly relationship?: string; + readonly phone?: { readonly country: string; readonly number: string }; + }> + | undefined, +): ReadonlyArray { + if (!parents) return []; + return parents.map((parent) => ({ + name: parent.name ?? "", + relationship: parent.relationship ?? "", + phone: phoneValueFromContract(parent.phone), + })); +} + +function parentsToContract( + parents: ReadonlyArray, +): + | ReadonlyArray<{ + readonly name?: string; + readonly relationship?: string; + readonly phone?: { readonly country: string; readonly number: string }; + }> + | undefined { + const cleaned = parents.filter((parent) => { + const hasName = parent.name.trim().length > 0; + const hasPhone = parent.phone && parent.phone.number.trim().length > 0; + return hasName || hasPhone; + }); + + if (cleaned.length === 0) return undefined; + + return cleaned.map((parent) => { + const result: { + name?: string; + relationship?: string; + phone?: { country: string; number: string }; + } = {}; + + if (parent.name.trim()) result.name = parent.name.trim(); + if (parent.relationship.trim()) result.relationship = parent.relationship.trim(); + const phone = phoneValueToContract(parent.phone); + if (phone) result.phone = phone; + + return result; + }); +} + +function validatePostalCode(postalCode: string | undefined): boolean { + if (!postalCode) return true; + return /^\d{6}$/.test(postalCode); +} + +function hasAnyAddressValue(address: AddressValue | undefined): boolean { + if (!address) return false; + return Boolean( + address.block || address.street || address.building || address.unit || address.postalCode, + ); +} + +export function StudentForm({ mode, initialStudent, onSave, onCancel }: StudentFormProps) { + const [formState, setFormState] = useState(() => ({ + name: initialStudent?.name ?? "", + phone: phoneValueFromContract(initialStudent?.phone), + subjects: initialStudent?.subjects?.join(", ") ?? "", + school: initialStudent?.school ?? "", + address: addressValueFromContract(initialStudent?.address), + parents: parentsFromContract(initialStudent?.parents), + notes: initialStudent?.notes ?? "", + })); + + const [nameError, setNameError] = useState(undefined); + + const addressError = useMemo(() => { + if (!hasAnyAddressValue(formState.address)) return undefined; + if (!validatePostalCode(formState.address?.postalCode)) { + return "Postal code must be 6 digits when address is partially filled"; + } + return undefined; + }, [formState.address]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedName = formState.name.trim(); + if (!trimmedName) { + setNameError("Name is required"); + return; + } + setNameError(undefined); + + if (addressError) { + return; + } + + const now = new Date().toISOString(); + const studentId = mode === "create" + ? StudentId.make(randomUUID()) + : initialStudent?.id ?? StudentId.make(randomUUID()); + + const phone = phoneValueToContract(formState.phone); + const subjectsArray = formState.subjects + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const address = addressValueToContract(formState.address); + const parents = parentsToContract(formState.parents); + + const student: Student = { + id: studentId, + name: trimmedName, + createdAt: mode === "create" ? now : initialStudent?.createdAt ?? now, + updatedAt: now, + ...(phone && { phone }), + ...(subjectsArray.length > 0 && { subjects: subjectsArray as readonly string[] }), + ...(formState.school.trim() && { school: formState.school.trim() }), + ...(address && { address }), + ...(parents && { parents }), + ...(formState.notes.trim() && { notes: formState.notes }), + }; + + onSave(student); + }; + + return ( +
+
+ + { + setFormState({ ...formState, name: event.target.value }); + setNameError(undefined); + }} + placeholder="Student name" + aria-invalid={nameError !== undefined} + autoFocus + /> + {nameError && {nameError}} +
+ +
+ + setFormState({ ...formState, phone })} + /> +
+ +
+ + setFormState({ ...formState, subjects: event.target.value })} + placeholder="e.g. Math, Physics, Chemistry" + /> + + Enter subjects separated by commas + +
+ +
+ + setFormState({ ...formState, school: event.target.value })} + placeholder="e.g. Raffles Institution" + /> +
+ +
+ + setFormState({ ...formState, address })} + /> + {addressError && {addressError}} +
+ +
+ + setFormState({ ...formState, parents })} + /> +
+ +
+ +