Files
bikeApp/loam-mtb-app.jsx
T
2026-05-19 10:40:16 +02:00

1310 lines
75 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from "react";
// ══════════════════════════════════════════════════════════════════════════════
// STYLES — deep forest aesthetic
// ══════════════════════════════════════════════════════════════════════════════
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;700&display=swap');
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#070f04; --bg2:#0d160a; --bg3:#131e0e;
--panel:#192512; --panel2:#1e2d16;
--bdr:#233518; --bdr2:#334825; --bdr3:#4a6535;
--fern:#5a9635; --sage:#7ab840; --lime:#a0d855;
--moss:#3d6e22; --bark:#6a4218; --earth:#a06828;
--txt:#b8cca0; --txt2:#7aaa55; --txt3:#4a6835; --txt4:#2e4220;
--red:#b83828; --amb:#c07820;
--fD:'Bebas Neue',sans-serif;
--fM:'IBM Plex Mono',monospace;
}
body{background:var(--bg);color:var(--txt);font-family:var(--fM);min-height:100vh;overflow-x:hidden}
/* Grain */
body::before{content:'';position:fixed;inset:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.04'/%3E%3C/svg%3E");pointer-events:none;z-index:9998;opacity:.6}
.app{max-width:1000px;margin:0 auto;padding:0 14px 80px}
/* Header */
.hdr{padding:22px 0 16px;border-bottom:1px solid var(--bdr);margin-bottom:18px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.logo{display:flex;align-items:center;gap:10px}
.logo-leaf{width:40px;height:40px;background:var(--fern);clip-path:polygon(50%0%,85%15%,100%50%,85%85%,50%100%,15%85%,0%50%,15%15%);display:flex;align-items:center;justify-content:center;font-family:var(--fD);font-size:22px;color:#050c02;line-height:1;flex-shrink:0}
.logo h1{font-family:var(--fD);font-size:clamp(28px,7vw,44px);letter-spacing:5px;line-height:1;color:var(--txt)}
.hdr-sub{font-size:9px;letter-spacing:3px;text-transform:uppercase;color:var(--fern);margin-left:auto;align-self:flex-end;padding-bottom:3px}
/* Tabs */
.tabs{display:flex;gap:2px;margin-bottom:20px;background:var(--bg2);border:1px solid var(--bdr);padding:3px;overflow-x:auto}
.tab{flex:1;min-width:70px;padding:8px 4px;background:transparent;border:none;cursor:pointer;font-family:var(--fD);font-size:clamp(12px,2vw,15px);letter-spacing:1.5px;text-transform:uppercase;color:var(--txt4);transition:all .15s;white-space:nowrap;text-align:center}
.tab:hover{color:var(--txt3);background:var(--bg3)}
.tab.on{background:var(--fern);color:#050c02}
/* Buttons */
.btn{padding:8px 14px;border:1px solid var(--bdr2);background:var(--bg3);color:var(--txt3);font-family:var(--fM);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;cursor:pointer;transition:all .15s}
.btn:hover{border-color:var(--fern);color:var(--sage)}
.btn:disabled{opacity:.35;cursor:not-allowed}
.btn-p{background:var(--fern);color:#050c02;border-color:var(--fern);font-weight:700}
.btn-p:hover{background:var(--sage);border-color:var(--sage);color:#050c02}
.btn-sm{padding:5px 9px;font-size:9px}
.btn-d:hover{border-color:var(--red);color:var(--red)}
/* Forms */
.field{display:flex;flex-direction:column;gap:4px}
.field label{font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--txt3);font-weight:700}
.field input,.field select,.field textarea{background:var(--bg2);border:1px solid var(--bdr);color:var(--txt);font-family:var(--fM);font-size:12px;padding:8px 10px;outline:none;transition:border-color .15s;width:100%}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--fern)}
.field select{cursor:pointer}
.field select option{background:var(--bg2)}
.field textarea{resize:vertical;min-height:70px;line-height:1.5}
.field .hint{font-size:9px;color:var(--txt4);letter-spacing:.5px;margin-top:2px}
/* Grid */
.g2{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.g3{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
.g4{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px}
.full{grid-column:1/-1}
/* Section header */
.sH{font-family:var(--fD);font-size:12px;letter-spacing:2.5px;color:var(--txt3);padding:16px 0 8px;border-bottom:1px solid var(--bdr);margin-bottom:10px;display:flex;align-items:center;gap:8px}
.sH::before{content:'';display:block;width:3px;height:12px;background:var(--fern);flex-shrink:0}
/* Component card */
.cCard{background:var(--bg3);border:1px solid var(--bdr);padding:12px;cursor:pointer;transition:all .2s;position:relative;user-select:none}
.cCard:hover{border-color:var(--moss);background:var(--panel)}
.cCard.sel{border-color:var(--fern);background:var(--panel)}
.cCard .cBrand{font-size:8px;letter-spacing:2px;text-transform:uppercase;color:var(--txt3)}
.cCard .cModel{font-family:var(--fD);font-size:19px;letter-spacing:1px;color:var(--txt);line-height:1.1;margin:2px 0 6px}
.cCard .cTags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
.cCard .cTag{font-size:8px;letter-spacing:1px;text-transform:uppercase;padding:2px 5px;background:var(--bg2);border:1px solid var(--bdr2);color:var(--txt4)}
.cCard .cRec{border-color:var(--fern);color:var(--fern)}
.cCard .cCheck{position:absolute;top:8px;right:8px;width:14px;height:14px;background:var(--fern);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:#050c02;font-weight:900;line-height:1}
.cCard .cNote{font-size:9px;color:var(--txt3);line-height:1.4;margin-top:6px;border-top:1px solid var(--bdr);padding-top:6px}
/* Rider profile */
.riderBar{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;background:var(--panel);border:1px solid var(--bdr);padding:14px;margin-bottom:18px}
/* Slider */
.slW{display:flex;flex-direction:column;gap:4px}
.slH{display:flex;justify-content:space-between;align-items:baseline}
.slH span:first-child{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--txt3);font-weight:700}
.slV{font-family:var(--fD);font-size:18px;letter-spacing:1px;color:var(--sage)}
.slU{font-size:9px;color:var(--txt3);margin-left:2px;font-family:var(--fM)}
input[type=range]{width:100%;-webkit-appearance:none;appearance:none;height:2px;background:var(--bdr2);outline:none;cursor:pointer;margin:4px 0}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;background:var(--fern);cursor:pointer;clip-path:polygon(50%0%,100%50%,50%100%,0%50%)}
/* Spec blocks */
.specs{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px;margin:10px 0}
.spB{background:var(--bg2);border:1px solid var(--bdr);padding:10px 12px}
.spL{font-size:8px;letter-spacing:1.5px;text-transform:uppercase;color:var(--txt3);margin-bottom:3px}
.spV{font-family:var(--fD);font-size:22px;letter-spacing:1px;color:var(--sage);line-height:1}
.spU{font-size:9px;color:var(--txt3);margin-left:2px;font-family:var(--fM)}
/* Meter bars (grip, feel etc) */
.mBar{display:flex;align-items:center;gap:8px;margin:3px 0}
.mLabel{font-size:9px;color:var(--txt3);width:90px;letter-spacing:.5px;text-transform:uppercase;flex-shrink:0}
.mTrack{flex:1;height:4px;background:var(--bdr2);border-radius:0;overflow:hidden}
.mFill{height:100%;transition:width .3s}
/* AI output */
.aiOut{background:var(--bg2);border:1px solid var(--bdr);border-left:3px solid var(--fern);padding:14px 16px;font-size:12px;line-height:1.9;color:var(--txt);white-space:pre-wrap;max-height:420px;overflow-y:auto;margin-top:12px}
.thinking{display:flex;align-items:center;gap:6px;color:var(--fern);font-size:11px;letter-spacing:1px;padding:12px 0}
.dot{width:6px;height:6px;background:var(--fern);border-radius:50%;animation:bl 1.2s infinite}
.dot:nth-child(2){animation-delay:.2s}.dot:nth-child(3){animation-delay:.4s}
@keyframes bl{0%,100%{opacity:1}50%{opacity:.15}}
/* Garage */
.gGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}
.gCard{background:var(--panel);border:1px solid var(--bdr);border-left:3px solid var(--moss);padding:14px;cursor:pointer;transition:all .15s}
.gCard:hover{border-left-color:var(--fern);background:var(--panel2)}
.gBike{font-family:var(--fD);font-size:20px;letter-spacing:1px;text-transform:uppercase;color:var(--txt)}
.gSub{font-size:10px;color:var(--txt3);letter-spacing:1px;margin:2px 0 8px}
.gPills{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
.pill{display:inline-block;font-size:8px;letter-spacing:1px;text-transform:uppercase;padding:2px 6px;background:var(--bg3);border:1px solid var(--bdr2);color:var(--txt4)}
.pill-g{border-color:var(--moss);color:var(--fern)}
.pill-e{border-color:var(--earth);color:var(--amb)}
/* Toast */
.toast{position:fixed;bottom:20px;right:18px;background:var(--fern);color:#050c02;font-family:var(--fM);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:9px 16px;z-index:10000;animation:ti .2s ease}
@keyframes ti{from{transform:translateY(14px);opacity:0}to{transform:translateY(0);opacity:1}}
/* Notice */
.notice{font-size:10px;color:var(--txt3);line-height:1.6;padding:10px 12px;background:var(--bg3);border-left:2px solid var(--bdr3)}
/* Feel slider */
.feelRow{display:grid;grid-template-columns:120px 1fr 32px;gap:8px;align-items:center;margin:6px 0}
.feelLabel{font-size:9px;letter-spacing:1px;text-transform:uppercase;color:var(--txt3)}
.feelScore{font-size:12px;font-weight:700;color:var(--sage);text-align:right;font-family:var(--fD)}
/* Misc */
.flex{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.flexE{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap}
.mt{margin-top:12px} .mt2{margin-top:20px} .mb{margin-bottom:10px}
.divider{height:1px;background:var(--bdr);margin:18px 0}
.empty{text-align:center;padding:50px 20px;color:var(--txt3)}
.empty-i{font-size:42px;margin-bottom:12px;opacity:.3}
.empty h3{font-family:var(--fD);font-size:22px;letter-spacing:2px;text-transform:uppercase;color:var(--txt2);margin-bottom:6px}
.empty p{font-size:11px;line-height:1.6}
@media(max-width:540px){
.tabs{flex-wrap:wrap}
.tab{flex:none}
.g2,.g3,.g4{grid-template-columns:1fr}
.riderBar{grid-template-columns:1fr 1fr}
.feelRow{grid-template-columns:80px 1fr 28px}
}
`;
// ══════════════════════════════════════════════════════════════════════════════
// COMPONENT DATABASE (curated from manufacturer specs + community data)
// ══════════════════════════════════════════════════════════════════════════════
const FRAMES = [
{ id:'tr-sentinel', brand:'Transition', model:'Sentinel', travel:140, ha:64.0, color:'#e07020', geo:'enduro',
bestFor:['enduro','trail'], desc:'Aggressive trail / light enduro. Progressive geometry, great for technical terrain.' },
{ id:'tr-scout', brand:'Transition', model:'Scout', travel:130, ha:65.5, color:'#2060c8', geo:'trail',
bestFor:['trail','xc'], desc:'Playful short-travel trail weapon. Efficient climber, fun descender.' },
{ id:'no-sight', brand:'Norco', model:'Sight VLT', travel:140, ha:64.0, color:'#c02020', geo:'enduro',
bestFor:['enduro','trail'], desc:'Full-power e-MTB enduro. Long, low, slack geometry. (Electric)' },
{ id:'ca-spectral', brand:'Canyon', model:'Spectral', travel:140, ha:64.5, color:'#1a1a1a', geo:'trail',
bestFor:['trail','enduro'], desc:'Versatile all-mountain. Balanced pedaling efficiency and descending capability.' },
{ id:'la-coal', brand:'Last', model:'Coal', travel:150, ha:63.5, color:'#384040', geo:'enduro',
bestFor:['enduro'], desc:'Austrian enduro machine. Very long and slack, huge travel, shreds everything.' },
{ id:'ev-calling', brand:'Evil', model:'The Calling', travel:140, ha:65.0, color:'#c02838', geo:'trail',
bestFor:['trail','enduro'], desc:'High-performance trail bike. Sharp, responsive handling with huge capability.' },
];
const FORKS = [
{ id:'mn-mezzer', brand:'Manitou', model:'Mezzer', travels:[140,150,160], damper:'IsoSpeed', fcolor:'#9090a0',
psi:{ lt:[60,72], md:[70,84], hv:[82,100] }, sag:{ xc:20, trail:25, enduro:27 },
reb:{ lt:14, md:11, hv:9 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'Superb small-bump. IsoSpeed damper is very tunable. Lighter than Fox/RS alternatives.' },
{ id:'fx-38', brand:'Fox', model:'38 Factory', travels:[160,170,180], damper:'GRIP2', fcolor:'#f06010',
psi:{ lt:[65,78], md:[76,92], hv:[88,110] }, sag:{ xc:20, trail:25, enduro:28 },
reb:{ lt:13, md:11, hv:8 }, lsc:{ lt:6, md:8, hv:10 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'Enduro/DH dedicated. Very stiff chassis. GRIP2 is industry-leading damper. Run 25% sag, low rebound.' },
{ id:'fx-36', brand:'Fox', model:'36 Factory', travels:[140,150,160], damper:'GRIP2', fcolor:'#f06010',
psi:{ lt:[60,74], md:[70,86], hv:[82,102] }, sag:{ xc:20, trail:25, enduro:27 },
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:1, hv:2 },
notes:'All-mountain workhorse. Best balance of weight, stiffness and feel. Most popular enduro fork.' },
{ id:'fx-34', brand:'Fox', model:'34 Factory', travels:[120,130,140], damper:'GRIP2', fcolor:'#f06010',
psi:{ lt:[55,68], md:[64,80], hv:[76,94] }, sag:{ xc:20, trail:22, enduro:25 },
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:0, hv:1 },
notes:'Lightweight XC/trail fork. Best paired with lighter frames and short-travel builds.' },
{ id:'rs-zeb', brand:'RockShox', model:'ZEB Ultimate', travels:[150,160,170,180], damper:'DebonAir+', fcolor:'#c8a010',
psi:{ lt:[62,76], md:[72,88], hv:[84,106] }, sag:{ xc:20, trail:25, enduro:28 },
reb:{ lt:14, md:12, hv:10 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'DebonAir+ gives plush small-bump feel. Slightly more supple than Fox 38 for trail. Great enduro choice.' },
{ id:'rs-lyrik', brand:'RockShox', model:'Lyrik Ultimate', travels:[140,150,160], damper:'DebonAir+', fcolor:'#c8a010',
psi:{ lt:[58,72], md:[68,83], hv:[80,100] }, sag:{ xc:20, trail:25, enduro:28 },
reb:{ lt:14, md:12, hv:10 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:1, hv:2 },
notes:'Best all-rounder in its travel range. Plush + supportive. Pairs perfectly with Super Deluxe shock.' },
{ id:'rs-pike', brand:'RockShox', model:'Pike Ultimate', travels:[120,130,140], damper:'DebonAir+', fcolor:'#c8a010',
psi:{ lt:[55,68], md:[64,78], hv:[76,93] }, sag:{ xc:20, trail:22, enduro:25 },
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:3, md:5, hv:7 }, hsc:{ lt:2, md:3, hv:3 }, tok:{ lt:0, md:0, hv:1 },
notes:'Lightweight, responsive. Great XC/trail. Zero tokens to start — very progressive air spring.' },
];
const SHOCKS = [
{ id:'fx-x2', brand:'Fox', model:'X2 Factory', type:'air', hsr:true, hsc:true, scolor:'#f06010',
psi:{ lt:[140,158], md:[158,185], hv:[185,212] }, sag:{ xc:25, trail:28, enduro:31 },
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'Top Fox air shock. 4-way adjust (HSC/LSC/HSR/LSR). Best for technical high-speed terrain.' },
{ id:'fx-fx', brand:'Fox', model:'Float X Factory', type:'air', hsr:false, hsc:false, scolor:'#f06010',
psi:{ lt:[132,152], md:[152,178], hv:[178,205] }, sag:{ xc:25, trail:28, enduro:30 },
reb:{ lt:11, md:9, hv:7 }, lsc:{ lt:5, md:7, hv:9 }, hsc:null, tok:{ lt:0, md:1, hv:2 },
notes:'Simplified 2-way. Easier to set up than X2. Still very capable for trail/enduro use.' },
{ id:'rs-vivid', brand:'RockShox', model:'Vivid Air Ultimate', type:'air', hsr:true, hsc:true, scolor:'#c8a010',
psi:{ lt:[128,150], md:[150,178], hv:[178,208] }, sag:{ xc:25, trail:28, enduro:32 },
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'More supple off the top than Fox. DebonAir air spring pairs perfectly with Lyrik/ZEB fork.' },
{ id:'rs-sdu-air', brand:'RockShox', model:'Super Deluxe Ultimate', type:'air', hsr:false, hsc:true, scolor:'#c8a010',
psi:{ lt:[135,158], md:[158,184], hv:[184,212] }, sag:{ xc:25, trail:28, enduro:31 },
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:3, md:5, hv:7 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
notes:'Proven OEM workhorse. Consistent, reliable. HSC/LSC on Factory version. Great trail shock.' },
{ id:'rs-sdu-coil', brand:'RockShox', model:'Super Deluxe Coil Ultimate', type:'coil', hsr:false, hsc:true, scolor:'#c8a010',
sag:{ xc:30, trail:33, enduro:36 },
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:3, md:4, hv:5 },
spring:{ lt:'350400 lb/in', md:'400500 lb/in', hv:'500600 lb/in' },
notes:'Coil = bottomless feel, no harshness on big hits. Select spring: lighter = softer. Requires respring to tune.' },
];
const TIRES = {
front: [
{ id:'mx-assegai-f', brand:'Maxxis', model:'Assegai', w:2.5, grip:10, roll:3, best:['enduro','dh','wet'],
psi:{ lt:[17,22], md:[21,26], hv:[25,30] }, notes:'Max grip, minimum rolling speed. Dominant enduro front tire worldwide.' },
{ id:'mx-dissector-f', brand:'Maxxis', model:'Dissector', w:2.4, grip:7, roll:7, best:['trail','xc','dry'],
psi:{ lt:[20,24], md:[23,28], hv:[27,32] }, notes:'Fast rolling, good dry grip. Best paired with Assegai/DHF.' },
{ id:'mx-minion-f', brand:'Maxxis', model:'Minion DHF', w:2.5, grip:9, roll:5, best:['enduro','trail','mixed'],
psi:{ lt:[18,23], md:[22,27], hv:[25,30] }, notes:'The benchmark. Predictable and capable in all conditions.' },
{ id:'sw-mary-f', brand:'Schwalbe', model:'Magic Mary', w:2.4, grip:10, roll:3, best:['enduro','dh','wet'],
psi:{ lt:[16,21], md:[20,25], hv:[24,29] }, notes:'Extraordinary wet traction. Soft compound, slow but grippy.' },
{ id:'sw-albert-f', brand:'Schwalbe', model:'Albert Radial', w:2.35, grip:8, roll:6, best:['trail','enduro'],
psi:{ lt:[19,24], md:[22,27], hv:[26,30] }, notes:'Radial casing = excellent suppleness. Versatile all-rounder.' },
{ id:'sp-elim-f', brand:'Specialized', model:'Eliminator', w:2.3, grip:7, roll:7, best:['trail','xc'],
psi:{ lt:[21,26], md:[24,29], hv:[28,33] }, notes:'Efficient XC/trail option. Good cornering, lower grip in wet.' },
{ id:'sp-butch-f', brand:'Specialized', model:'Butcher', w:2.3, grip:8, roll:6, best:['enduro','trail'],
psi:{ lt:[20,25], md:[23,28], hv:[26,31] }, notes:'Aggressive but not max grip. Solid daily enduro tire.' },
],
rear: [
{ id:'mx-assegai-r', brand:'Maxxis', model:'Assegai', w:2.5, grip:9, roll:4, best:['enduro','dh'],
psi:{ lt:[22,27], md:[25,30], hv:[29,34] }, notes:'Heavy but grippy rear. For very tech/loose terrain.' },
{ id:'mx-dissector-r', brand:'Maxxis', model:'Dissector', w:2.4, grip:7, roll:8, best:['trail','xc','dry'],
psi:{ lt:[22,27], md:[26,31], hv:[30,35] }, notes:'Recommended combo: Dissector rear + Assegai front.' },
{ id:'mx-minion-r', brand:'Maxxis', model:'Minion DHR II', w:2.4, grip:8, roll:6, best:['enduro','trail','mixed'],
psi:{ lt:[21,26], md:[24,29], hv:[28,33] }, notes:'Classic workhorse rear. Balanced traction and rolling speed.' },
{ id:'sw-mary-r', brand:'Schwalbe', model:'Magic Mary', w:2.4, grip:9, roll:3, best:['enduro','dh','wet'],
psi:{ lt:[20,25], md:[23,28], hv:[27,32] }, notes:'Wet conditions specialist rear. Very slow rolling.' },
{ id:'sw-albert-r', brand:'Schwalbe', model:'Albert Radial', w:2.35, grip:7, roll:7, best:['trail','enduro'],
psi:{ lt:[21,26], md:[24,29], hv:[27,32] }, notes:'Supple and forgiving. Good all-conditions rear option.' },
{ id:'sp-elim-r', brand:'Specialized', model:'Eliminator', w:2.3, grip:7, roll:8, best:['trail','xc'],
psi:{ lt:[23,28], md:[26,31], hv:[30,35] }, notes:'Fast XC rear. Good on hard-pack and gravel mix.' },
{ id:'sp-butch-r', brand:'Specialized', model:'Butcher', w:2.3, grip:8, roll:7, best:['enduro','trail'],
psi:{ lt:[21,26], md:[24,29], hv:[27,33] }, notes:'Balanced grip+roll. Reliable enduro/trail rear choice.' },
],
};
const DISC = ['XC / Marathon', 'Trail', 'Enduro', 'DH / Park'];
const LEVELS = ['Beginner', 'Intermediate', 'Advanced', 'Expert / Racer'];
const STYLES_R = ['Smooth & Pedally', 'Aggressive / Attack', 'Tech-focused', 'Flow & Jump', 'Mixed'];
// ══════════════════════════════════════════════════════════════════════════════
// HELPERS
// ══════════════════════════════════════════════════════════════════════════════
const wCat = (kg) => kg < 65 ? 'lt' : kg < 85 ? 'md' : 'hv';
const dCat = (d) => d.toLowerCase().includes('xc') ? 'xc' : d.toLowerCase().includes('enduro') || d.toLowerCase().includes('dh') ? 'enduro' : 'trail';
function isRecFork(fork, rider) {
const dc = dCat(rider.disc);
const wc = wCat(rider.weight);
const minT = dc === 'enduro' ? 150 : dc === 'trail' ? 130 : 110;
const maxT = dc === 'xc' ? 140 : 200;
return fork.travels.some(t => t >= minT && t <= maxT);
}
function isRecShock(shock, rider) {
const dc = dCat(rider.disc);
if (dc === 'xc' && shock.type === 'coil') return false;
if (dc === 'dh' && shock.id === 'fx-fx') return false;
return true;
}
function isRecFrame(frame, rider) {
const dc = dCat(rider.disc);
return frame.bestFor.some(b => b === dc || b === 'trail');
}
function isRecTire(tire, rider, pos) {
const dc = dCat(rider.disc);
return tire.best.some(b => b === dc || (pos === 'front' && b === 'mixed'));
}
function getSetupDefaults(build, rider) {
const wc = wCat(rider.weight);
const dc = dCat(rider.disc);
const fork = build.fork;
const shock = build.shock;
const ft = build.frontTire;
const rt = build.rearTire;
const lvlMod = { 'Beginner':0, 'Intermediate':0, 'Advanced':1, 'Expert / Racer':2 }[rider.level] || 0;
// Fork PSI midpoint + level adjustment
const fPsiRange = fork?.psi?.[wc];
const fPsi = fPsiRange ? Math.round((fPsiRange[0] + fPsiRange[1]) / 2 + lvlMod * 2) : null;
const fSag = fork?.sag?.[dc] ?? 25;
const fReb = fork ? Math.max(0, (fork.reb[wc] || 10) - lvlMod) : null;
const fLsc = fork ? Math.min(20, (fork.lsc[wc] || 6) + lvlMod) : null;
const fHsc = fork?.hsc ? Math.min(10, (fork.hsc[wc] || 3) + lvlMod) : null;
const fTok = fork?.tok?.[wc] ?? 0;
const sPsiRange = shock?.type === 'air' ? shock.psi?.[wc] : null;
const sPsi = sPsiRange ? Math.round((sPsiRange[0] + sPsiRange[1]) / 2 + lvlMod * 3) : null;
const sSag = shock?.sag?.[dc] ?? 28;
const sReb = shock ? Math.max(0, (shock.reb[wc] || 10) - lvlMod) : null;
const sLsc = shock ? Math.min(20, (shock.lsc[wc] || 5) + lvlMod) : null;
const sHsc = shock?.hsc ? Math.min(10, (shock.hsc[wc] || 3) + lvlMod) : null;
const sTok = shock?.tok?.[wc] ?? 0;
const ftPsiRange = ft?.psi?.[wc];
const fTirePsi = ftPsiRange ? Math.round((ftPsiRange[0] + ftPsiRange[1]) / 2) : null;
const rtPsiRange = rt?.psi?.[wc];
const rTirePsi = rtPsiRange ? Math.round((rtPsiRange[0] + rtPsiRange[1]) / 2) : null;
return { fPsi, fSag, fReb, fLsc, fHsc, fTok, sPsi, sSag, sReb, sLsc, sHsc, sTok, fTirePsi, rTirePsi };
}
// Storage
const SK = 'loam-v2';
async function loadData() {
try { const r = await window.storage.get(SK); return r ? JSON.parse(r.value) : {}; } catch { return {}; }
}
async function saveData(data) {
try { await window.storage.set(SK, JSON.stringify(data)); } catch {}
}
// AI call — Anthropic or Ollama
async function callAI(userMsg, systemMsg, config) {
if (config.backend === 'ollama') {
const res = await fetch(`${config.ollamaUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: config.model, stream: false, messages: [
{ role: 'system', content: systemMsg },
{ role: 'user', content: userMsg }
]})
});
if (!res.ok) throw new Error(`Ollama error ${res.status} — is the server running?`);
const d = await res.json();
return d.message?.content || d.response || '';
} else {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model:'claude-sonnet-4-20250514', max_tokens:1000,
system: systemMsg, messages:[{ role:'user', content:userMsg }] })
});
if (!res.ok) throw new Error(`API error ${res.status}`);
const d = await res.json();
return d.content?.[0]?.text || '';
}
}
const AI_SYS = `You are a professional mountain bike suspension tuner with deep hands-on expertise in Fox, RockShox, Manitou, and Öhlins products.
You combine manufacturer specs with real-world community setups from forums like Pinkbike, Vital MTB, and MTBR.
Be specific, practical, and technical. Use metric where possible but mention PSI/clicks.
Format responses with clear section headers using ▸ symbol and short numbered lists.
Keep recommendations actionable — give exact values, not ranges when possible.`;
// ══════════════════════════════════════════════════════════════════════════════
// BIKE VISUALIZER SVG
// ══════════════════════════════════════════════════════════════════════════════
function BikeVisualizer({ build }) {
const { frame, fork, shock, frontTire, rearTire } = build;
const W = 560, H = 310;
const ha = frame?.ha || 65;
const slack = Math.max(0, Math.min(1, (65 - ha) / 2.0));
const ft = fork ? fork.travels[Math.floor(fork.travels.length / 2)] : 150;
const forkScale = 0.86 + (ft - 130) / 70 * 0.18;
const isCoil = shock?.type === 'coil';
const fc = frame?.color || '#3a5828';
const fkc = fork?.fcolor || '#4a6035';
const shc = shock?.scolor || '#3a4828';
const rtw = Math.round(((rearTire?.w || 2.4) - 2.15) * 32 + 10);
const ftw = Math.round(((frontTire?.w || 2.4) - 2.15) * 32 + 10);
// Key points
const RW = [116, 224];
const FWx = 388 + slack * 10;
const FW = [FWx, 224];
const WR = 72;
const BB = [204, 228];
const HTT = [342 - slack * 8, 114 + slack * 5];
const HTB = [356 - slack * 6, 148 + slack * 4];
const STT = [200, 100];
const SSJ = [202, 140]; // seat stay junction
// Fork end scaling
const fDx = (FW[0] - HTB[0]) * forkScale;
const fDy = (FW[1] - HTB[1]) * forkScale;
const fEnd = [HTB[0] + fDx, HTB[1] + fDy];
// Fork arch (junction upper/lower legs)
const archF = 0.38;
const arch = [HTB[0] + fDx * archF, HTB[1] + fDy * archF];
// Fork perpendicular offset (for two-leg visual)
const fAngle = Math.atan2(fDy, fDx);
const pOff = [Math.sin(fAngle) * 9, -Math.cos(fAngle) * 9];
// Shock mounts
const ShT = [252, 150];
const ShB = [226, 195];
const shMid = [(ShT[0]+ShB[0])/2, (ShT[1]+ShB[1])/2];
const spokes = (cx, cy, r1, r2, n=8) =>
Array.from({length:n}, (_,i) => {
const a = i * Math.PI * 2 / n + 0.2;
return <line key={i}
x1={cx+Math.cos(a)*r1} y1={cy+Math.sin(a)*r1}
x2={cx+Math.cos(a)*r2} y2={cy+Math.sin(a)*r2}
stroke="#1e2d14" strokeWidth="1" />;
});
const hasAny = frame || fork || shock || frontTire || rearTire;
return (
<svg viewBox={`0 0 ${W} ${H}`} style={{width:'100%',maxHeight:275,display:'block'}}>
<defs>
<radialGradient id="bgG" cx="50%" cy="68%" r="52%">
<stop offset="0%" stopColor="#142010" />
<stop offset="100%" stopColor="#070f04" />
</radialGradient>
<linearGradient id="fkG" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={fkc} stopOpacity="1" />
<stop offset="100%" stopColor={fkc} stopOpacity="0.55" />
</linearGradient>
</defs>
<rect width={W} height={H} fill="url(#bgG)" />
{/* Shadow */}
<ellipse cx={(RW[0]+FW[0])/2+20} cy={RW[1]+WR+6} rx={175} ry={7} fill="#000" opacity="0.35" />
{/* Ground */}
<line x1={32} y1={RW[1]+WR+2} x2={528} y2={FW[1]+WR+2} stroke="#1e2d14" strokeWidth="1" />
{/* ── REAR WHEEL ── */}
<circle cx={RW[0]} cy={RW[1]} r={WR} fill="none" stroke="#060e03" strokeWidth={rtw+5} />
<circle cx={RW[0]} cy={RW[1]} r={WR} fill="none" stroke="#182410" strokeWidth={rtw} />
<circle cx={RW[0]} cy={RW[1]} r={WR-rtw/2-2} fill="none" stroke="#2a3c1a" strokeWidth="2.5" />
<circle cx={RW[0]} cy={RW[1]} r={WR-rtw/2-8} fill="none" stroke="#1e2d14" strokeWidth="1.5" />
<circle cx={RW[0]} cy={RW[1]} r={22} fill="none" stroke="#243018" strokeWidth="3" />
{spokes(RW[0],RW[1],15,WR-rtw/2-9)}
<circle cx={RW[0]} cy={RW[1]} r={13} fill="#182410" stroke="#2a3c1a" strokeWidth="2" />
<circle cx={RW[0]} cy={RW[1]} r={5} fill="#243018" />
{/* ── FRONT WHEEL ── */}
<circle cx={FW[0]} cy={FW[1]} r={WR} fill="none" stroke="#060e03" strokeWidth={ftw+5} />
<circle cx={FW[0]} cy={FW[1]} r={WR} fill="none" stroke="#182410" strokeWidth={ftw} />
<circle cx={FW[0]} cy={FW[1]} r={WR-ftw/2-2} fill="none" stroke="#2a3c1a" strokeWidth="2.5" />
<circle cx={FW[0]} cy={FW[1]} r={WR-ftw/2-8} fill="none" stroke="#1e2d14" strokeWidth="1.5" />
{spokes(FW[0],FW[1],13,WR-ftw/2-9)}
<circle cx={FW[0]} cy={FW[1]} r={13} fill="#182410" stroke="#2a3c1a" strokeWidth="2" />
<circle cx={FW[0]} cy={FW[1]} r={5} fill="#243018" />
{/* ── CHAIN STAYS ── */}
<line x1={BB[0]} y1={BB[1]-5} x2={RW[0]+10} y2={RW[1]-5} stroke={fc} strokeWidth="8" strokeLinecap="round" />
<line x1={BB[0]} y1={BB[1]+4} x2={RW[0]+10} y2={RW[1]+4} stroke={fc} strokeWidth="5" strokeLinecap="round" />
{/* ── SEAT STAYS ── */}
<line x1={SSJ[0]} y1={SSJ[1]} x2={RW[0]+8} y2={RW[1]-8} stroke={fc} strokeWidth="5.5" strokeLinecap="round" />
<line x1={SSJ[0]+7} y1={SSJ[1]+3} x2={RW[0]+14} y2={RW[1]-5} stroke={fc} strokeWidth="3.5" strokeLinecap="round" />
{/* ── REAR SHOCK ── */}
{isCoil ? (
<>
<line x1={ShT[0]} y1={ShT[1]} x2={ShB[0]} y2={ShB[1]} stroke="#30403a" strokeWidth="10" strokeLinecap="round" />
{Array.from({length:6},(_,i)=>{
const f=(i+0.5)/6;
const mx=ShT[0]+(ShB[0]-ShT[0])*f, my=ShT[1]+(ShB[1]-ShT[1])*f;
const dx=ShB[0]-ShT[0], dy=ShB[1]-ShT[1];
const len=Math.sqrt(dx*dx+dy*dy);
const nx=-dy/len*7, ny=dx/len*7;
return <line key={i} x1={mx+nx} y1={my+ny} x2={mx-nx} y2={my-ny} stroke={shc} strokeWidth="2.5" strokeLinecap="round" />;
})}
</>
) : (
<>
<line x1={ShT[0]} y1={ShT[1]} x2={ShB[0]} y2={ShB[1]} stroke="#252e20" strokeWidth="11" strokeLinecap="round" />
<line x1={ShT[0]} y1={ShT[1]} x2={shMid[0]} y2={shMid[1]} stroke={shc} strokeWidth="11" strokeLinecap="round" />
<line x1={ShT[0]} y1={ShT[1]} x2={shMid[0]} y2={shMid[1]} stroke={shc} strokeWidth="5" strokeLinecap="round" opacity="0.6" />
</>
)}
<circle cx={ShT[0]} cy={ShT[1]} r={4.5} fill="#1e2d14" stroke="#3a4828" strokeWidth="1.5" />
<circle cx={ShB[0]} cy={ShB[1]} r={4.5} fill="#1e2d14" stroke="#3a4828" strokeWidth="1.5" />
{/* ── DOWN TUBE ── */}
<line x1={HTB[0]} y1={HTB[1]} x2={BB[0]} y2={BB[1]} stroke={fc} strokeWidth="13" strokeLinecap="round" />
{/* ── TOP TUBE ── */}
<line x1={HTT[0]} y1={HTT[1]} x2={STT[0]} y2={STT[1]} stroke={fc} strokeWidth="9" strokeLinecap="round" />
{/* ── SEAT TUBE ── */}
<line x1={STT[0]} y1={STT[1]} x2={BB[0]} y2={BB[1]} stroke={fc} strokeWidth="9" strokeLinecap="round" />
{/* ── HEAD TUBE ── */}
<line x1={HTT[0]} y1={HTT[1]} x2={HTB[0]} y2={HTB[1]} stroke={fc} strokeWidth="15" strokeLinecap="round" />
{/* ── BB ── */}
<circle cx={BB[0]} cy={BB[1]} r={14} fill={fc} />
<circle cx={BB[0]} cy={BB[1]} r={8} fill="#070f04" />
<circle cx={BB[0]} cy={BB[1]} r={25} fill="none" stroke="#243018" strokeWidth="4.5" />
<circle cx={BB[0]} cy={BB[1]} r={20} fill="none" stroke="#1e2d14" strokeWidth="1.5" />
<line x1={BB[0]} y1={BB[1]} x2={BB[0]-16} y2={BB[1]+26} stroke="#2a3c1a" strokeWidth="7" strokeLinecap="round" />
<rect x={BB[0]-22} y={BB[1]+25} width="13" height="5" rx="2" fill="#2a3c1a" />
{/* ── FORK upper stanchions ── */}
<line x1={HTB[0]} y1={HTB[1]} x2={arch[0]} y2={arch[1]}
stroke="url(#fkG)" strokeWidth="8" strokeLinecap="round" />
<line x1={HTB[0]+pOff[0]} y1={HTB[1]+pOff[1]} x2={arch[0]+pOff[0]} y2={arch[1]+pOff[1]}
stroke={fkc} strokeWidth="5" strokeLinecap="round" opacity="0.55" />
{/* Fork crown */}
<ellipse cx={arch[0]+pOff[0]*0.4} cy={arch[1]} rx={11} ry={6}
fill={fkc} opacity="0.85"
transform={`rotate(${Math.atan2(fDy,fDx)*180/Math.PI},${arch[0]+pOff[0]*0.4},${arch[1]})`} />
{/* Fork lower legs */}
<line x1={arch[0]} y1={arch[1]} x2={fEnd[0]} y2={fEnd[1]}
stroke={fkc} strokeWidth="11" strokeLinecap="round" opacity="0.75" />
<line x1={arch[0]+pOff[0]} y1={arch[1]+pOff[1]} x2={fEnd[0]+pOff[0]} y2={fEnd[1]+pOff[1]}
stroke={fkc} strokeWidth="8" strokeLinecap="round" opacity="0.45" />
{/* Axle */}
<line x1={FW[0]-8} y1={FW[1]} x2={FW[0]+8} y2={FW[1]} stroke={fkc} strokeWidth="3" strokeLinecap="round" opacity="0.6" />
{/* ── SEAT POST ── */}
<line x1={STT[0]+2} y1={STT[1]} x2={STT[0]} y2={STT[1]-28} stroke="#344a22" strokeWidth="5.5" strokeLinecap="round" />
{/* SADDLE */}
<ellipse cx={STT[0]+6} cy={STT[1]-31} rx="30" ry="5.5" fill="#283c18" />
<line x1={STT[0]-21} y1={STT[1]-31} x2={STT[0]+33} y2={STT[1]-31} stroke="#344a22" strokeWidth="2.5" strokeLinecap="round" />
<line x1={STT[0]+5} y1={STT[1]-28} x2={STT[0]+5} y2={STT[1]-31} stroke="#344a22" strokeWidth="4" />
{/* ── STEM + BAR ── */}
<line x1={HTT[0]-2} y1={HTT[1]+4} x2={HTT[0]-20} y2={HTT[1]-18} stroke="#344a22" strokeWidth="8" strokeLinecap="round" />
<circle cx={HTT[0]-20} cy={HTT[1]-18} r={5} fill="#243018" stroke="#344a22" strokeWidth="1.5" />
<path d={`M ${HTT[0]-38} ${HTT[1]-30} Q ${HTT[0]-22} ${HTT[1]-26} ${HTT[0]-5} ${HTT[1]-12}`}
fill="none" stroke="#344a22" strokeWidth="8" strokeLinecap="round" />
{/* ── LABELS ── */}
{fork && <text x={FW[0]+12} y={FW[1]-46} fontSize="8" fill={fkc} fontFamily="'IBM Plex Mono',monospace" opacity="0.75">{fork.brand} {fork.model}</text>}
{shock && <text x={ShT[0]+12} y={shMid[1]-4} fontSize="8" fill={shc} fontFamily="'IBM Plex Mono',monospace" opacity="0.75">{shock.brand} {shock.model}</text>}
{frame && <text x={244} y={168} fontSize="8" fill={fc} fontFamily="'IBM Plex Mono',monospace" opacity="0.65">{frame.brand} {frame.model}</text>}
{frontTire && <text x={FW[0]} y={FW[1]+WR+ftw/2+14} fontSize="8" fill="#5a9635" fontFamily="'IBM Plex Mono',monospace" textAnchor="middle" opacity="0.7">{frontTire.model}</text>}
{rearTire && <text x={RW[0]} y={RW[1]+WR+rtw/2+14} fontSize="8" fill="#5a9635" fontFamily="'IBM Plex Mono',monospace" textAnchor="middle" opacity="0.7">{rearTire.model}</text>}
{!hasAny && (
<text x={W/2} y={H/2+8} fontSize="12" fill="#2a3c1a" fontFamily="'IBM Plex Mono',monospace" textAnchor="middle">
SELECT COMPONENTS TO BUILD YOUR BIKE
</text>
)}
</svg>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// SUB-COMPONENTS
// ══════════════════════════════════════════════════════════════════════════════
function Sl({ label, value, onChange, min, max, step=1, unit='' }) {
return (
<div className="slW">
<div className="slH">
<span>{label}</span>
<span className="slV">{value}<span className="slU">{unit}</span></span>
</div>
<input type="range" min={min} max={max} step={step} value={value}
onChange={e => onChange(Number(e.target.value))} />
</div>
);
}
function SpB({ label, value, unit }) {
return (
<div className="spB">
<div className="spL">{label}</div>
<div className="spV">{value ?? '—'}{unit && <span className="spU">{unit}</span>}</div>
</div>
);
}
function CompCard({ item, selected, recommended, onClick, extraTags=[] }) {
return (
<div className={`cCard${selected?' sel':''}${recommended?' rec':''}`} onClick={onClick}>
{selected && <div className="cCheck"></div>}
<div className="cBrand">{item.brand}</div>
<div className="cModel">{item.model}</div>
{item.desc && <div className="cNote">{item.desc}</div>}
{item.notes && <div className="cNote">{item.notes}</div>}
<div className="cTags">
{recommended && <span className="cTag cRec"> Recommended</span>}
{extraTags.map((t,i) => <span key={i} className="cTag">{t}</span>)}
</div>
</div>
);
}
function MeterBar({ label, value, max=10, color }) {
const pct = Math.round((value / max) * 100);
const bg = color || (pct > 70 ? 'var(--fern)' : pct > 40 ? 'var(--amb)' : 'var(--red)');
return (
<div className="mBar">
<span className="mLabel">{label}</span>
<div className="mTrack"><div className="mFill" style={{width:`${pct}%`,background:bg}} /></div>
<span style={{fontSize:10,color:'var(--txt2)',minWidth:22,textAlign:'right'}}>{value}/{max}</span>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// MAIN APP
// ══════════════════════════════════════════════════════════════════════════════
const emptyBuild = () => ({ frame:null, fork:null, shock:null, frontTire:null, rearTire:null, name:'' });
const emptySetup = () => ({ fPsi:'', fSag:25, fReb:'', fLsc:'', fHsc:'', fTok:0, sPsi:'', sSag:28, sReb:'', sLsc:'', sHsc:'', sTok:0, fTirePsi:'', rTirePsi:'', notes:'' });
const emptyReview = () => ({ buildId:'', suspFeel:5, fGrip:7, rGrip:7, rollRes:5, confidence:7, bottomOut:false, harshHit:false, wallow:false, smallBump:false, notes:'' });
const emptyRider = () => ({ weight:78, disc:'Trail', level:'Intermediate', style:'Mixed' });
export default function App() {
const [tab, setTab] = useState('build');
const [rider, setRider] = useState(emptyRider());
const [build, setBuild] = useState(emptyBuild());
const [setup, setSetup] = useState(emptySetup());
const [garage, setGarage] = useState([]);
const [reviews, setReviews] = useState([]);
const [prefs, setPrefs] = useState({ harshTendency:0, softTendency:0, gripPref:0 });
const [aiConfig, setAiConfig] = useState({ backend:'anthropic', ollamaUrl:'http://localhost:11434', model:'llama3.1:8b' });
const [aiOut, setAiOut] = useState('');
const [aiLoad, setAiLoad] = useState(false);
const [review, setReview] = useState(emptyReview());
const [revOut, setRevOut] = useState('');
const [revLoad, setRevLoad] = useState(false);
const [toast, setToast] = useState(null);
const [loading, setLoading] = useState(true);
const [showAiSettings, setShowAiSettings] = useState(false);
const [detailBuild, setDetailBuild] = useState(null);
useEffect(() => {
loadData().then(d => {
if (d.garage) setGarage(d.garage);
if (d.reviews) setReviews(d.reviews);
if (d.prefs) setPrefs(d.prefs);
if (d.aiConfig) setAiConfig(p => ({...p,...d.aiConfig}));
setLoading(false);
});
}, []);
const persist = async (updates) => {
const merged = { garage, reviews, prefs, aiConfig: {backend:aiConfig.backend,ollamaUrl:aiConfig.ollamaUrl,model:aiConfig.model}, ...updates };
await saveData(merged);
};
const toast_ = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2400); };
const rSet = (k) => (e) => setRider(r => ({...r,[k]: e?.target ? e.target.value : e}));
const bSet = (k, v) => setBuild(b => ({...b,[k]:b[k]?.id===v?.id ? null : v}));
const wc = wCat(rider.weight);
const dc = dCat(rider.disc);
const defaults = getSetupDefaults(build, rider);
// Adapt setup defaults whenever build/rider changes
useEffect(() => {
const d = getSetupDefaults(build, rider);
setSetup(s => ({
...s,
fPsi: s.fPsi || '', fSag: d.fSag||s.fSag,
fReb: s.fReb || '', fLsc: s.fLsc || '', fHsc: s.fHsc || '',
fTok: s.fTok!=='' ? s.fTok : d.fTok||0,
sPsi: s.sPsi || '', sSag: d.sSag||s.sSag,
sReb: s.sReb || '', sLsc: s.sLsc || '', sHsc: s.sHsc || '',
sTok: s.sTok!=='' ? s.sTok : d.sTok||0,
fTirePsi: s.fTirePsi || '', rTirePsi: s.rTirePsi || '',
}));
}, [build.fork?.id, build.shock?.id, build.frontTire?.id, build.rearTire?.id, rider.weight, rider.disc, rider.level]);
const saveBuild = async () => {
if (!build.name.trim()) { toast_('Add a build name first'); return; }
const saved = { id: Date.now().toString(), ...build, rider: {...rider}, setup: {...setup}, date: new Date().toISOString() };
const updated = [saved, ...garage];
setGarage(updated);
await persist({ garage: updated });
toast_('Build saved to garage');
};
const loadGarageBuild = (gb) => {
setBuild({ frame:gb.frame, fork:gb.fork, shock:gb.shock, frontTire:gb.frontTire, rearTire:gb.rearTire, name:gb.name });
if (gb.setup) setSetup(gb.setup);
if (gb.rider) setRider(gb.rider);
setDetailBuild(null);
setTab('build');
toast_('Build loaded');
};
const deleteGarageBuild = async (id) => {
const updated = garage.filter(g => g.id !== id);
setGarage(updated);
await persist({ garage: updated });
toast_('Build deleted');
};
const buildSummary = () => {
const parts = [build.frame, build.fork, build.shock, build.frontTire, build.rearTire].filter(Boolean);
return parts.map(p => `${p.brand} ${p.model}`).join(', ') || 'No components selected';
};
const learnedContext = () => {
if (!reviews.length) return '';
const harsh = reviews.filter(r => r.suspFeel >= 7).length;
const soft = reviews.filter(r => r.suspFeel <= 3).length;
if (harsh > soft && harsh > 1) return '\n[RIDER PATTERN] Previous reviews indicate this rider consistently finds suspension too harsh. Recommend starting with softer rebound (more clicks open) and lower compression damping.';
if (soft > harsh && soft > 1) return '\n[RIDER PATTERN] Previous reviews indicate this rider often finds suspension too soft/wallowy. Recommend firmer base settings and consider extra volume spacers.';
return '';
};
const handleAiRec = async () => {
setAiLoad(true); setAiOut('');
const ctx = learnedContext();
const prompt = `Give me a complete suspension setup recommendation for this build:
RIDER: ${rider.weight}kg, ${rider.level}, ${rider.style}, rides ${rider.disc}
FRAME: ${build.frame ? `${build.frame.brand} ${build.frame.model} (${build.frame.travel}mm, ${build.frame.ha}° HA)` : 'Not selected'}
FORK: ${build.fork ? `${build.fork.brand} ${build.fork.model} (${build.fork.travels[1]}mm travel, ${build.fork.damper} damper)` : 'Not selected'}
SHOCK: ${build.shock ? `${build.shock.brand} ${build.shock.model} (${build.shock.type})` : 'Not selected'}
FRONT TIRE: ${build.frontTire ? `${build.frontTire.brand} ${build.frontTire.model} ${build.frontTire.w}"` : 'Not selected'}
REAR TIRE: ${build.rearTire ? `${build.rearTire.brand} ${build.rearTire.model} ${build.rearTire.w}"` : 'Not selected'}
${ctx}
Please give:
▸ FORK: Starting PSI, sag %, rebound clicks from closed, LSC, HSC (if applicable), volume tokens
▸ SHOCK: Starting PSI (or spring rate for coil), sag %, rebound, LSC, HSC, volume tokens
▸ TIRES: Front and rear starting PSI
▸ RATIONALE: Why these settings for this rider/build/discipline
▸ FIRST RIDE TIPS: What to feel for and how to adjust
▸ KNOWN ISSUES: Any common pitfalls for this specific component combo`;
try {
const resp = await callAI(prompt, AI_SYS, aiConfig);
setAiOut(resp);
} catch (e) { setAiOut('Error: ' + e.message); }
setAiLoad(false);
};
const handlePostRide = async () => {
setRevLoad(true); setRevOut('');
const selectedBuild = garage.find(g => g.id === review.buildId);
const bp = selectedBuild ? `\nBUILD: ${selectedBuild.name}\nFORK: ${selectedBuild.fork?.brand} ${selectedBuild.fork?.model}\nSHOCK: ${selectedBuild.shock?.brand} ${selectedBuild.shock?.model}
SETUP: Fork ${selectedBuild.setup?.fPsi||'?'}psi / ${selectedBuild.setup?.fSag||'?'}% sag / ${selectedBuild.setup?.fReb||'?'} reb clicks, Shock ${selectedBuild.setup?.sPsi||'?'}psi` : '';
const prompt = `POST-RIDE REVIEW ANALYSIS:
${bp}
RIDER FEEDBACK (scale 1-10):
- Suspension feel: ${review.suspFeel}/10 (1=too soft/wallowing, 5=perfect, 10=too harsh/rigid)
- Front grip: ${review.fGrip}/10
- Rear grip: ${review.rGrip}/10
- Rolling resistance: ${review.rollRes}/10 (10=fast rolling, 1=sluggish)
- Confidence level: ${review.confidence}/10
ISSUES REPORTED:
- Bottoming out: ${review.bottomOut ? 'YES' : 'No'}
- Harsh initial hit: ${review.harshHit ? 'YES' : 'No'}
- Wallowing/lack of support: ${review.wallow ? 'YES' : 'No'}
- Poor small-bump sensitivity: ${review.smallBump ? 'YES' : 'No'}
RIDER NOTES: ${review.notes || 'None'}
Based on this feedback, provide specific actionable adjustments:
▸ FORK ADJUSTMENTS: Exact changes to PSI, rebound clicks, compression clicks
▸ SHOCK ADJUSTMENTS: Exact changes to PSI, rebound, compression, tokens
▸ TIRE ADJUSTMENTS: Any PSI changes recommended
▸ PRIORITY: Which adjustment to make first
▸ NEXT RIDE: What to feel for after changes`;
try {
const resp = await callAI(prompt, AI_SYS, aiConfig);
setRevOut(resp);
// Learn from this review
const newReviews = [{ ...review, date: new Date().toISOString() }, ...reviews.slice(0,19)];
const newPrefs = { ...prefs,
harshTendency: newReviews.filter(r=>r.suspFeel>=7).length,
softTendency: newReviews.filter(r=>r.suspFeel<=3).length,
};
setReviews(newReviews);
setPrefs(newPrefs);
await persist({ reviews: newReviews, prefs: newPrefs });
} catch (e) { setRevOut('Error: ' + e.message); }
setRevLoad(false);
};
// ── RENDER: Build tab ─────────────────────────────────────────────────────
function renderBuild() {
const tires = TIRES;
return (
<div>
{/* Rider profile bar */}
<div className="riderBar">
<div className="field">
<label>Rider Weight (kg)</label>
<input type="number" min={40} max={130} value={rider.weight} onChange={e=>setRider(r=>({...r,weight:Number(e.target.value)}))} />
</div>
<div className="field">
<label>Discipline</label>
<select value={rider.disc} onChange={rSet('disc')}>
{DISC.map(d=><option key={d}>{d}</option>)}
</select>
</div>
<div className="field">
<label>Rider Level</label>
<select value={rider.level} onChange={rSet('level')}>
{LEVELS.map(l=><option key={l}>{l}</option>)}
</select>
</div>
<div className="field">
<label>Riding Style</label>
<select value={rider.style} onChange={rSet('style')}>
{STYLES_R.map(s=><option key={s}>{s}</option>)}
</select>
</div>
</div>
{/* Bike viz */}
<div className="bike-viewer" style={{marginBottom:20}}>
<BikeVisualizer build={build} />
</div>
{/* Build name + save */}
<div className="flex mb">
<div className="field" style={{flex:1}}>
<label>Build Name</label>
<input placeholder="e.g. Enduro season 2025 / Local trails setup" value={build.name} onChange={e=>setBuild(b=>({...b,name:e.target.value}))} />
</div>
<div style={{display:'flex',alignItems:'flex-end'}}>
<button className="btn btn-p" onClick={saveBuild}>Save to Garage</button>
</div>
</div>
{/* Frame */}
<div className="sH">Frame</div>
<div className="g3">
{FRAMES.map(f => (
<CompCard key={f.id} item={f} selected={build.frame?.id===f.id}
recommended={isRecFrame(f, rider)} onClick={()=>bSet('frame',f)}
extraTags={[`${f.travel}mm`, f.ha+'°', f.geo]} />
))}
</div>
{/* Fork */}
<div className="sH">Fork</div>
<div className="g3">
{FORKS.map(f => (
<CompCard key={f.id} item={f} selected={build.fork?.id===f.id}
recommended={isRecFork(f, rider)} onClick={()=>bSet('fork',f)}
extraTags={[f.travels.join('/')+'mm', f.damper]} />
))}
</div>
{/* Shock */}
<div className="sH">Rear Shock</div>
<div className="g3">
{SHOCKS.map(s => (
<CompCard key={s.id} item={s} selected={build.shock?.id===s.id}
recommended={isRecShock(s, rider)} onClick={()=>bSet('shock',s)}
extraTags={[s.type, s.hsc?'HSC':'', s.hsr?'HSR':''].filter(Boolean)} />
))}
</div>
{/* Tires */}
<div className="sH">Front Tire</div>
<div className="g4">
{tires.front.map(t => (
<CompCard key={t.id} item={{...t,desc:undefined}} selected={build.frontTire?.id===t.id}
recommended={isRecTire(t, rider, 'front')} onClick={()=>bSet('frontTire',t)}
extraTags={[t.w+'"', `Grip:${t.grip}`, `Roll:${t.roll}`]} />
))}
</div>
<div className="sH">Rear Tire</div>
<div className="g4">
{tires.rear.map(t => (
<CompCard key={t.id} item={{...t,desc:undefined}} selected={build.rearTire?.id===t.id}
recommended={isRecTire(t, rider, 'rear')} onClick={()=>bSet('rearTire',t)}
extraTags={[t.w+'"', `Grip:${t.grip}`, `Roll:${t.roll}`]} />
))}
</div>
</div>
);
}
// ── RENDER: Setup tab ─────────────────────────────────────────────────────
function renderSetup() {
const d = defaults;
const sp = (v, d) => v !== '' ? v : (d !== null && d !== undefined ? `${d}` : '');
return (
<div>
<div className="notice mb">
Settings below auto-populate from your build + rider profile. Edit any value. Click AI Tune to get full AI recommendation.
</div>
{/* Fork */}
{build.fork ? (
<>
<div className="sH">Fork {build.fork.brand} {build.fork.model}</div>
<div className="g2">
<div className="field">
<label>Air Pressure (PSI)</label>
<input type="number" placeholder={d.fPsi ? `${d.fPsi} psi` : 'Select fork + enter rider weight'}
value={setup.fPsi} onChange={e=>setSetup(s=>({...s,fPsi:e.target.value}))} />
{d.fPsi && <span className="hint">Manufacturer range for your weight: {build.fork.psi[wc][0]}{build.fork.psi[wc][1]} psi</span>}
</div>
<div className="field">
<label>Volume Tokens</label>
<input type="number" min={0} max={8} placeholder={`${d.fTok}`}
value={setup.fTok} onChange={e=>setSetup(s=>({...s,fTok:e.target.value}))} />
<span className="hint">More tokens = more progressive</span>
</div>
<div className="full">
<Sl label="Sag" value={setup.fSag} onChange={v=>setSetup(s=>({...s,fSag:v}))} min={15} max={40} unit="%" />
</div>
<Sl label="Rebound (clicks from closed)" value={setup.fReb===''?d.fReb||10:Number(setup.fReb)}
onChange={v=>setSetup(s=>({...s,fReb:v}))} min={0} max={20} />
<Sl label="Low-Speed Compression (clicks)" value={setup.fLsc===''?d.fLsc||5:Number(setup.fLsc)}
onChange={v=>setSetup(s=>({...s,fLsc:v}))} min={0} max={20} />
{build.fork.hsc && <Sl label="High-Speed Compression (clicks)" value={setup.fHsc===''?d.fHsc||3:Number(setup.fHsc)}
onChange={v=>setSetup(s=>({...s,fHsc:v}))} min={0} max={10} />}
</div>
</>
) : (
<div className="empty" style={{padding:'24px 0'}}><p>Select a fork in the Build tab</p></div>
)}
{/* Shock */}
{build.shock ? (
<>
<div className="sH">Rear Shock {build.shock.brand} {build.shock.model}</div>
<div className="g2">
{build.shock.type === 'air' ? (
<div className="field">
<label>Air Pressure (PSI)</label>
<input type="number" placeholder={d.sPsi ? `${d.sPsi} psi` : 'PSI'}
value={setup.sPsi} onChange={e=>setSetup(s=>({...s,sPsi:e.target.value}))} />
{d.sPsi && <span className="hint">Range for your weight: {build.shock.psi[wc][0]}{build.shock.psi[wc][1]} psi</span>}
</div>
) : (
<div className="field">
<label>Spring Rate</label>
<input readOnly value={build.shock.spring?.[wc] || ''} style={{color:'var(--sage)'}} />
<span className="hint">Consult spring chart for fine-tuning</span>
</div>
)}
<div className="field">
<label>Volume Tokens (air only)</label>
<input type="number" min={0} max={8} disabled={build.shock.type==='coil'} placeholder={`${d.sTok}`}
value={setup.sTok} onChange={e=>setSetup(s=>({...s,sTok:e.target.value}))} />
</div>
<div className="full">
<Sl label="Sag" value={setup.sSag} onChange={v=>setSetup(s=>({...s,sSag:v}))} min={20} max={42} unit="%" />
</div>
<Sl label="Rebound (clicks from closed)" value={setup.sReb===''?d.sReb||10:Number(setup.sReb)}
onChange={v=>setSetup(s=>({...s,sReb:v}))} min={0} max={20} />
<Sl label="Low-Speed Compression (clicks)" value={setup.sLsc===''?d.sLsc||5:Number(setup.sLsc)}
onChange={v=>setSetup(s=>({...s,sLsc:v}))} min={0} max={20} />
{build.shock.hsc && <Sl label="High-Speed Compression (clicks)" value={setup.sHsc===''?d.sHsc||3:Number(setup.sHsc)}
onChange={v=>setSetup(s=>({...s,sHsc:v}))} min={0} max={10} />}
</div>
</>
) : (
<div className="empty" style={{padding:'24px 0'}}><p>Select a shock in the Build tab</p></div>
)}
{/* Tires */}
{(build.frontTire || build.rearTire) && (
<>
<div className="sH">Tire Pressures</div>
<div className="g2">
{build.frontTire && (
<div className="field">
<label>Front {build.frontTire.model} ({build.frontTire.w}")</label>
<input type="number" placeholder={d.fTirePsi ? `≈ ${d.fTirePsi} psi` : 'PSI'}
value={setup.fTirePsi} onChange={e=>setSetup(s=>({...s,fTirePsi:e.target.value}))} />
{d.fTirePsi && <span className="hint">Range: {build.frontTire.psi[wc][0]}{build.frontTire.psi[wc][1]} psi</span>}
</div>
)}
{build.rearTire && (
<div className="field">
<label>Rear — {build.rearTire.model} ({build.rearTire.w}")</label>
<input type="number" placeholder={d.rTirePsi ? `${d.rTirePsi} psi` : 'PSI'}
value={setup.rTirePsi} onChange={e=>setSetup(s=>({...s,rTirePsi:e.target.value}))} />
{d.rTirePsi && <span className="hint">Range: {build.rearTire.psi[wc][0]}{build.rearTire.psi[wc][1]} psi</span>}
</div>
)}
</div>
</>
)}
<div className="sH">Notes</div>
<div className="field mb">
<label>Setup notes / tuning log</label>
<textarea placeholder="Feel, conditions, what to try next..." value={setup.notes} onChange={e=>setSetup(s=>({...s,notes:e.target.value}))} />
</div>
<div className="divider" />
<div className="flex">
<button className="btn btn-p" onClick={handleAiRec} disabled={aiLoad}>
{aiLoad ? 'Analysing…' : '🤖 Get AI Recommendation'}
</button>
<button className="btn btn-sm" onClick={saveBuild}>Save Build</button>
</div>
{aiLoad && <div className="thinking"><div className="dot"/><div className="dot"/><div className="dot"/> Querying suspension database</div>}
{aiOut && <div className="aiOut">{aiOut}</div>}
</div>
);
}
// ── RENDER: Post-ride review tab ──────────────────────────────────────────
function renderReview() {
return (
<div>
<div className="notice mb">
After each ride, rate your suspension feel. The AI will suggest precise adjustments, and the app learns your preferences over time.
{reviews.length > 0 && <span style={{color:'var(--fern)'}}> ({reviews.length} review{reviews.length>1?'s':''} stored preferences being learned)</span>}
</div>
{/* Select build */}
{garage.length > 0 && (
<div className="field mb">
<label>Reviewed Build (from garage)</label>
<select value={review.buildId} onChange={e=>setReview(r=>({...r,buildId:e.target.value}))}>
<option value=""> Current build (unsaved) </option>
{garage.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
)}
<div className="sH">Suspension Feel</div>
<div className="g2" style={{gap:16}}>
<div className="full">
<div className="feelRow">
<span className="feelLabel">Overall Feel</span>
<input type="range" min={1} max={10} value={review.suspFeel} onChange={e=>setReview(r=>({...r,suspFeel:Number(e.target.value)}))} />
<span className="feelScore">{review.suspFeel}</span>
</div>
<div className="flex" style={{justifyContent:'space-between',fontSize:9,color:'var(--txt3)',letterSpacing:1,marginTop:2}}>
<span>TOO SOFT / WALLOWING</span><span>PERFECT</span><span>TOO HARSH</span>
</div>
</div>
{[['fGrip','Front Grip',1,10],['rGrip','Rear Grip',1,10],['rollRes','Rolling Speed',1,10],['confidence','Confidence',1,10]].map(([k,l,mn,mx])=>(
<div key={k} className="slW">
<div className="slH"><span style={{fontSize:9,letterSpacing:'1.5px',textTransform:'uppercase',color:'var(--txt3)',fontWeight:700}}>{l}</span>
<span className="slV">{review[k]}<span className="slU">/10</span></span>
</div>
<input type="range" min={mn} max={mx} value={review[k]} onChange={e=>setReview(r=>({...r,[k]:Number(e.target.value)}))} />
</div>
))}
</div>
<div className="sH">Issues Experienced</div>
<div className="g2">
{[['bottomOut','Bottoming Out'],['harshHit','Harsh Initial Hit'],['wallow','Wallowing / No Support'],['smallBump','Poor Small-Bump Sensitivity']].map(([k,l])=>(
<label key={k} style={{display:'flex',gap:10,alignItems:'center',cursor:'pointer',padding:'10px 12px',background:'var(--bg3)',border:`1px solid ${review[k]?'var(--fern)':'var(--bdr)'}`,transition:'all .15s'}}>
<input type="checkbox" checked={review[k]} onChange={e=>setReview(r=>({...r,[k]:e.target.checked}))} style={{accentColor:'var(--fern)',width:14,height:14}} />
<span style={{fontSize:11,letterSpacing:1,textTransform:'uppercase',color:review[k]?'var(--sage)':'var(--txt3)'}}>{l}</span>
</label>
))}
</div>
<div className="field mt mb">
<label>Notes (trail conditions, what stood out, etc.)</label>
<textarea placeholder="Felt great on loamy corners but bottomed out on the big rock drop at speed…" value={review.notes} onChange={e=>setReview(r=>({...r,notes:e.target.value}))} />
</div>
<div className="sH">Grip & Feel Summary</div>
<div style={{background:'var(--panel)',border:'1px solid var(--bdr)',padding:'14px 16px',marginBottom:16}}>
<MeterBar label="Front grip" value={review.fGrip} />
<MeterBar label="Rear grip" value={review.rGrip} />
<MeterBar label="Roll speed" value={review.rollRes} color="var(--amb)" />
<MeterBar label="Confidence" value={review.confidence} />
<MeterBar label="Susp. balance" value={10 - Math.abs(review.suspFeel - 5) * 2} />
</div>
<button className="btn btn-p" onClick={handlePostRide} disabled={revLoad}>
{revLoad ? 'Analysing ride data…' : '🤖 Get Improvement Recommendations'}
</button>
{revLoad && <div className="thinking"><div className="dot"/><div className="dot"/><div className="dot"/> Processing ride feedback</div>}
{revOut && <div className="aiOut">{revOut}</div>}
</div>
);
}
// ── RENDER: Garage tab ────────────────────────────────────────────────────
function renderGarage() {
if (detailBuild) {
const gb = detailBuild;
const sp = gb.setup;
return (
<div>
<button className="btn btn-sm mb" onClick={()=>setDetailBuild(null)}> Back</button>
<div style={{fontFamily:'var(--fD)',fontSize:'clamp(24px,6vw,36px)',letterSpacing:2,textTransform:'uppercase',marginBottom:8}}>{gb.name}</div>
<div className="gPills" style={{marginBottom:12}}>
{gb.rider && <><span className="pill pill-g">{gb.rider.disc}</span><span className="pill">{gb.rider.weight}kg</span><span className="pill">{gb.rider.level}</span></>}
<span className="pill" style={{color:'var(--txt4)'}}>{new Date(gb.date).toLocaleDateString()}</span>
</div>
<div className="g2 mb">
{gb.frame && <div className="spB"><div className="spL">Frame</div><div className="spV" style={{fontSize:16}}>{gb.frame.brand} {gb.frame.model}</div></div>}
{gb.fork && <div className="spB"><div className="spL">Fork</div><div className="spV" style={{fontSize:16}}>{gb.fork.brand} {gb.fork.model}</div></div>}
{gb.shock && <div className="spB"><div className="spL">Shock</div><div className="spV" style={{fontSize:16}}>{gb.shock.brand} {gb.shock.model}</div></div>}
</div>
{sp && (
<>
<div className="sH">Suspension Settings</div>
<div className="specs">
{sp.fPsi && <SpB label="Fork PSI" value={sp.fPsi} unit=" psi" />}
<SpB label="Fork Sag" value={sp.fSag} unit="%" />
{sp.fReb && <SpB label="Fork Reb" value={sp.fReb} unit=" clk" />}
{sp.fLsc && <SpB label="Fork LSC" value={sp.fLsc} unit=" clk" />}
{sp.sPsi && <SpB label="Shock PSI" value={sp.sPsi} unit=" psi" />}
<SpB label="Shock Sag" value={sp.sSag} unit="%" />
{sp.sReb && <SpB label="Shock Reb" value={sp.sReb} unit=" clk" />}
{sp.sLsc && <SpB label="Shock LSC" value={sp.sLsc} unit=" clk" />}
{sp.fTirePsi && <SpB label="F Tire" value={sp.fTirePsi} unit=" psi" />}
{sp.rTirePsi && <SpB label="R Tire" value={sp.rTirePsi} unit=" psi" />}
</div>
{sp.notes && <div style={{background:'var(--bg3)',border:'1px solid var(--bdr)',borderLeft:'3px solid var(--moss)',padding:'12px 14px',fontSize:12,lineHeight:1.7,color:'var(--txt2)',whiteSpace:'pre-wrap',marginTop:10}}>{sp.notes}</div>}
</>
)}
<div className="flex mt2">
<button className="btn btn-p" onClick={()=>loadGarageBuild(gb)}>Load Build</button>
<button className="btn btn-d" onClick={()=>{deleteGarageBuild(gb.id);setDetailBuild(null)}}>Delete</button>
</div>
</div>
);
}
if (!garage.length) return (
<div className="empty">
<div className="empty-i">🚵</div>
<h3>Garage is empty</h3>
<p>Build and save your first setup from the Build tab.</p>
</div>
);
return (
<div>
<div style={{fontSize:10,letterSpacing:2,color:'var(--txt3)',textTransform:'uppercase',marginBottom:12}}>
{garage.length} saved build{garage.length>1?'s':''}
</div>
<div className="gGrid">
{garage.map(gb => (
<div key={gb.id} className="gCard" onClick={()=>setDetailBuild(gb)}>
<div className="gBike">{gb.name}</div>
<div className="gSub">{gb.rider?.disc} · {gb.rider?.weight}kg · {gb.rider?.level}</div>
<div className="gPills">
{gb.fork && <span className="pill pill-g">{gb.fork.model}</span>}
{gb.shock && <span className="pill pill-g">{gb.shock.model}</span>}
{gb.frame && <span className="pill">{gb.frame.model}</span>}
</div>
{gb.setup && (
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:'3px 8px',fontSize:11,color:'var(--txt3)'}}>
{gb.setup.fPsi && <span>Fork: <span style={{color:'var(--sage)'}}>{gb.setup.fPsi} psi</span></span>}
{gb.setup.sPsi && <span>Shock: <span style={{color:'var(--sage)'}}>{gb.setup.sPsi} psi</span></span>}
{gb.setup.fSag && <span>F-Sag: <span style={{color:'var(--sage)'}}>{gb.setup.fSag}%</span></span>}
{gb.setup.sSag && <span>R-Sag: <span style={{color:'var(--sage)'}}>{gb.setup.sSag}%</span></span>}
</div>
)}
</div>
))}
</div>
</div>
);
}
// ── RENDER: AI & Settings tab ─────────────────────────────────────────────
function renderAI() {
return (
<div>
{/* AI Backend settings */}
<div style={{background:'var(--panel)',border:'1px solid var(--bdr)',padding:16,marginBottom:20}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:showAiSettings?14:0}}>
<div style={{fontFamily:'var(--fD)',fontSize:16,letterSpacing:2,textTransform:'uppercase'}}>
AI Backend: <span style={{color:'var(--sage)'}}>{aiConfig.backend === 'anthropic' ? 'Claude (Anthropic API)' : `Ollama — ${aiConfig.model}`}</span>
</div>
<button className="btn btn-sm" onClick={()=>setShowAiSettings(s=>!s)}>{showAiSettings?'Close':'Configure'}</button>
</div>
{showAiSettings && (
<div className="g2" style={{gap:10}}>
<div className="field">
<label>Backend</label>
<select value={aiConfig.backend} onChange={e=>{const v=e.target.value;setAiConfig(c=>({...c,backend:v}));persist({aiConfig:{...aiConfig,backend:v}})}}>
<option value="anthropic">Anthropic API (Claude) Works here</option>
<option value="ollama">Ollama (local) Requires local deployment</option>
</select>
</div>
{aiConfig.backend === 'ollama' && (
<>
<div className="field">
<label>Ollama URL</label>
<input value={aiConfig.ollamaUrl} onChange={e=>setAiConfig(c=>({...c,ollamaUrl:e.target.value}))} placeholder="http://localhost:11434" />
</div>
<div className="field">
<label>Model</label>
<input value={aiConfig.model} onChange={e=>setAiConfig(c=>({...c,model:e.target.value}))} placeholder="llama3.1:8b" />
</div>
<div className="full">
<div className="notice">
Ollama runs locally and cannot be called from this Claude.ai sandbox. To use Ollama, download this code and run it locally (e.g. with Vite/CRA). Recommended models:<br/>
<strong style={{color:'var(--sage)'}}>llama3.1:8b</strong> Best reasoning for setups (default)<br/>
<strong style={{color:'var(--sage)'}}>mistral:7b-instruct</strong> Faster, good structured output<br/>
<strong style={{color:'var(--sage)'}}>qwen2.5:7b</strong> Strong technical reasoning<br/>
Install: <code style={{color:'var(--amb)'}}>ollama pull llama3.1:8b</code>
</div>
</div>
</>
)}
</div>
)}
</div>
{/* Preferences summary */}
{(prefs.harshTendency > 0 || prefs.softTendency > 0) && (
<div className="notice mb">
🧠 <strong>Learned preferences:</strong> Based on {reviews.length} post-ride review{reviews.length>1?'s':''}:
{prefs.harshTendency > 1 && ` You frequently find suspension too harsh (${prefs.harshTendency}×) — AI will bias toward softer settings.`}
{prefs.softTendency > 1 && ` You frequently find suspension too soft (${prefs.softTendency}×) — AI will bias toward firmer settings.`}
{prefs.harshTendency <= 1 && prefs.softTendency <= 1 && ' Your suspension feel is well-balanced across rides.'}
</div>
)}
{/* Current build summary for AI */}
<div style={{background:'var(--panel)',border:'1px solid var(--bdr)',padding:16,marginBottom:16}}>
<div style={{fontFamily:'var(--fD)',fontSize:15,letterSpacing:2,textTransform:'uppercase',color:'var(--txt2)',marginBottom:10}}>Current Build</div>
<div style={{fontSize:11,color:'var(--txt3)',lineHeight:2}}>
{build.frame ? <div>🗂 Frame: <span style={{color:'var(--txt2)'}}>{build.frame.brand} {build.frame.model} ({build.frame.travel}mm / {build.frame.ha}°)</span></div> : <div style={{color:'var(--txt4)'}}>No frame selected</div>}
{build.fork ? <div>🔀 Fork: <span style={{color:'var(--txt2)'}}>{build.fork.brand} {build.fork.model} ({build.fork.travels[1]}mm / {build.fork.damper})</span></div> : <div style={{color:'var(--txt4)'}}>No fork selected</div>}
{build.shock ? <div>🌀 Shock: <span style={{color:'var(--txt2)'}}>{build.shock.brand} {build.shock.model} ({build.shock.type})</span></div> : <div style={{color:'var(--txt4)'}}>No shock selected</div>}
{build.frontTire && <div> F Tire: <span style={{color:'var(--txt2)'}}>{build.frontTire.brand} {build.frontTire.model} {build.frontTire.w}"</span></div>}
{build.rearTire && <div>⬇ R Tire: <span style={{color:'var(--txt2)'}}>{build.rearTire.brand} {build.rearTire.model} {build.rearTire.w}"</span></div>}
</div>
<div style={{marginTop:10,fontSize:11,color:'var(--txt3)'}}>Rider: {rider.weight}kg · {rider.disc} · {rider.level} · {rider.style}</div>
</div>
<button className="btn btn-p" onClick={handleAiRec} disabled={aiLoad || (!build.fork && !build.shock)}>
{aiLoad ? 'Querying AI…' : '🤖 Generate Full Setup Recommendation'}
</button>
{(!build.fork && !build.shock) && <span style={{marginLeft:10,fontSize:10,color:'var(--txt3)'}}>Select at least a fork or shock first</span>}
{aiLoad && <div className="thinking"><div className="dot"/><div className="dot"/><div className="dot"/> Cross-referencing community data & manufacturer specs</div>}
{aiOut && <div className="aiOut">{aiOut}</div>}
{/* Recent reviews */}
{reviews.length > 0 && (
<>
<div className="sH mt2">Recent Post-Ride Reviews</div>
{reviews.slice(0,5).map((r,i) => (
<div key={i} style={{background:'var(--bg3)',border:'1px solid var(--bdr)',padding:'10px 14px',marginBottom:6}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:6}}>
<span style={{fontSize:10,color:'var(--txt3)',letterSpacing:1}}>{new Date(r.date).toLocaleDateString()}</span>
<span style={{fontSize:10,color:r.suspFeel<=3?'var(--amb)':r.suspFeel>=7?'var(--red)':'var(--fern)'}}>Feel: {r.suspFeel}/10</span>
</div>
<MeterBar label="Front grip" value={r.fGrip} />
<MeterBar label="Rear grip" value={r.rGrip} />
{r.notes && <div style={{fontSize:10,color:'var(--txt3)',marginTop:4,fontStyle:'italic'}}>"{r.notes.slice(0,80)}{r.notes.length>80?'…':''}"</div>}
</div>
))}
</>
)}
</div>
);
}
// ── ROOT ──────────────────────────────────────────────────────────────────
return (
<>
<style>{STYLES}</style>
<div className="app">
<div className="hdr">
<div className="logo">
<div className="logo-leaf">L</div>
<h1>LOAM</h1>
</div>
<div className="hdr-sub">MTB Build & Tune</div>
</div>
<div className="tabs">
{[['build','🔧 Build'],['setup','⚙️ Setup'],['review','📊 Post-Ride'],['garage','🏠 Garage'],['ai','🤖 AI Tune']].map(([t,l]) => (
<button key={t} className={`tab${tab===t?' on':''}`} onClick={()=>{setDetailBuild(null);setTab(t)}}>{l}</button>
))}
</div>
{loading ? (
<div className="empty"><div className="thinking"><div className="dot"/><div className="dot"/><div className="dot"/> Loading garage</div></div>
) : (
<>
{tab==='build' && renderBuild()}
{tab==='setup' && renderSetup()}
{tab==='review' && renderReview()}
{tab==='garage' && renderGarage()}
{tab==='ai' && renderAI()}
</>
)}
</div>
{toast && <div className="toast">{toast}</div>}
</>
);
}