Polish: live OFA age warning, puppy sorting, responsive fixes, form hints

- HealthRecordForm shows a live amber warning under Test Date when the dog is
  under the OFA minimum certification age (submit-time block unchanged)
- LitterDetail puppies sortable by name / sex / birth date
- Responsive at 375px: DogDetail photos+info grid stacks, header wraps;
  puppy grid uses min(220px,100%); navbar wraps with scrollable icon row;
  calendar uses minmax(0,1fr) columns so cells shrink instead of overflowing
- LitterForm explains why sire/dam selects are locked when editing
- Add .claude/launch.json for the vite dev server

Verified in-browser at 375px and desktop: no horizontal overflow on any page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-06 15:06:32 -05:00
parent 55d636b512
commit f3fc01f31c
7 changed files with 72 additions and 14 deletions
+11 -3
View File
@@ -139,14 +139,22 @@
height: 4rem;
}
.nav-links {
.navbar .container {
flex-wrap: wrap;
gap: 0.25rem;
}
.nav-links {
gap: 0.25rem;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.nav-link span {
display: none;
}
.nav-link {
padding: 0.625rem;
}
@@ -100,6 +100,14 @@ export default function HealthRecordForm({ dogId, dogBirthDate, record, onClose,
test_type: v === 'ofa_clearance' ? (f.test_type || 'hip_ofa') : f.test_type,
}))
// Live warning while typing — validate() still hard-blocks on submit.
const ageWarning = (() => {
if (!isOFA || !ofaTest?.minAgeMonths || !dogBirthDate || !form.test_date) return null
const months = (new Date(form.test_date) - new Date(dogBirthDate)) / (1000 * 60 * 60 * 24 * 30.44)
if (months < 0 || months >= ofaTest.minAgeMonths) return null
return `Dog was ~${Math.floor(months)} months old on this date — final ${ofaTest.label} certification requires ${ofaTest.minAgeMonths} months.`
})()
const validate = () => {
if (!form.test_date) return 'A date is required.'
if (form.test_date > today()) return 'Date cannot be in the future.'
@@ -250,6 +258,9 @@ export default function HealthRecordForm({ dogId, dogBirthDate, record, onClose,
<label style={labelStyle}>Test Date *</label>
<input style={inputStyle} type="date" required max={today()} value={form.test_date}
onChange={e => set('test_date', e.target.value)} />
{ageWarning && (
<span style={{ ...hintStyle, color: 'var(--warning)' }}>{ageWarning}</span>
)}
</div>
{ofaTest?.recurs ? (
<div style={fw}>
+5
View File
@@ -116,6 +116,11 @@ function LitterForm({ litter, prefill, onClose, onSave }) {
<option key={d.id} value={d.id}>{d.name} {d.registration_number ? `(${d.registration_number})` : ''}</option>
))}
</select>
{!!litter && (
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
Parents can't be changed after the litter is created.
</p>
)}
</div>
<div className="form-group">
+2 -2
View File
@@ -534,7 +534,7 @@ export default function BreedingCalendar() {
</div>
{/* Day headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', borderBottom: '1px solid var(--border)' }}>
{DAY_NAMES.map(d => (
<div key={d} style={{ padding: '0.5rem', textAlign: 'center', fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
))}
@@ -544,7 +544,7 @@ export default function BreedingCalendar() {
{loading ? (
<div className="loading" style={{ minHeight: 280 }}>Loading calendar</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))' }}>
{Array.from({ length: rows * 7 }).map((_, idx) => {
const dayNum = idx - startPad + 1
const isValid = dayNum >= 1 && dayNum <= lastDay.getDate()
+2 -2
View File
@@ -126,7 +126,7 @@ function DogDetail() {
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
<button className="btn-icon" onClick={() => navigate('/dogs')} style={{ marginRight: '0.5rem' }}>
<ArrowLeft size={20} />
</button>
@@ -160,7 +160,7 @@ function DogDetail() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(320px, 100%), 1fr))', gap: '1.5rem', marginBottom: '1.5rem' }}>
{/* Photo Section */}
<div className="card" style={{ padding: '1rem' }}>
<div style={{ marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+29 -7
View File
@@ -272,6 +272,7 @@ function LitterDetail() {
const [addMode, setAddMode] = useState('existing')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [puppySort, setPuppySort] = useState('name')
const toast = useToast()
const confirm = useConfirm()
@@ -351,6 +352,12 @@ function LitterDetail() {
const puppyCount = litter.puppies?.length ?? 0
const sortedPuppies = [...(litter.puppies || [])].sort((a, b) => {
if (puppySort === 'sex') return (a.sex || '').localeCompare(b.sex || '') || (a.name || '').localeCompare(b.name || '')
if (puppySort === 'dob') return (a.date_of_birth || '').localeCompare(b.date_of_birth || '') || (a.name || '').localeCompare(b.name || '')
return (a.name || '').localeCompare(b.name || '')
})
return (
<div className="container">
{/* Header */}
@@ -405,12 +412,27 @@ function LitterDetail() {
)}
{/* Puppies section */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h2 style={{ margin: 0 }}>Puppies</h2>
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
<Plus size={16} style={{ marginRight: '0.4rem' }} />
Add Puppy
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{puppyCount > 1 && (
<select
value={puppySort}
onChange={e => setPuppySort(e.target.value)}
className="input"
style={{ width: 'auto', fontSize: '0.85rem', padding: '0.4rem 0.6rem' }}
aria-label="Sort puppies"
>
<option value="name">Sort: Name</option>
<option value="sex">Sort: Sex</option>
<option value="dob">Sort: Birth Date</option>
</select>
)}
<button className="btn btn-primary" onClick={() => { setShowAddPuppy(true); setError('') }}>
<Plus size={16} style={{ marginRight: '0.4rem' }} />
Add Puppy
</button>
</div>
</div>
{puppyCount === 0 ? (
@@ -419,8 +441,8 @@ function LitterDetail() {
<p style={{ color: 'var(--text-secondary)' }}>No puppies linked yet. Add puppies to this litter.</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '1rem' }}>
{litter.puppies.map(puppy => (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(220px, 100%), 1fr))', gap: '1rem' }}>
{sortedPuppies.map(puppy => (
<div key={puppy.id} className="card" style={{ position: 'relative' }}>
<button
className="btn-icon"