The lead-authored .fractal/lead-prd.json and validated task DAG are ready.
Dog
A live Fractal execution graph shared by 1island.
Execution
Project graph
In the current workspace root, create the complete project scaffold for a Vite + React 18 + TypeScript app named 'pawfolio'. Create: package.json (name 'pawfolio', private, scripts: 'dev': 'vite', 'build': 'tsc -b && vite build', 'test': 'vitest run', 'test:watch': 'vitest'; dependencies: react ^18.3.0, react-dom ^18.3.0; devDependencies: typescript ~5.5, vite ^5.4, @vitejs/plugin-react ^4.3, vitest ^2.1, jsdom ^25, @testing-library/react ^16, @testing-library/user-event ^14, @testing-library/jest-dom ^6, @types/react ^18, @types/react-dom ^18); vite.config.ts using @vitejs/plugin-react and a `test` block with environment 'jsdom', globals true, setupFiles ['src/test/setup.ts']; tsconfig.json (strict true, jsx 'react-jsx', module/target ESNext, moduleResolution 'bundler', types ['vitest/globals', '@testing-library/jest-dom']); index.html with <div id="root"></div> loading /src/main.tsx; src/main.tsx that renders <App /> from './App' into #root with React.StrictMode; a placeholder src/App.tsx exporting a default App component rendering <h1>Pawfolio</h1> (will be replaced later); src/test/setup.ts importing '@testing-library/jest-dom/vitest' and adding an afterEach that calls window.localStorage.clear(); and a .gitignore for node_modules and dist. Then run `npm install`, create a trivial smoke test src/test/smoke.test.ts asserting 1+1===2, and verify `npx vitest run` exits 0. All later tasks rely on these devDependencies, so the dependency list must be complete now; do not leave any TODOs.
Create src/styles/app.css containing the full visual system for Pawfolio: CSS custom properties on :root for colors (warm cream background #FAF6F0, ink text #2B2320, accent terracotta #C96F4A, positive green #4A8F5C, warning amber #C9932B, card surface #FFFFFF, border #E5DCD2), spacing scale, radius (12px cards), and a system-ui font stack; base reset (box-sizing border-box, margin 0); layout classes: .app-shell (max-width 720px, centered, padding), .tabbar (horizontal tab buttons with an active state using the accent color), .card (surface, radius, subtle shadow, padding), .tile-grid (responsive grid of stat tiles, minmax(150px, 1fr)), .tile (centered stat with large value and small label); form classes: .field (label above input, gap), .field input/select (full width, padded, border, focus ring in accent), .field-error (small warning-amber text), .btn (accent background, white text, radius, hover darken) and .btn-danger (muted red); list classes: .entry-list (unstyled ul), .entry-row (flex row with kind badge, note, timestamp, delete button), .badge (small pill, per-kind background via .badge-walk/.badge-meal/.badge-meds/.badge-play); and an .empty-state class (centered, muted, large emoji). Include a prefers-color-scheme: dark override adjusting the custom properties to a dark warm palette with sufficient contrast. This file must be self-contained — components will only reference these class names, so name them exactly as listed.
Create src/types/dog.ts exporting: interface FeedingSchedule { times: string[] } where times are 'HH:MM' 24h strings; interface DogProfile { name: string; breed: string; birthdate: string /* ISO yyyy-mm-dd */; avatar: string /* emoji */; feeding: FeedingSchedule }; type ActivityKind = 'walk' | 'meal' | 'meds' | 'play'; interface ActivityEntry { id: string; kind: ActivityKind; note: string; timestamp: string /* ISO datetime */ }; interface WeightEntry { id: string; kg: number; date: string /* ISO yyyy-mm-dd */ }; interface AppState { dog: DogProfile | null; activities: ActivityEntry[]; weights: WeightEntry[] }; const EMPTY_STATE: AppState = { dog: null, activities: [], weights: [] }; and function validateDogProfile(input: { name: string; breed: string; birthdate: string; avatar: string; feeding: FeedingSchedule }, now: Date): { ok: true; value: DogProfile } | { ok: false; errors: { field: string; message: string }[] } which rejects: empty/whitespace-only name ('Name is required'), birthdate that is not a valid date or is after `now` ('Birthdate must be in the past'), any feeding time not matching /^([01]\d|2[0-3]):[0-5]\d$/ ('Feeding times must be HH:MM'), and empty feeding.times array ('At least one feeding time is required'); trim name and breed on success. Also create src/fixtures/sampleDog.ts exporting sampleDog (a valid DogProfile named 'Biscuit', breed 'Corgi', birthdate '2024-03-15', avatar '🐶', feeding times ['08:00','18:00']), sampleActivities (3 ActivityEntry items with distinct kinds and ISO timestamps), and sampleWeights (3 WeightEntry items with increasing kg). Pure TypeScript only — no React imports, no Date.now() calls inside validation (use the `now` parameter).
Re-run the acceptance suite against the produced artifact; fail if any test fails.
Create src/lib/care.ts importing DogProfile, ActivityEntry, WeightEntry, FeedingSchedule, ActivityKind from '../types/dog'. Export these pure functions, every one taking an explicit `now: Date` where time matters (never call Date.now() or new Date() with no args): (1) computeAgeString(birthdate: string, now: Date): string — whole years and remaining whole months between birthdate and now, formatted '2 years, 3 months', with singulars ('1 year, 1 month'), months-only under a year ('7 months'), and 'Less than a month old' under one month; account for day-of-month so the month count does not overcount before the monthly anniversary. (2) weightTrend(entries: WeightEntry[], windowSize?: number): { direction: 'gaining' | 'losing' | 'stable' | 'insufficient'; deltaKg: number } — sort entries by date ascending, compare the latest entry to the previous one (or to the entry windowSize back when provided); 'insufficient' with deltaKg 0 when fewer than 2 entries; 'stable' when |delta| < 0.1 kg; round deltaKg to 1 decimal. (3) nextFeedingStatus(feeding: FeedingSchedule, now: Date): { label: string; overdue: boolean } — sort feeding.times ascending and compare against now's local HH:MM: if the most recently passed time today is at most 30 minutes in the past, return { label: 'Overdue: HH:MM meal', overdue: true } for that time; otherwise return { label: 'Next meal at HH:MM', overdue: false } using the next upcoming time today, or { label: 'Next meal at HH:MM tomorrow', overdue: false } with the first scheduled time when every time today has passed by more than 30 minutes; empty times array returns { label: 'No feeding schedule', overdue: false }. (4) activitySummary(entries: ActivityEntry[], now: Date): { total: number; byKind: Record<ActivityKind, number> } — counts only entries whose timestamp is within the last 7 * 24 hours before (inclusive) now; byKind always contains all four kinds with 0 defaults. (5) formatTimestamp(iso: string): string — 'Mon DD, HH:MM' local-time display string. Document each function with a short JSDoc block stating its edge-case behavior exactly as specified above so tests can be written against it.
Create src/types/dog.test.ts using Vitest (describe/it/expect, globals enabled) testing validateDogProfile from './dog' with a fixed `now` of new Date('2026-07-28T12:00:00'): valid input (use fields from src/fixtures/sampleDog) returns ok:true with name and breed trimmed when given padded strings; empty and whitespace-only name returns ok:false containing an error with field 'name' and message 'Name is required'; birthdate '2027-01-01' (future) and 'not-a-date' each return ok:false with field 'birthdate'; feeding time '25:00' and '8:00' (missing leading zero) each return ok:false with field matching feeding; empty feeding.times returns ok:false with message 'At least one feeding time is required'; multiple simultaneous violations return all corresponding errors in one errors array; birthdate exactly equal to today ('2026-07-28') is accepted (not in the future). Also assert EMPTY_STATE has dog null and empty arrays. Do not test storage or care logic here.
Create src/lib/storage.ts importing AppState and EMPTY_STATE from '../types/dog'. Export: const STORAGE_KEY = 'pawfolio.v1'; function saveState(state: AppState, storage: Storage = window.localStorage): void — serializes { version: 1, state } as JSON under STORAGE_KEY; function loadState(storage: Storage = window.localStorage): AppState — reads STORAGE_KEY and returns the parsed state only if JSON.parse succeeds, version === 1, and the payload structurally matches AppState (dog is null or has string name/breed/birthdate/avatar and feeding.times array of strings; activities and weights are arrays whose items have the required fields with correct primitive types — validate with a local isAppState type guard, filtering out malformed array items rather than rejecting the whole state when the top-level shape is valid); on any failure (missing key, invalid JSON, wrong version, wrong shape) return a fresh deep copy of EMPTY_STATE and never throw; function clearState(storage: Storage = window.localStorage): void — removes STORAGE_KEY; function exportStateJson(storage: Storage = window.localStorage): string — returns the pretty-printed JSON of loadState's result. Always return deep copies (structuredClone) so callers cannot mutate stored references. No React imports.
Re-run the acceptance suite against the produced artifact; fail if any test fails.
Create src/lib/care.test.ts using Vitest testing computeAgeString, weightTrend, nextFeedingStatus, activitySummary from './care', constructing all Date values explicitly (no vi.useFakeTimers needed since every function takes `now`). computeAgeString with now = new Date('2026-07-28T12:00:00'): birthdate '2024-03-15' → '2 years, 4 months'; '2025-07-28' (anniversary today) → '1 year'-style output containing '1 year'; '2026-01-10' → months-only string; '2026-07-20' → 'Less than a month old'; '2024-08-05' (monthly anniversary not yet reached) must NOT overcount to '2 years'. weightTrend: [] and single entry → direction 'insufficient'; entries kg 10.0 then 10.05 (dates ascending) → 'stable'; 10.0 → 10.4 → 'gaining' with deltaKg 0.4; 10.4 → 10.0 → 'losing' with deltaKg -0.4; entries provided out of date order must still compare the two latest by date. nextFeedingStatus with feeding times ['08:00','18:00']: now 07:30 → label 'Next meal at 08:00', overdue false; now 08:15 (within 30 min after 08:00) → overdue true and label containing '08:00'; now 12:00 → 'Next meal at 18:00', overdue false; now 19:30 (all passed by >30 min) → label containing '08:00' and 'tomorrow', overdue false; empty times → 'No feeding schedule'. activitySummary with now = new Date('2026-07-28T12:00:00'): an entry timestamped 6 days ago is counted, one 8 days ago is excluded, byKind contains all four kinds with correct counts and zero defaults, and total equals the sum of byKind values.
Create src/lib/storage.test.ts using Vitest testing saveState, loadState, clearState, STORAGE_KEY from './storage' with fixtures from '../fixtures/sampleDog' (jsdom provides window.localStorage; the shared setup file clears it after each test, but also call localStorage.clear() in a beforeEach for independence). Tests: (1) round-trip — saveState of an AppState built from sampleDog/sampleActivities/sampleWeights then loadState returns a deeply equal state; (2) mutation safety — mutating the object returned by loadState (e.g. push into activities) then calling loadState again returns the original unmutated data; (3) missing key — loadState on empty storage returns { dog: null, activities: [], weights: [] }; (4) corrupt JSON — localStorage.setItem(STORAGE_KEY, '{not json!!') then loadState returns the empty state and does not throw; (5) wrong version — a stored payload { version: 99, state: {...valid...} } yields the empty state; (6) malformed items filtered — a stored version-1 payload whose activities array contains one valid entry and one junk object ({ id: 5 }) loads with only the valid entry present and dog intact; (7) clearState removes the key so a subsequent loadState is empty.
Create src/components/ActivityLog.tsx exporting default function ActivityLog({ activities, weights, onAddActivity, onDeleteActivity, onAddWeight }: { activities: ActivityEntry[]; weights: WeightEntry[]; onAddActivity: (kind: ActivityKind, note: string) => void; onDeleteActivity: (id: string) => void; onAddWeight: (kg: number) => void }) using types from '../types/dog' and formatTimestamp from '../lib/care'. Render two .card sections: (1) 'Log activity' — a select labeled 'Activity type' with options walk/meal/meds/play, a text input labeled 'Note', and a .btn 'Add activity' that calls onAddActivity and clears the note; below it a .entry-list of activities sorted newest-first, each .entry-row showing a .badge with the kind (class badge-walk etc.), the note, formatTimestamp(entry.timestamp), and a .btn-danger button with accessible name 'Delete' + the entry note (aria-label `Delete ${note || kind}`) calling onDeleteActivity(entry.id); when activities is empty render an .empty-state with '🐾 No activities yet'. (2) 'Record weight' — a number input labeled 'Weight (kg)' (min 0.1, step 0.1) and a .btn 'Add weight' that calls onAddWeight with the parsed number only when it is a finite number > 0 (otherwise show a .field-error 'Enter a weight above 0'); list existing weights newest-first as 'DATE — X kg'. ID generation for new entries is the parent's job (props only pass kind/note/kg), so this component stays presentational. All controls must have proper label associations for Testing Library.
Create src/components/Dashboard.tsx exporting default function Dashboard({ dog, activities, weights, now }: { dog: DogProfile; activities: ActivityEntry[]; weights: WeightEntry[]; now: Date }) using computeAgeString, weightTrend, nextFeedingStatus, activitySummary from '../lib/care'. Render: a header .card with the dog's avatar emoji (large), name as an <h2>, and breed; then a .tile-grid of .tile elements: (1) 'Age' tile showing computeAgeString(dog.birthdate, now); (2) 'Weight trend' tile — if weightTrend(weights).direction is 'insufficient' show 'Add 2+ weigh-ins', else show the direction word capitalized plus signed delta e.g. 'Gaining (+0.4 kg)' with the value colored via inline class trend-gaining/trend-losing/trend-stable; (3) 'Feeding' tile showing nextFeedingStatus(dog.feeding, now).label, with a data-overdue attribute set to the overdue boolean; (4) 'Last 7 days' tile showing activitySummary(activities, now).total followed by 'activities', with a small per-kind breakdown line 'walks W · meals M · meds D · play P' using the byKind counts. Accept `now` strictly as a prop (never call new Date() inside this component) so tests control time. Use only class names from src/styles/app.css plus the three trend-* classes (add those three classes to nothing — inline the color with style={} using var(--positive)/var(--warning)/inherit instead, to avoid editing the css file).
Create src/components/DogProfile.tsx exporting default function DogProfile({ dog, onSave }: { dog: DogProfile | null; onSave: (dog: DogProfile) => void }) using types from '../types/dog'. Render a .card containing a form (aria-label 'Dog profile form') with labeled controls: text input 'Name', text input 'Breed', date input 'Birthdate', a select 'Avatar' offering the emojis 🐶 🐕 🦮 🐩 🐕🦺, and a feeding-times editor: a list of time inputs (one per feeding time, default ['08:00','18:00'] for a new dog) with an 'Add feeding time' button and a per-row 'Remove' button (Remove disabled when only one row remains). Initialize fields from the `dog` prop when present (edit mode, submit button label 'Save changes') else blank/new-dog defaults (label 'Create profile'). On submit, call validateDogProfile from '../types/dog' with new Date() as now; if ok, call onSave(result.value) and render a status line 'Profile saved' (role='status'); if not ok, render each error message in a .field-error element adjacent to its field and do NOT call onSave. Every input must be reachable by accessible label text (htmlFor/id pairs) so Testing Library's getByLabelText works. Use only class names defined in src/styles/app.css (.card, .field, .field-error, .btn).
Replace the placeholder src/App.tsx with the full application shell. Import './styles/app.css', types and EMPTY_STATE from './types/dog', loadState/saveState from './lib/storage', and the three components from './components/DogProfile', './components/ActivityLog', './components/Dashboard'. App holds state via useState<AppState>(() => loadState()) and a tab state 'dashboard' | 'log' | 'profile' (default 'dashboard' when a dog exists, 'profile' otherwise). Persist by calling saveState(state) inside a useEffect that runs on every state change. Render an .app-shell with an <h1>Pawfolio 🐾</h1>, a .tabbar with three buttons ('Dashboard', 'Activity log', 'Profile') that set the active tab (aria-current='page' on the active one); when state.dog is null and tab is not 'profile', render an .empty-state card '🐶 Welcome! Create your dog's profile to get started.' with a .btn 'Create profile' that switches to the profile tab. Handlers passed down: onSave(dog) sets state.dog and switches to the dashboard tab; onAddActivity(kind, note) prepends { id: crypto.randomUUID(), kind, note, timestamp: new Date().toISOString() }; onDeleteActivity(id) filters it out; onAddWeight(kg) appends { id: crypto.randomUUID(), kg, date: new Date().toISOString().slice(0, 10) }. Dashboard receives now={new Date()} constructed at render time in App (the single place real time enters the UI). Delete the scaffold's src/test/smoke.test.ts (superseded by real suites). Also update index.html's <title> to 'Pawfolio'. Verify the app compiles by running `npx tsc -b` and fix any type errors across the files you integrate (do not change the public signatures of the domain/lib modules).
Create src/App.test.tsx using Vitest + @testing-library/react + @testing-library/user-event testing the default export of './App'. Tests: (1) empty state — render <App />; assert the welcome empty-state text about creating a profile is shown. (2) validation surface — navigate to the Profile tab, submit the profile form with an empty name, assert 'Name is required' appears and no dog header is rendered. (3) create profile happy path — fill Name 'Biscuit', Breed 'Corgi', Birthdate '2024-03-15' via getByLabelText, submit, and assert the app switches to the dashboard showing 'Biscuit' and an Age tile. (4) log activity round-trip — after creating the profile, go to 'Activity log', choose type 'walk', type note 'Morning loop', click 'Add activity'; assert the entry row with 'Morning loop' appears; go to Dashboard and assert the 'Last 7 days' tile shows 1; return to the log, click the delete button whose accessible name includes 'Morning loop', and assert the row is removed. (5) persistence across reload — create the profile, then unmount() the rendered App, render a fresh <App /> without clearing localStorage, and assert 'Biscuit' is displayed on the dashboard (loadState restored it); then simulate corruption by localStorage.setItem('pawfolio.v1', 'garbage'), render a fresh <App />, and assert the welcome empty state renders instead of throwing. Use userEvent.setup() per test and findBy* queries for post-interaction assertions to avoid act warnings. Note the shared setup file clears localStorage after each test — within test 5 do all reload steps inside the single test.
Create README.md at the workspace root documenting Pawfolio: a one-paragraph product description (offline dog-care companion: profile, activity log, weight tracking, care dashboard); a 'Getting started' section with the exact commands `npm install`, `npm run dev` (Vite dev server), `npm run build`, and `npm test` (Vitest); a 'Features' bullet list mapping to the acceptance criteria (validated dog profile, persistent localStorage data that survives reload and tolerates corruption, dashboard with computed age / weight trend / next-feeding status / 7-day activity summary, activity logging with delete); a short 'Architecture' section naming the layers (pure domain in src/types and src/lib, presentational components in src/components, state-owning shell in src/App.tsx, versioned storage under key 'pawfolio.v1') and noting that all time-dependent logic takes an explicit `now: Date` for deterministic testing; and a 'Non-goals' section (no backend/accounts/sync, single dog only, no medical advice). Match the actual file layout and script names from package.json exactly.
From the workspace root run `npm install` (ensure dependencies are present) then `npx tsc -b` and `npx vitest run`. All test files (src/types/dog.test.ts, src/lib/storage.test.ts, src/lib/care.test.ts, src/App.test.tsx) must pass and the type check must emit no errors — this verifies acceptance criteria AC-1 through AC-5. If any test or type error fails, diagnose and fix the implementation or, only when a test contradicts the specified behavior in fractal-plan.json's acceptance criteria, correct the test to match the specified behavior; then rerun until `npx vitest run` exits 0. Finish by running `npm run build` and confirming it produces a dist/ directory without errors. Report the final vitest summary (files/tests passed) as the task output.
Review the finished implementation against .fractal/lead-prd.json. Inspect the changes and verification evidence, run any final checks needed, then write .fractal/closeout.json with schema fractal.closeout.v1, status approved, a non-empty summary, an acceptance array containing every PRD acceptance id with passed=true and concrete evidence, and a risks array. Do not approve if any criterion is unsupported.
Contribute