summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ai_prompt.md521
-rw-r--r--src/App.css42
-rw-r--r--src/App.tsx148
-rw-r--r--src/assets/react.svg1
-rw-r--r--src/components/ResultDisplay.tsx48
-rw-r--r--src/components/SizeSelector.tsx81
-rw-r--r--src/data/tire_sizes.json74
-rw-r--r--src/index.css70
-rw-r--r--src/types.ts26
-rw-r--r--src/utils/calculate_circumference.ts36
10 files changed, 905 insertions, 142 deletions
diff --git a/ai_prompt.md b/ai_prompt.md
new file mode 100644
index 0000000..89660db
--- /dev/null
+++ b/ai_prompt.md
@@ -0,0 +1,521 @@
+## Me
+
+> Context:
+> - <rules>.rules/daisyui.md</rules>
+> - <rules>.rules/project.md</rules>
+> - <file>reference/tire-sizes.json</file>
+> - <file>reference/howto.mdown</file>
+> - <group>files</group>
+
+I need you to use @{files} to build a frontend.
+
+It will contain the following
+- 2 dropdowns: one for rim size and one for tire size
+ - the values in this dropdowns will come from the json file I have given you. place this json file in an appropriate spot in the project
+- each of those dropdowns will have an accompanying text box
+ - these textboxes will only accept numbers
+ - when a dropdown option is selected, it will fill the text box with the corresponding value from the json
+ - whenever the textbox value is manually updated by the user, the dropdown should update automatically to match the number with the first matching value. if there are no matching values instead set the dropdown to a value called "custom". This "custom" value should be at the bottom of the selections. If custom is selected in the dropdown by a user nothing should happen or change in the text box
+- an output box which will have the resulting value from the options selected based on the equation in the given howto.mdown file
+
+you may completely remove the default given code and start from scratch as you see fit.
+
+## CodeCompanion (Copilot)
+
+
+
+I'll start by exploring the project structure to understand what exists, then build the app.
+
+Searched files for `**/*`, 50 results
+```
+.dockerignore
+.gitignore
+.tool-versions
+Dockerfile
+README.md
+docker-compose.yml
+docker-entrypoint.sh
+docker_readme.md
+eslint.config.js
+index.html
+package-lock.json
+package.json
+tsconfig.app.json
+tsconfig.json
+tsconfig.node.json
+vite.config.ts
+.git/COMMIT_EDITMSG
+.git/HEAD
+.git/config
+.git/description
+.git/index
+.rules/daisyui.md
+.rules/project.md
+node_modules/.package-lock.json
+public/vite.svg
+reference/howto.mdown
+reference/tire-sizes.json
+src/App.css
+src/App.tsx
+src/index.css
+src/main.tsx
+.git/hooks/applypatch-msg.sample
+.git/hooks/commit-msg.sample
+.git/hooks/fsmonitor-watchman.sample
+.git/hooks/post-update.sample
+.git/hooks/pre-applypatch.sample
+.git/hooks/pre-commit.sample
+.git/hooks/pre-merge-commit.sample
+.git/hooks/pre-push.sample
+.git/hooks/pre-rebase.sample
+.git/hooks/pre-receive.sample
+.git/hooks/prepare-commit-msg.sample
+.git/hooks/push-to-checkout.sample
+.git/hooks/sendemail-validate.sample
+.git/hooks/update.sample
+.git/info/exclude
+.git/logs/HEAD
+node_modules/acorn/CHANGELOG.md
+node_modules/acorn/LICENSE
+node_modules/acorn/README.md
+```
+
+
+Read file `package.json` (0 - end)
+
+
+Read file `src/index.css` (0 - end)
+
+
+Read file `src/App.tsx` (0 - end)
+
+
+Read file `src/App.css` (0 - end)
+
+
+Read file `src/main.tsx` (0 - end)
+
+
+Read file `index.html` (0 - end)
+
+
+Read file `vite.config.ts` (0 - end)
+
+
+Now I have a clear picture. Let me plan the implementation:
+
+1. Copy the tire sizes JSON into `src/data/tire_sizes.json`
+2. Create a TypeScript types file for the data
+3. Create a `circumference_calculator` component
+4. Rewrite `App.tsx` to use it
+5. Clean up `index.css` (remove custom styles, keep Tailwind+daisyUI)
+6. Delete `App.css` and the assets folder
+
+Let me build this step by step:
+
+Searched files for `src/assets/*`, 1 results
+```
+src/assets/react.svg
+```
+
+
+Created file `src/data/tire_sizes.json`
+
+````json
+{
+ "wheel_sizes": [
+ { "label": "12.5\"", "diameter_mm": 203 },
+ { "label": "14\"", "diameter_mm": 254 },
+ { "label": "16\"", "diameter_mm": 305 },
+ { "label": "20\"", "diameter_mm": 406 },
+ { "label": "24\" BMX/Kids Bikes", "diameter_mm": 507 },
+ { "label": "24\" Terry Bicycles", "diameter_mm": 520 },
+ { "label": "24\"", "diameter_mm": 540 },
+ { "label": "24\" Schwinn", "diameter_mm": 540 },
+ { "label": "26\"", "diameter_mm": 559 },
+ { "label": "650c", "diameter_mm": 571 },
+ { "label": "650b", "diameter_mm": 584 },
+ { "label": "27.5", "diameter_mm": 584 },
+ { "label": "26 x 1 1/2\"", "diameter_mm": 584 },
+ { "label": "650a", "diameter_mm": 590 },
+ { "label": "26 x 1 3/8\"", "diameter_mm": 590 },
+ { "label": "650", "diameter_mm": 597 },
+ { "label": "26 x 1 1/4\"", "diameter_mm": 597 },
+ { "label": "29er", "diameter_mm": 622 },
+ { "label": "700c", "diameter_mm": 622 },
+ { "label": "27 inch", "diameter_mm": 630 }
+ ],
+ "tire_sizes": [
+ { "label": "18C", "diameter_mm": 18 },
+ { "label": "19C", "diameter_mm": 19 },
+ { "label": "20C", "diameter_mm": 20 },
+ { "label": "21C", "diameter_mm": 21 },
+ { "label": "23C", "diameter_mm": 23 },
+ { "label": "25C", "diameter_mm": 25 },
+ { "label": "26C", "diameter_mm": 26 },
+ { "label": "28C", "diameter_mm": 28 },
+ { "label": "30C", "diameter_mm": 30 },
+ { "label": "32C", "diameter_mm": 32 },
+ { "label": "35C", "diameter_mm": 35 },
+ { "label": "38C", "diameter_mm": 38 },
+ { "label": "40C", "diameter_mm": 40 },
+ { "label": "44C", "diameter_mm": 44 },
+ { "label": "50C", "diameter_mm": 50 },
+ { "label": "56C", "diameter_mm": 56 },
+ { "label": "1.00 inch", "diameter_mm": 25.4 },
+ { "label": "1 1/8 inch", "diameter_mm": 28.575 },
+ { "label": "1.25 inch", "diameter_mm": 31.75 },
+ { "label": "1 3/8 inch", "diameter_mm": 34.925 },
+ { "label": "1.4 inch", "diameter_mm": 35.56 },
+ { "label": "1.5 inch", "diameter_mm": 38.1 },
+ { "label": "1.75 inch", "diameter_mm": 44.45 },
+ { "label": "1.95 inch", "diameter_mm": 49.5 },
+ { "label": "2.00 inch", "diameter_mm": 50.8 },
+ { "label": "2.10 inch", "diameter_mm": 53.34 },
+ { "label": "2.125 inch", "diameter_mm": 54 },
+ { "label": "2.20 inch", "diameter_mm": 55.88 },
+ { "label": "2.25 inch", "diameter_mm": 57.15 },
+ { "label": "2.30 inch", "diameter_mm": 58.42 },
+ { "label": "2.35 inch", "diameter_mm": 59.69 },
+ { "label": "2.40 inch", "diameter_mm": 60.69 },
+ { "label": "2.50 inch", "diameter_mm": 63.5 },
+ { "label": "2.60 inch", "diameter_mm": 66.04 },
+ { "label": "2.70 inch", "diameter_mm": 68.58 },
+ { "label": "2.80 inch", "diameter_mm": 71.12 },
+ { "label": "2.90 inch", "diameter_mm": 73.66 },
+ { "label": "3.0 inch", "diameter_mm": 76.2 },
+ { "label": "3.1 inch", "diameter_mm": 78.74 },
+ { "label": "3.2 inch", "diameter_mm": 81.28 },
+ { "label": "3.3 inch", "diameter_mm": 83.82 },
+ { "label": "3.4 inch", "diameter_mm": 86.36 },
+ { "label": "3.5 inch", "diameter_mm": 88.9 },
+ { "label": "3.6 inch", "diameter_mm": 91.44 },
+ { "label": "3.7 inch", "diameter_mm": 93.98 },
+ { "label": "3.8 inch", "diameter_mm": 96.52 },
+ { "label": "3.9 inch", "diameter_mm": 99.06 },
+ { "label": "4.0 inch", "diameter_mm": 101.6 }
+ ]
+}
+
+````
+
+
+Created file `src/types.ts`
+
+````ts
+/**
+ * Type definitions for the tire size data used throughout the app.
+ * Each size entry has a human-readable label and a diameter in millimeters.
+ */
+
+/** A single rim or tire size option from our reference data. */
+export interface SizeOption {
+ label: string;
+ diameter_mm: number;
+}
+
+/** The shape of the imported tire_sizes.json file. */
+export interface TireSizeData {
+ wheel_sizes: SizeOption[];
+ tire_sizes: SizeOption[];
+}
+
+/**
+ * Results from the circumference calculation.
+ * All values derived from: circumference = 3.13772 * (2 * tire + rim)
+ */
+export interface CircumferenceResult {
+ mm: number;
+ cm: number;
+ inches: number;
+ mph_setting: number;
+}
+
+````
+
+
+Created file `src/utils/calculate_circumference.ts`
+
+````ts
+/**
+ * calculate_circumference.ts
+ *
+ * Pure function that computes the bicycle wheel circumference from rim and
+ * tire diameters (both in mm). The formula uses 3.13772 instead of exact π
+ * to approximate the "roll-out" circumference (tire compressed under rider
+ * weight).
+ *
+ * Formula: circumference_mm = 3.13772 × (2 × tire_diameter + rim_diameter)
+ */
+
+import type { CircumferenceResult } from "../types";
+
+/** The modified Pi constant that accounts for tire compression. */
+const ROLLOUT_PI = 3.13772;
+
+/**
+ * Returns circumference in multiple units, or null when inputs are invalid
+ * (non-positive or missing).
+ */
+export function calculate_circumference(
+ rim_mm: number,
+ tire_mm: number,
+): CircumferenceResult | null {
+ if (rim_mm <= 0 || tire_mm <= 0 || isNaN(rim_mm) || isNaN(tire_mm)) {
+ return null;
+ }
+
+ const circ_mm = ROLLOUT_PI * (2 * tire_mm + rim_mm);
+
+ return {
+ mm: Math.round(circ_mm),
+ cm: Math.round(circ_mm / 10),
+ inches: Math.round((10 * circ_mm) / 25.4) / 10,
+ mph_setting: Math.round(circ_mm / 1.6),
+ };
+}
+
+````
+
+
+Now create the main component with the two dropdowns, text inputs, and result output:
+
+Created file `src/components/SizeSelector.tsx`
+
+````tsx
+/**
+ * SizeSelector.tsx
+ *
+ * A reusable pair of <select> + <input> for choosing a rim or tire size.
+ *
+ * Behaviour:
+ * - Selecting a dropdown option fills the text input with its diameter_mm.
+ * - Typing a number in the text input auto-selects the first matching
+ * dropdown option, or falls back to "Custom" if no match exists.
+ * - Choosing "Custom" in the dropdown does nothing to the text input.
+ */
+
+import type { SizeOption } from "../types";
+
+/** Sentinel value used as the <option> value for "Custom". */
+const CUSTOM_VALUE = "__custom__";
+
+interface SizeSelectorProps {
+ /** Human-readable label shown above the controls. */
+ label: string;
+ /** The list of predefined size options to populate the dropdown. */
+ options: SizeOption[];
+ /** Currently selected dropdown value (a label string or CUSTOM_VALUE). */
+ selected_option: string;
+ /** Current numeric value in the text input (as a string so we can allow empty). */
+ input_value: string;
+ /** Called when the dropdown selection changes. */
+ on_option_change: (option_value: string) => void;
+ /** Called when the text input value changes. */
+ on_input_change: (value: string) => void;
+}
+
+export default function SizeSelector({
+ label,
+ options,
+ selected_option,
+ input_value,
+ on_option_change,
+ on_input_change,
+}: SizeSelectorProps) {
+ return (
+ <fieldset className="fieldset">
+ <legend className="fieldset-legend">{label}</legend>
+
+ {/* Dropdown for predefined sizes */}
+ <select
+ className="select select-bordered w-full"
+ value={selected_option}
+ aria-label={`${label} preset`}
+ onChange={(e) => on_option_change(e.target.value)}
+ >
+ <option disabled value="">
+ Pick a size
+ </option>
+ {options.map((opt, idx) => (
+ <option key={`${opt.label}-${idx}`} value={opt.label}>
+ {opt.label} ({opt.diameter_mm} mm)
+ </option>
+ ))}
+ <option value={CUSTOM_VALUE}>Custom</option>
+ </select>
+
+ {/* Numeric text input — mirrors / overrides the dropdown */}
+ <label className="input w-full mt-2">
+ <input
+ type="number"
+ step="any"
+ min="0"
+ className="grow"
+ placeholder="mm"
+ value={input_value}
+ aria-label={`${label} diameter in mm`}
+ onChange={(e) => on_input_change(e.target.value)}
+ />
+ <span className="label">mm</span>
+ </label>
+ </fieldset>
+ );
+}
+
+export { CUSTOM_VALUE };
+
+````
+
+
+Created file `src/components/ResultDisplay.tsx`
+
+````tsx
+/**
+ * ResultDisplay.tsx
+ *
+ * Shows the computed circumference in several units inside a styled card.
+ * When inputs are incomplete / invalid the component shows a helpful prompt.
+ */
+
+import type { CircumferenceResult } from "../types";
+
+interface ResultDisplayProps {
+ result: CircumferenceResult | null;
+}
+
+export default function ResultDisplay({ result }: ResultDisplayProps) {
+ if (!result) {
+ return (
+ <div className="card card-border bg-base-200">
+ <div className="card-body items-center text-center">
+ <p className="text-base-content/60">
+ Select or enter both rim and tire sizes to see the circumference.
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ /** Helper to render a single stat inside the stats bar. */
+ const stat_item = (title: string, value: string | number, desc: string) => (
+ <div className="stat" key={title}>
+ <div className="stat-title">{title}</div>
+ <div className="stat-value text-lg">{value}</div>
+ <div className="stat-desc">{desc}</div>
+ </div>
+ );
+
+ return (
+ <div className="card card-border bg-base-200">
+ <div className="card-body">
+ <h2 className="card-title justify-center">Wheel Circumference</h2>
+ <div className="stats stats-vertical sm:stats-horizontal shadow w-full">
+ {stat_item("Millimeters", result.mm, "mm")}
+ {stat_item("Centimeters", result.cm, "cm")}
+ {stat_item("Inches", result.inches, "in")}
+ {stat_item("MPH Setting", result.mph_setting, "speedometer")}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+````
+
+
+Now the main App component that wires everything together:
+
+Failed creating `src/App.tsx` - File already exists
+
+
+Edited `src/App.tsx` file
+Replace entire App.tsx with the tire calculator app
+
+
+Wait, I had an issue with the emoji encoding. Let me fix that:
+
+Edited `src/App.tsx` buffer
+Fix the bicycle emoji - use literal emoji instead of escape sequence
+
+
+Now clean up the CSS — remove all the custom Vite boilerplate styles:
+
+Edited `src/index.css` file
+Strip all custom CSS from index.css, keep only Tailwind + daisyUI imports
+
+
+Delete files we no longer need:
+
+Deleted file `src/App.css`
+
+
+Deleted file `src/assets/react.svg`
+
+
+Now let me verify the final state of all new/changed files:
+
+Read file `src/App.tsx` (0 - end)
+
+
+Let me also check that `tsconfig.app.json` supports `resolveJsonModule` for the JSON import:
+
+Read file `tsconfig.app.json` (0 - end)
+
+
+The `moduleResolution: "bundler"` handles JSON imports fine with Vite. `resolveJsonModule` is implicitly true with this setup. Good.
+
+Now let me verify all the files look correct:
+
+Read file `src/components/SizeSelector.tsx` (0 - end)
+
+
+Read file `src/components/ResultDisplay.tsx` (0 - end)
+
+
+Let me do a final check on all the changes:
+
+Reading changed files
+
+
+Everything looks clean. Here's a summary of what was built:
+
+### Files created
+| File | Purpose |
+|---|---|
+| `src/data/tire_sizes.json` | Reference data for rim and tire sizes (copied from your JSON) |
+| `src/types.ts` | TypeScript interfaces for `SizeOption`, `TireSizeData`, `CircumferenceResult` |
+| `src/utils/calculate_circumference.ts` | Pure function using the roll-out formula: `3.13772 × (2t + r)` |
+| `src/components/SizeSelector.tsx` | Reusable dropdown + numeric input pair with bidirectional sync |
+| `src/components/ResultDisplay.tsx` | Shows circumference in mm, cm, inches, and MPH setting using daisyUI stats |
+
+### Files modified
+| File | Change |
+|---|---|
+| `src/App.tsx` | Replaced Vite boilerplate with the calculator wiring two `SizeSelector`s and a `ResultDisplay` |
+| `src/index.css` | Stripped custom CSS, kept only `@import "tailwindcss"` + `@plugin "daisyui"` |
+
+### Files deleted
+| File | Reason |
+|---|---|
+| `src/App.css` | No longer needed — all styling via utility classes |
+| `src/assets/react.svg` | Boilerplate asset no longer referenced |
+
+### Key behaviours
+- **Dropdown → input:** Selecting an option fills the text input with its `diameter_mm`
+- **Input → dropdown:** Typing a number auto-matches the first option with that exact `diameter_mm`, or selects "Custom" if no match
+- **Custom selected:** Choosing "Custom" from the dropdown does nothing to the text input
+- **Results:** Computed live via `useMemo` whenever either input changes — shows mm, cm, inches, and MPH setting
+
+You could try running the dev server next to verify everything renders, and consider adding unit tests for `calculate_circumference` as a good next step.
+
+## Me
+
+> Context:
+> - <rules>.rules/daisyui.md</rules>
+> - <rules>.rules/project.md</rules>
+> - <file>reference/tire-sizes.json</file>
+> - <file>reference/howto.mdown</file>
+> - <group>files</group>
+
+
diff --git a/src/App.css b/src/App.css
deleted file mode 100644
index b9d355d..0000000
--- a/src/App.css
+++ /dev/null
@@ -1,42 +0,0 @@
-#root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
-}
-
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
-}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
- }
-}
-
-.card {
- padding: 2em;
-}
-
-.read-the-docs {
- color: #888;
-}
diff --git a/src/App.tsx b/src/App.tsx
index a062428..532597a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,35 +1,123 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from '/vite.svg'
-import './App.css'
+/**
+ * App.tsx — root component for the Bicycle Wheel Circumference calculator.
+ *
+ * State management:
+ * - Each selector (rim / tire) tracks two pieces of state:
+ * 1. The currently selected dropdown label (or "__custom__").
+ * 2. The raw text-input value (kept as a string for UX).
+ * - When a dropdown option is chosen its diameter_mm fills the text input.
+ * - When the user types a number, we search for the first option whose
+ * diameter_mm matches; if none match we set the dropdown to "Custom".
+ * - The circumference result is derived (not stored) on every render.
+ */
-function App() {
- const [count, setCount] = useState(0)
+import { useState, useMemo } from "react";
+import tire_size_data from "./data/tire_sizes.json";
+import type { TireSizeData, SizeOption } from "./types";
+import SizeSelector, { CUSTOM_VALUE } from "./components/SizeSelector";
+import ResultDisplay from "./components/ResultDisplay";
+import { calculate_circumference } from "./utils/calculate_circumference";
+/** Typed reference to the imported JSON. */
+const data: TireSizeData = tire_size_data;
+
+/**
+ * Given a numeric value, find the first option whose diameter_mm matches.
+ * Returns the option's label, or CUSTOM_VALUE when nothing matches.
+ */
+function find_matching_option(value: number, options: SizeOption[]): string {
+ const match = options.find((o) => o.diameter_mm === value);
+ return match ? match.label : CUSTOM_VALUE;
+}
+
+export default function App() {
+ /* ── Rim state ─────────────────────────────────────────────── */
+ const [rim_option, set_rim_option] = useState<string>("");
+ const [rim_input, set_rim_input] = useState<string>("");
+
+ /* ── Tire state ────────────────────────────────────────────── */
+ const [tire_option, set_tire_option] = useState<string>("");
+ const [tire_input, set_tire_input] = useState<string>("");
+
+ /* ── Derived circumference result ──────────────────────────── */
+ const result = useMemo(
+ () =>
+ calculate_circumference(parseFloat(rim_input), parseFloat(tire_input)),
+ [rim_input, tire_input],
+ );
+
+ /* ── Handlers: Rim ─────────────────────────────────────────── */
+ const handle_rim_option = (label: string) => {
+ set_rim_option(label);
+ // When "Custom" is chosen, leave the text input alone.
+ if (label === CUSTOM_VALUE) return;
+ const match = data.wheel_sizes.find((o) => o.label === label);
+ if (match) set_rim_input(String(match.diameter_mm));
+ };
+
+ const handle_rim_input = (raw: string) => {
+ set_rim_input(raw);
+ const num = parseFloat(raw);
+ if (isNaN(num)) {
+ set_rim_option(CUSTOM_VALUE);
+ return;
+ }
+ set_rim_option(find_matching_option(num, data.wheel_sizes));
+ };
+
+ /* ── Handlers: Tire ────────────────────────────────────────── */
+ const handle_tire_option = (label: string) => {
+ set_tire_option(label);
+ if (label === CUSTOM_VALUE) return;
+ const match = data.tire_sizes.find((o) => o.label === label);
+ if (match) set_tire_input(String(match.diameter_mm));
+ };
+
+ const handle_tire_input = (raw: string) => {
+ set_tire_input(raw);
+ const num = parseFloat(raw);
+ if (isNaN(num)) {
+ set_tire_option(CUSTOM_VALUE);
+ return;
+ }
+ set_tire_option(find_matching_option(num, data.tire_sizes));
+ };
+
+ /* ── Render ────────────────────────────────────────────────── */
return (
- <>
- <div>
- <a href="https://vite.dev" target="_blank">
- <img src={viteLogo} className="logo" alt="Vite logo" />
- </a>
- <a href="https://react.dev" target="_blank">
- <img src={reactLogo} className="logo react" alt="React logo" />
- </a>
- </div>
- <h1 className="bg-red-500">Vite + React</h1>
- <div className="card">
- <button onClick={() => setCount((count) => count + 1)}>
- count is {count}
- </button>
- <p>
- Edit <code>src/App.tsx</code> and save to test HMR
- </p>
+ <div className="min-h-screen flex items-center justify-center p-4">
+ <div className="card card-border bg-base-100 shadow-xl w-full max-w-lg">
+ <div className="card-body gap-6">
+ <h1 className="card-title justify-center text-2xl">
+ Tire Size Calculator
+ </h1>
+
+ {/* Rim size selector */}
+ <SizeSelector
+ label="Rim Size"
+ options={data.wheel_sizes}
+ selected_option={rim_option}
+ input_value={rim_input}
+ on_option_change={handle_rim_option}
+ on_input_change={handle_rim_input}
+ />
+
+ {/* Tire size selector */}
+ <SizeSelector
+ label="Tire Size"
+ options={data.tire_sizes}
+ selected_option={tire_option}
+ input_value={tire_input}
+ on_option_change={handle_tire_option}
+ on_input_change={handle_tire_input}
+ />
+
+ <div className="divider">Results</div>
+
+ {/* Computed output */}
+ <ResultDisplay result={result} />
+ </div>
</div>
- <p className="read-the-docs">
- Click on the Vite and React logos to learn more
- </p>
- </>
- )
+ </div>
+ );
}
-
-export default App
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> \ No newline at end of file
diff --git a/src/components/ResultDisplay.tsx b/src/components/ResultDisplay.tsx
new file mode 100644
index 0000000..3f3f81a
--- /dev/null
+++ b/src/components/ResultDisplay.tsx
@@ -0,0 +1,48 @@
+/**
+ * ResultDisplay.tsx
+ *
+ * Shows the computed circumference in several units inside a styled card.
+ * When inputs are incomplete / invalid the component shows a helpful prompt.
+ */
+
+import type { CircumferenceResult } from "../types";
+
+interface ResultDisplayProps {
+ result: CircumferenceResult | null;
+}
+
+export default function ResultDisplay({ result }: ResultDisplayProps) {
+ if (!result) {
+ return (
+ <div className="card card-border bg-base-200">
+ <div className="card-body items-center text-center">
+ <p className="text-base-content/60">
+ Select or enter both rim and tire sizes to see the circumference.
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ /** Helper to render a single stat inside the stats bar. */
+ const stat_item = (title: string, value: string | number, desc: string) => (
+ <div className="stat" key={title}>
+ <div className="stat-title">{title}</div>
+ <div className="stat-value text-lg">{value}</div>
+ <div className="stat-desc">{desc}</div>
+ </div>
+ );
+
+ return (
+ <div className="card card-border bg-base-200">
+ <div className="card-body">
+ <h2 className="card-title justify-center">Wheel Circumference</h2>
+ <div className="stats stats-vertical sm:stats-horizontal shadow w-full">
+ {stat_item("Millimeters", result.mm, "mm")}
+ {stat_item("Centimeters", result.cm, "cm")}
+ {stat_item("Inches", result.inches, "in")}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/SizeSelector.tsx b/src/components/SizeSelector.tsx
new file mode 100644
index 0000000..cb4baef
--- /dev/null
+++ b/src/components/SizeSelector.tsx
@@ -0,0 +1,81 @@
+/**
+ * SizeSelector.tsx
+ *
+ * A reusable pair of <select> + <input> for choosing a rim or tire size.
+ *
+ * Behaviour:
+ * - Selecting a dropdown option fills the text input with its diameter_mm.
+ * - Typing a number in the text input auto-selects the first matching
+ * dropdown option, or falls back to "Custom" if no match exists.
+ * - Choosing "Custom" in the dropdown does nothing to the text input.
+ */
+
+import type { SizeOption } from "../types";
+
+/** Sentinel value used as the <option> value for "Custom". */
+const CUSTOM_VALUE = "__custom__";
+
+interface SizeSelectorProps {
+ /** Human-readable label shown above the controls. */
+ label: string;
+ /** The list of predefined size options to populate the dropdown. */
+ options: SizeOption[];
+ /** Currently selected dropdown value (a label string or CUSTOM_VALUE). */
+ selected_option: string;
+ /** Current numeric value in the text input (as a string so we can allow empty). */
+ input_value: string;
+ /** Called when the dropdown selection changes. */
+ on_option_change: (option_value: string) => void;
+ /** Called when the text input value changes. */
+ on_input_change: (value: string) => void;
+}
+
+export default function SizeSelector({
+ label,
+ options,
+ selected_option,
+ input_value,
+ on_option_change,
+ on_input_change,
+}: SizeSelectorProps) {
+ return (
+ <fieldset className="fieldset">
+ <legend className="fieldset-legend">{label}</legend>
+
+ {/* Dropdown for predefined sizes */}
+ <select
+ className="select select-bordered w-full"
+ value={selected_option}
+ aria-label={`${label} preset`}
+ onChange={(e) => on_option_change(e.target.value)}
+ >
+ <option disabled value="">
+ Pick a size
+ </option>
+ {options.map((opt, idx) => (
+ <option key={`${opt.label}-${idx}`} value={opt.label}>
+ {opt.label} ({opt.diameter_mm} mm)
+ </option>
+ ))}
+ <option value={CUSTOM_VALUE}>Custom</option>
+ </select>
+
+ {/* Numeric text input — mirrors / overrides the dropdown */}
+ <label className="input w-full mt-2">
+ <input
+ type="number"
+ step="any"
+ min="0"
+ className="grow"
+ placeholder="mm"
+ value={input_value}
+ aria-label={`${label} diameter in mm`}
+ onChange={(e) => on_input_change(e.target.value)}
+ />
+ <span className="label">mm</span>
+ </label>
+ </fieldset>
+ );
+}
+
+export { CUSTOM_VALUE };
diff --git a/src/data/tire_sizes.json b/src/data/tire_sizes.json
new file mode 100644
index 0000000..d101fc8
--- /dev/null
+++ b/src/data/tire_sizes.json
@@ -0,0 +1,74 @@
+{
+ "wheel_sizes": [
+ { "label": "12.5\"", "diameter_mm": 203 },
+ { "label": "14\"", "diameter_mm": 254 },
+ { "label": "16\"", "diameter_mm": 305 },
+ { "label": "20\"", "diameter_mm": 406 },
+ { "label": "24\" BMX/Kids Bikes", "diameter_mm": 507 },
+ { "label": "24\" Terry Bicycles", "diameter_mm": 520 },
+ { "label": "24\"", "diameter_mm": 540 },
+ { "label": "24\" Schwinn", "diameter_mm": 540 },
+ { "label": "26\"", "diameter_mm": 559 },
+ { "label": "650c", "diameter_mm": 571 },
+ { "label": "650b", "diameter_mm": 584 },
+ { "label": "27.5", "diameter_mm": 584 },
+ { "label": "26 x 1 1/2\"", "diameter_mm": 584 },
+ { "label": "650a", "diameter_mm": 590 },
+ { "label": "26 x 1 3/8\"", "diameter_mm": 590 },
+ { "label": "650", "diameter_mm": 597 },
+ { "label": "26 x 1 1/4\"", "diameter_mm": 597 },
+ { "label": "29er", "diameter_mm": 622 },
+ { "label": "700c", "diameter_mm": 622 },
+ { "label": "27 inch", "diameter_mm": 630 }
+ ],
+ "tire_sizes": [
+ { "label": "18C", "diameter_mm": 18 },
+ { "label": "19C", "diameter_mm": 19 },
+ { "label": "20C", "diameter_mm": 20 },
+ { "label": "21C", "diameter_mm": 21 },
+ { "label": "23C", "diameter_mm": 23 },
+ { "label": "25C", "diameter_mm": 25 },
+ { "label": "26C", "diameter_mm": 26 },
+ { "label": "28C", "diameter_mm": 28 },
+ { "label": "30C", "diameter_mm": 30 },
+ { "label": "32C", "diameter_mm": 32 },
+ { "label": "35C", "diameter_mm": 35 },
+ { "label": "38C", "diameter_mm": 38 },
+ { "label": "40C", "diameter_mm": 40 },
+ { "label": "44C", "diameter_mm": 44 },
+ { "label": "50C", "diameter_mm": 50 },
+ { "label": "56C", "diameter_mm": 56 },
+ { "label": "1.00 inch", "diameter_mm": 25.4 },
+ { "label": "1 1/8 inch", "diameter_mm": 28.575 },
+ { "label": "1.25 inch", "diameter_mm": 31.75 },
+ { "label": "1 3/8 inch", "diameter_mm": 34.925 },
+ { "label": "1.4 inch", "diameter_mm": 35.56 },
+ { "label": "1.5 inch", "diameter_mm": 38.1 },
+ { "label": "1.75 inch", "diameter_mm": 44.45 },
+ { "label": "1.95 inch", "diameter_mm": 49.5 },
+ { "label": "2.00 inch", "diameter_mm": 50.8 },
+ { "label": "2.10 inch", "diameter_mm": 53.34 },
+ { "label": "2.125 inch", "diameter_mm": 54 },
+ { "label": "2.20 inch", "diameter_mm": 55.88 },
+ { "label": "2.25 inch", "diameter_mm": 57.15 },
+ { "label": "2.30 inch", "diameter_mm": 58.42 },
+ { "label": "2.35 inch", "diameter_mm": 59.69 },
+ { "label": "2.40 inch", "diameter_mm": 60.69 },
+ { "label": "2.50 inch", "diameter_mm": 63.5 },
+ { "label": "2.60 inch", "diameter_mm": 66.04 },
+ { "label": "2.70 inch", "diameter_mm": 68.58 },
+ { "label": "2.80 inch", "diameter_mm": 71.12 },
+ { "label": "2.90 inch", "diameter_mm": 73.66 },
+ { "label": "3.0 inch", "diameter_mm": 76.2 },
+ { "label": "3.1 inch", "diameter_mm": 78.74 },
+ { "label": "3.2 inch", "diameter_mm": 81.28 },
+ { "label": "3.3 inch", "diameter_mm": 83.82 },
+ { "label": "3.4 inch", "diameter_mm": 86.36 },
+ { "label": "3.5 inch", "diameter_mm": 88.9 },
+ { "label": "3.6 inch", "diameter_mm": 91.44 },
+ { "label": "3.7 inch", "diameter_mm": 93.98 },
+ { "label": "3.8 inch", "diameter_mm": 96.52 },
+ { "label": "3.9 inch", "diameter_mm": 99.06 },
+ { "label": "4.0 inch", "diameter_mm": 101.6 }
+ ]
+}
diff --git a/src/index.css b/src/index.css
index 34900d7..91f6dbe 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,71 +1,3 @@
+/* Tailwind CSS base + daisyUI plugin — all styling is done via utility classes */
@import "tailwindcss";
@plugin "daisyui";
-
-:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
-
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
-}
-
-body {
- margin: 0;
- display: flex;
- place-items: center;
- min-width: 320px;
- min-height: 100vh;
-}
-
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
-}
-
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
-}
-button:hover {
- border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
-}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..75d6945
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,26 @@
+/**
+ * Type definitions for the tire size data used throughout the app.
+ * Each size entry has a human-readable label and a diameter in millimeters.
+ */
+
+/** A single rim or tire size option from our reference data. */
+export interface SizeOption {
+ label: string;
+ diameter_mm: number;
+}
+
+/** The shape of the imported tire_sizes.json file. */
+export interface TireSizeData {
+ wheel_sizes: SizeOption[];
+ tire_sizes: SizeOption[];
+}
+
+/**
+ * Results from the circumference calculation.
+ * All values derived from: circumference = 3.13772 * (2 * tire + rim)
+ */
+export interface CircumferenceResult {
+ mm: number;
+ cm: number;
+ inches: number;
+}
diff --git a/src/utils/calculate_circumference.ts b/src/utils/calculate_circumference.ts
new file mode 100644
index 0000000..4ef6a2f
--- /dev/null
+++ b/src/utils/calculate_circumference.ts
@@ -0,0 +1,36 @@
+/**
+ * calculate_circumference.ts
+ *
+ * Pure function that computes the bicycle wheel circumference from rim and
+ * tire diameters (both in mm). The formula uses 3.13772 instead of exact π
+ * to approximate the "roll-out" circumference (tire compressed under rider
+ * weight).
+ *
+ * Formula: circumference_mm = 3.13772 × (2 × tire_diameter + rim_diameter)
+ */
+
+import type { CircumferenceResult } from "../types";
+
+/** The modified Pi constant that accounts for tire compression. */
+const ROLLOUT_PI = 3.13772;
+
+/**
+ * Returns circumference in multiple units, or null when inputs are invalid
+ * (non-positive or missing).
+ */
+export function calculate_circumference(
+ rim_mm: number,
+ tire_mm: number,
+): CircumferenceResult | null {
+ if (rim_mm <= 0 || tire_mm <= 0 || isNaN(rim_mm) || isNaN(tire_mm)) {
+ return null;
+ }
+
+ const circ_mm = ROLLOUT_PI * (2 * tire_mm + rim_mm);
+
+ return {
+ mm: Math.round(circ_mm),
+ cm: Math.round(circ_mm / 10),
+ inches: Math.round((10 * circ_mm) / 25.4) / 10,
+ };
+}