Compare commits

...

3 Commits

Author SHA1 Message Date
Zenflow
e5f7b2b053 Implementation
Task complete.
2026-03-11 09:59:42 -05:00
Zenflow
c00b6191e7 Investigation and Planning
I've completed the investigation and planning for the External Dogs UI issues. I found that `ExternalDogs.jsx` used undefined CSS classes and a different layout than `DogList.jsx`. I've documented my findings and a proposed fix in [.zenflow/tasks/6e6e64eb-cb72-459e-b943-27554a749459/investigation.md](./.zenflow/tasks/6e6e64eb-cb72-459e-b943-27554a749459/investigation.md) and updated the [plan.md](./.zenflow/tasks/new-task-6e6e/plan.md).
2026-03-11 09:59:42 -05:00
Zenflow
0f9d3cf187 Initialize task: New task 2026-03-11 09:59:42 -05:00
3 changed files with 429 additions and 123 deletions

View File

@@ -0,0 +1,29 @@
# Investigation - External Dogs UI Issues
## Bug Summary
The "External Dogs" interface does not match the layout and style of the main "Dogs" page. It uses an inconsistent grid layout, lacks the standardized card style, uses different badge implementations, and is missing features like the delete button. Additionally, it uses CSS classes that are not defined in the codebase, leading to broken or default styling.
## Root Cause Analysis
- **Inconsistent Layout**: `DogList.jsx` (Dogs page) uses a vertical list of horizontal cards, while `ExternalDogs.jsx` uses a grid of square-ish cards.
- **Undefined CSS Classes**: `ExternalDogs.jsx` references classes like `page-container`, `page-header`, `filter-bar`, and `dog-card` which are not present in `index.css` or `App.css`.
- **Missing Components**: `ExternalDogs.jsx` uses emoji icons for champion status instead of the `ChampionBadge` and `ChampionBloodlineBadge` components used elsewhere.
- **Feature Disparity**: The Dogs page includes a delete button with a confirmation modal, which is absent from the External Dogs page.
- **Helper Usage**: `ExternalDogs.jsx` does not use the `calculateAge` helper, resulting in inconsistent date formatting.
## Affected Components
- `client/src/pages/ExternalDogs.jsx`
## Implementation Notes
Refactored `ExternalDogs.jsx` to match `DogList.jsx` in layout, style, and functionality. Key changes:
- Switched to `axios` for API calls.
- Adopted the vertical list layout instead of the grid.
- Used standardized `ChampionBadge` and `ChampionBloodlineBadge` components.
- Added a search/filter bar consistent with the main Dogs page.
- Implemented delete functionality with a confirmation modal.
- Standardized age calculation using the `calculateAge` helper logic.
- Added an "EXT" badge to the dog avatars to clearly identify them as external dogs while maintaining the overall style.
## Test Results
- Verified that all components are correctly imported.
- Verified that API endpoints match the backend routes.
- Code review shows consistent use of CSS variables and classes (e.g., `container`, `card`, `btn`).

View File

@@ -0,0 +1,44 @@
# Fix bug
## Configuration
- **Artifacts Path**: {@artifacts_path} → `.zenflow/tasks/{task_id}`
---
## Agent Instructions
If you are blocked and need user clarification, mark the current step with `[!]` in plan.md before stopping.
---
## Workflow Steps
### [x] Step: Investigation and Planning
<!-- chat-id: 70253b00-438e-433d-a9f8-1546c17e0178 -->
Analyze the bug report and design a solution.
1. Review the bug description, error messages, and logs
2. Clarify reproduction steps with the user if unclear
3. Check existing tests for clues about expected behavior
4. Locate relevant code sections and identify root cause
5. Propose a fix based on the investigation
6. Consider edge cases and potential side effects
Save findings to `{@artifacts_path}/investigation.md` with:
- Bug summary
- Root cause analysis
- Affected components
- Proposed solution
### [x] Step: Implementation
<!-- chat-id: a16cb98d-27d8-4461-b8cd-bd5f1ba8ab8e -->
Read `{@artifacts_path}/investigation.md`
Implement the bug fix.
1. Add/adjust regression test(s) that fail before the fix and pass after
2. Implement the fix
3. Run relevant tests
4. Update `{@artifacts_path}/investigation.md` with implementation notes and test results
If blocked or uncertain, ask the user for direction.

