Person Type, Store, and Persistence
Before any UI, define the data shape. FamilyTree’s entire domain lives in two TypeScript interfaces and a single Pinia store.
TypeScript interfaces
Section titled “TypeScript interfaces”export interface SpouseRelationship { personId: string marriageDate?: string // free-form: "1995", "June 1995", "June 12, 1995" divorceDate?: string // undefined = currently married}
export interface Person { id: string name: string birthYear?: number deathYear?: number bio?: string parentIds: string[] // biological parents (max 2) stepParentIds: string[] // step-parents (no limit) spouses: SpouseRelationship[] // ordered; no divorceDate = current siblingIds: string[] // explicitly added siblings}
export type PersonFormData = Omit<Person, 'id'>The SpouseRelationship array supports multiple marriages: divorced-and-remarried history is just multiple entries where some have divorceDate and the current one doesn’t.
The family store
Section titled “The family store”import { ref, computed } from 'vue'import { defineStore } from 'pinia'import type { Person, PersonFormData, SpouseRelationship } from '../types'
export const useFamilyStore = defineStore( 'family', () => { const people = ref<Person[]>([]) const focusPersonId = ref<string | null>(null)
const focusPerson = computed<Person | undefined>(() => people.value.find(p => p.id === focusPersonId.value) )
const parents = computed<Person[]>(() => focusPerson.value ? people.value.filter(p => (focusPerson.value!.parentIds ?? []).includes(p.id)) : [] )
const children = computed<Person[]>(() => focusPerson.value ? people.value.filter(p => (p.parentIds ?? []).includes(focusPerson.value!.id)) : [] )
const currentSpouseRel = computed<SpouseRelationship | undefined>(() => (focusPerson.value?.spouses ?? []).find(s => !s.divorceDate) )
const currentSpouse = computed<Person | undefined>(() => currentSpouseRel.value ? people.value.find(p => p.id === currentSpouseRel.value!.personId) : undefined )
function addPerson(data: PersonFormData): string { const id = crypto.randomUUID() const { parentIds, stepParentIds, siblingIds, spouses, ...rest } = data people.value.push({ ...rest, parentIds: parentIds ?? [], stepParentIds: stepParentIds ?? [], spouses: spouses ?? [], siblingIds: siblingIds ?? [], id, }) if (focusPersonId.value === null) focusPersonId.value = id return id }
function updatePerson(id: string, updates: Partial<PersonFormData>): void { const idx = people.value.findIndex(p => p.id === id) if (idx !== -1) people.value[idx] = { ...people.value[idx], ...updates } }
function deletePerson(id: string): void { people.value = people.value .filter(p => p.id !== id) .map(p => ({ ...p, parentIds: (p.parentIds ?? []).filter(pid => pid !== id), stepParentIds: (p.stepParentIds ?? []).filter(pid => pid !== id), siblingIds: (p.siblingIds ?? []).filter(sid => sid !== id), spouses: (p.spouses ?? []).filter(s => s.personId !== id), })) if (focusPersonId.value === id) { focusPersonId.value = people.value[0]?.id ?? null } }
function setFocus(id: string): void { focusPersonId.value = id }
return { people, focusPersonId, focusPerson, parents, children, currentSpouseRel, currentSpouse, addPerson, updatePerson, deletePerson, setFocus, } }, { persist: { afterHydrate: (ctx) => { ctx.store.$patch({ people: (ctx.store.people as any[]).map((p: any) => ({ parentIds: [], stepParentIds: [], spouses: [], siblingIds: [], ...p, })), }) }, }, })Why afterHydrate matters
Section titled “Why afterHydrate matters”When you add a new field to Person after users already have data saved in localStorage, their saved Person objects won’t have the new field. Without normalization, any code that calls .includes() on undefined.stepParentIds will throw a TypeError. The afterHydrate hook backfills missing array fields with empty arrays before any component mounts.
Exercise
Section titled “Exercise”- Create
src/types/index.tswith the interfaces above. - Create
src/stores/familyStore.tswith the store above (you can start with justaddPerson,deletePerson, andsetFocus). - In a temporary component, call
store.addPerson({ name: 'Alice', ... }), then logstore.people. Confirm the person appears. - Refresh the page and confirm Alice is still there (persistence working).
SpouseRelationshipsupports multiple marriages with optionalmarriageDateanddivorceDate.Personis the single source of truth — relationships are arrays of IDs, derived by computed getters.afterHydratenormalizes persisted data — essential when the schema evolves after users have saved data.crypto.randomUUID()generates stable, unique person IDs.