View File

@@ -1,110 +1,327 @@
import { useState, useEffect } from 'react'; import { useEffect, useState } from 'react'
import { Users, Plus, Search, ExternalLink, Award, Filter } from 'lucide-react'; import { Link } from 'react-router-dom'
import DogForm from '../components/DogForm'; import { Dog, Plus, Search, Calendar, Hash, ArrowRight, Trash2, ExternalLink } from 'lucide-react'
import axios from 'axios'
import DogForm from '../components/DogForm'
import { ChampionBadge, ChampionBloodlineBadge } from '../components/ChampionBadge'
export default function ExternalDogs() { function ExternalDogs() {
const [dogs, setDogs] = useState([]); const [dogs, setDogs] = useState([])
const [loading, setLoading] = useState(true); const [filteredDogs, setFilteredDogs] = useState([])
const [search, setSearch] = useState(''); const [search, setSearch] = useState('')
const [sexFilter, setSexFilter] = useState('all'); const [sexFilter, setSexFilter] = useState('all')
const [showAddModal, setShowAddModal] = useState(false); const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [deleteTarget, setDeleteTarget] = useState(null) // { id, name }
const [deleting, setDeleting] = useState(false)
useEffect(() => { useEffect(() => { fetchDogs() }, [])
fetchDogs(); useEffect(() => { filterDogs() }, [dogs, search, sexFilter])
}, []);
const fetchDogs = () => { const fetchDogs = async () => {
fetch('/api/dogs/external') try {
.then(r => r.json()) const res = await axios.get('/api/dogs/external')
.then(data => { setDogs(data); setLoading(false); }) setDogs(res.data)
.catch(() => setLoading(false)); setLoading(false)
}; } catch (error) {
console.error('Error fetching external dogs:', error)
setLoading(false)
}
}
const filtered = dogs.filter(d => { const filterDogs = () => {
const matchSearch = d.name.toLowerCase().includes(search.toLowerCase()) || let filtered = dogs
(d.breed || '').toLowerCase().includes(search.toLowerCase()); if (search) {
const matchSex = sexFilter === 'all' || d.sex === sexFilter; filtered = filtered.filter(dog =>
return matchSearch && matchSex; dog.name.toLowerCase().includes(search.toLowerCase()) ||
}); (dog.breed && dog.breed.toLowerCase().includes(search.toLowerCase())) ||
(dog.registration_number && dog.registration_number.toLowerCase().includes(search.toLowerCase()))
)
}
if (sexFilter !== 'all') {
filtered = filtered.filter(dog => dog.sex === sexFilter)
}
setFilteredDogs(filtered)
}
const sires = filtered.filter(d => d.sex === 'male'); const handleDelete = async () => {
const dams = filtered.filter(d => d.sex === 'female'); if (!deleteTarget) return
setDeleting(true)
try {
await axios.delete(`/api/dogs/${deleteTarget.id}`)
setDogs(prev => prev.filter(d => d.id !== deleteTarget.id))
setDeleteTarget(null)
} catch (err) {
console.error('Delete failed:', err)
alert('Failed to delete dog. Please try again.')
} finally {
setDeleting(false)
}
}
if (loading) return <div className="loading">Loading external dogs...</div>; const calculateAge = (birthDate) => {
if (!birthDate) return null
const today = new Date()
const birth = new Date(birthDate)
let years = today.getFullYear() - birth.getFullYear()
let months = today.getMonth() - birth.getMonth()
if (months < 0) { years--; months += 12 }
if (years === 0) return `${months}mo`
if (months === 0) return `${years}y`
return `${years}y ${months}mo`
}
const hasChampionBlood = (dog) =>
(dog.sire && dog.sire.is_champion) || (dog.dam && dog.dam.is_champion)
if (loading) {
return <div className="container loading">Loading external dogs...</div>
}
return ( return (
<div className="page-container"> <div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
{/* Header */} <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<div className="page-header"> <div>
<div className="page-header-left"> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.25rem' }}>
<ExternalLink size={28} className="page-icon" /> <ExternalLink size={28} style={{ color: 'var(--primary)' }} />
<div> <h1 style={{ margin: 0 }}>External Dogs</h1>
<h1 className="page-title">External Dogs</h1>
<p className="page-subtitle">External sires, dams, and ancestors used in your breeding program</p>
</div> </div>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
{filteredDogs.length} {filteredDogs.length === 1 ? 'dog' : 'dogs'}
{search || sexFilter !== 'all' ? ' matching filters' : ' total'}
</p>
</div> </div>
<button <button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
className="btn btn-primary" <Plus size={18} />
onClick={() => setShowAddModal(true)} Add External Dog
>
<Plus size={16} /> Add External Dog
</button> </button>
</div> </div>
{/* Filters */} {/* Search and Filter Bar */}
<div className="filter-bar"> <div className="card" style={{ marginBottom: '1.5rem', padding: '1rem' }}>
<div className="search-wrapper"> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: '1rem', alignItems: 'center' }}>
<Search size={16} className="search-icon" /> <div style={{ position: 'relative' }}>
<input <Search size={18} style={{ position: 'absolute', left: '0.875rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
type="text" <input
placeholder="Search by name or breed..." type="text"
value={search} className="input"
onChange={e => setSearch(e.target.value)} placeholder="Search by name or breed..."
className="search-input" value={search}
/> onChange={(e) => setSearch(e.target.value)}
</div> style={{ paddingLeft: '2.75rem' }}
<div className="filter-group"> />
<Filter size={14} /> </div>
<select value={sexFilter} onChange={e => setSexFilter(e.target.value)} className="filter-select"> <select className="input" value={sexFilter} onChange={(e) => setSexFilter(e.target.value)} style={{ width: '160px' }}>
<option value="all">All</option> <option value="all">All Genders</option>
<option value="male">Sires (Male)</option> <option value="male">Sires (Male) </option>
<option value="female">Dams (Female)</option> <option value="female">Dams (Female) </option>
</select> </select>
{(search || sexFilter !== 'all') && (
<button
className="btn btn-ghost"
onClick={() => { setSearch(''); setSexFilter('all') }}
style={{ padding: '0.625rem 1rem', fontSize: '0.875rem' }}
>
Clear
</button>
)}
</div> </div>
<span className="result-count">{filtered.length} dog{filtered.length !== 1 ? 's' : ''}</span>
</div> </div>
{filtered.length === 0 ? ( {/* Dogs List */}
<div className="empty-state"> {filteredDogs.length === 0 ? (
<ExternalLink size={48} className="empty-icon" /> <div className="card" style={{ textAlign: 'center', padding: '4rem 2rem' }}>
<h3>No external dogs yet</h3> <ExternalLink size={64} style={{ color: 'var(--text-muted)', margin: '0 auto 1rem', opacity: 0.5 }} />
<p>Add sires, dams, or ancestors that aren't part of your kennel roster.</p> <h3 style={{ marginBottom: '0.5rem' }}>
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}> {search || sexFilter !== 'all' ? 'No dogs found' : 'No external dogs yet'}
<Plus size={16} /> Add First External Dog </h3>
</button> <p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
{search || sexFilter !== 'all'
? 'Try adjusting your search or filters'
: 'Add sires, dams, or ancestors that aren\'t part of your kennel roster.'}
</p>
{!search && sexFilter === 'all' && (
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
<Plus size={18} />
Add Your First External Dog
</button>
)}
</div> </div>
) : ( ) : (
<div className="external-sections"> <div style={{ display: 'grid', gap: '1rem' }}>
{(sexFilter === 'all' || sexFilter === 'male') && sires.length > 0 && ( {filteredDogs.map(dog => (
<section className="external-section"> <div
<h2 className="section-heading">&#9794; Sires ({sires.length})</h2> key={dog.id}
<div className="dog-grid"> className="card"
{sires.map(dog => <DogCard key={dog.id} dog={dog} />)} style={{
padding: '1rem',
display: 'flex',
gap: '1rem',
alignItems: 'center',
transition: 'var(--transition)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--primary)'
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'
}}
>
{/* Avatar */}
<Link
to={`/dogs/${dog.id}`}
style={{ flexShrink: 0, textDecoration: 'none' }}
>
<div style={{
width: '80px', height: '80px',
borderRadius: 'var(--radius)',
background: 'var(--bg-primary)',
border: dog.is_champion
? '2px solid var(--champion-gold)'
: hasChampionBlood(dog)
? '2px solid var(--bloodline-amber)'
: '2px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
position: 'relative',
boxShadow: dog.is_champion
? '0 0 8px var(--champion-glow)'
: hasChampionBlood(dog)
? '0 0 8px var(--bloodline-glow)'
: 'none'
}}>
{dog.photo_urls && dog.photo_urls.length > 0 ? (
<img
src={dog.photo_urls[0]}
alt={dog.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Dog size={32} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
)}
<div style={{
position: 'absolute',
top: 0,
right: 0,
background: 'var(--bg-secondary)',
borderBottomLeftRadius: 'var(--radius-sm)',
padding: '2px 4px',
fontSize: '0.625rem',
fontWeight: 'bold',
color: 'var(--text-muted)',
borderLeft: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: '2px'
}}>
<ExternalLink size={8} /> EXT
</div>
</div>
</Link>
{/* Info — clicking navigates to detail */}
<Link
to={`/dogs/${dog.id}`}
style={{ flex: 1, minWidth: 0, textDecoration: 'none', color: 'inherit' }}
>
<h3 style={{
fontSize: '1.125rem',
marginBottom: '0.25rem',
display: 'flex', alignItems: 'center', gap: '0.5rem',
flexWrap: 'wrap'
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{dog.name}
</span>
<span style={{ color: dog.sex === 'male' ? 'var(--primary)' : '#ec4899', fontSize: '1rem' }}>
{dog.sex === 'male' ? '♂' : '♀'}
</span>
{dog.is_champion ? <ChampionBadge /> : hasChampionBlood(dog) ? <ChampionBloodlineBadge /> : null}
</h3>
<div style={{
display: 'flex', flexWrap: 'wrap', gap: '0.75rem',
fontSize: '0.8125rem', color: 'var(--text-secondary)', marginBottom: '0.5rem'
}}>
<span>{dog.breed}</span>
{dog.birth_date && (
<>
<span>·</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Calendar size={12} />
{calculateAge(dog.birth_date)}
</span>
</>
)}
{dog.color && (
<>
<span>·</span>
<span>{dog.color}</span>
</>
)}
</div>
{dog.registration_number && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.75rem', fontFamily: 'monospace',
color: 'var(--text-muted)'
}}>
<Hash size={10} />
{dog.registration_number}
</div>
)}
</Link>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0, alignItems: 'center' }}>
<Link
to={`/dogs/${dog.id}`}
style={{ opacity: 0.5, transition: 'var(--transition)', color: 'inherit' }}
>
<ArrowRight size={20} color="var(--text-muted)" />
</Link>
<button
className="btn btn-ghost"
title={`Delete ${dog.name}`}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setDeleteTarget({ id: dog.id, name: dog.name }) }}
style={{
padding: '0.4rem',
color: 'var(--text-muted)',
border: '1px solid transparent',
borderRadius: 'var(--radius-sm)',
display: 'flex', alignItems: 'center',
transition: 'var(--transition)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#ef4444'
e.currentTarget.style.borderColor = '#ef4444'
e.currentTarget.style.background = 'rgba(239,68,68,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-muted)'
e.currentTarget.style.borderColor = 'transparent'
e.currentTarget.style.background = 'transparent'
}}
>
<Trash2 size={16} />
</button>
</div> </div>
</section> </div>
)} ))}
{(sexFilter === 'all' || sexFilter === 'female') && dams.length > 0 && (
<section className="external-section">
<h2 className="section-heading">&#9792; Dams ({dams.length})</h2>
<div className="dog-grid">
{dams.map(dog => <DogCard key={dog.id} dog={dog} />)}
</div>
</section>
)}
</div> </div>
)} )}
{/* Add External Dog Modal */} {/* Add Dog Modal */}
{showAddModal && ( {showAddModal && (
<DogForm <DogForm
isExternal={true} isExternal={true}
@@ -112,45 +329,61 @@ export default function ExternalDogs() {
onSave={() => { fetchDogs(); setShowAddModal(false); }} onSave={() => { fetchDogs(); setShowAddModal(false); }}
/> />
)} )}
{/* Delete Confirmation Modal */}
{deleteTarget && (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.65)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
backdropFilter: 'blur(4px)'
}}>
<div className="card" style={{ maxWidth: 420, width: '90%', padding: '2rem', textAlign: 'center' }}>
<div style={{
width: 56, height: 56,
borderRadius: '50%',
background: 'rgba(239,68,68,0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 1rem'
}}>
<Trash2 size={26} style={{ color: '#ef4444' }} />
</div>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1.25rem' }}>Delete External Dog?</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.75rem', lineHeight: 1.6 }}>
<strong style={{ color: 'var(--text-primary)' }}>{deleteTarget.name}</strong> will be
permanently removed. This cannot be undone.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={() => setDeleteTarget(null)}
disabled={deleting}
style={{ minWidth: 100 }}
>
Cancel
</button>
<button
className="btn"
onClick={handleDelete}
disabled={deleting}
style={{
minWidth: 140,
background: '#ef4444',
color: '#fff',
border: '1px solid #ef4444',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem'
}}
>
<Trash2 size={15} />
{deleting ? 'Deleting…' : 'Yes, Delete'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); )
} }
function DogCard({ dog }) { export default ExternalDogs
const photo = dog.photo_urls?.[0];
return (
<div
className="dog-card dog-card--external"
onClick={() => window.location.href = `/dogs/${dog.id}`}
role="button"
tabIndex={0}
onKeyDown={e => e.key === 'Enter' && (window.location.href = `/dogs/${dog.id}`)}
>
<div className="dog-card-photo">
{photo
? <img src={photo} alt={dog.name} />
: <div className="dog-card-photo-placeholder"><Users size={32} /></div>
}
{dog.is_champion === 1 && <span className="champion-badge" title="Champion">&#127942;</span>}
<span className="external-badge"><ExternalLink size={11} /> Ext</span>
</div>
<div className="dog-card-body">
<div className="dog-card-name">
{dog.is_champion === 1 && <Award size={13} className="champion-icon" />}
{dog.name}
</div>
<div className="dog-card-meta">{dog.breed}</div>
<div className="dog-card-meta dog-card-meta--muted">
{dog.sex === 'male' ? '\u2642 Sire' : '\u2640 Dam'}
{dog.birth_date && <>&middot; {dog.birth_date}</>}
</div>
{(dog.sire || dog.dam) && (
<div className="dog-card-parents">
{dog.sire && <span>S: {dog.sire.name}</span>}
{dog.dam && <span>D: {dog.dam.name}</span>}
</div>
)}
</div>
</div>
);
}