|
9 | 9 | let showCreateForm = false; |
10 | 10 | let imageInput: HTMLInputElement; |
11 | 11 |
|
| 12 | + const speciesSuggestions = ['dog','cat','bird','reptile','fish','rabbit','hamster','other'] as const; |
| 13 | + const ageUnitSuggestions = ['years','months','weeks'] as const; |
| 14 | + const genderSuggestions = ['male','female','unknown'] as const; |
| 15 | + const sizeSuggestions = ['tiny','small','medium','large','extra_large'] as const; |
| 16 | +
|
| 17 | + const breedSuggestionsMap: Record<string, string[]> = { |
| 18 | + dog: ['mixed','labrador','golden_retriever','german_shepherd','bulldog'], |
| 19 | + cat: ['persian','siamese','maine_coon','sphynx','mixed'], |
| 20 | + bird: ['budgie','cockatiel','parrot','canary','finch'], |
| 21 | + reptile: ['snake','lizard','turtle','gecko','iguana'], |
| 22 | + fish: ['goldfish','betta','tropical','saltwater'], |
| 23 | + rabbit: ['lop','rex','lionhead','netherland_dwarf'], |
| 24 | + hamster: ['syrian','dwarf','roborovski','chinese'], |
| 25 | + other: [], |
| 26 | + }; |
| 27 | +
|
| 28 | + function getBreedSuggestions(species: string): string[] { |
| 29 | + return breedSuggestionsMap[species] || []; |
| 30 | + } |
| 31 | +
|
| 32 | + const norm = (s: string) => (s || '').trim().toLowerCase(); |
| 33 | +
|
| 34 | + function normalizeAgeUnit(u: string): 'years' | 'months' | 'weeks' { |
| 35 | + const v = norm(u); |
| 36 | + if (v.startsWith('m')) { |
| 37 | + // could be months |
| 38 | + return 'months'; |
| 39 | + } |
| 40 | + if (v.startsWith('w')) { |
| 41 | + return 'weeks'; |
| 42 | + } |
| 43 | + return 'years'; |
| 44 | + } |
| 45 | +
|
| 46 | + function firstSuggestion(suggestions: readonly string[] | string[], value: string): string | null { |
| 47 | + const p = norm(value); |
| 48 | + if (!p) return null; |
| 49 | + const list = Array.from(suggestions); |
| 50 | + return list.find((s) => s.startsWith(p)) || null; |
| 51 | + } |
| 52 | +
|
| 53 | + function handleAutocomplete(field: 'species'|'breed'|'ageUnit'|'gender'|'size', suggestions: readonly string[] | string[], e: KeyboardEvent) { |
| 54 | + if (e.key === 'Enter') { |
| 55 | + const current = (newPet as any)[field] as string; |
| 56 | + const choice = firstSuggestion(suggestions, current); |
| 57 | + if (choice) { |
| 58 | + (newPet as any)[field] = choice; |
| 59 | + e.preventDefault(); |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | +
|
| 64 | + function onSpeciesInput(e: Event) { |
| 65 | + const target = e.target as HTMLInputElement; |
| 66 | + const prev = norm(newPet.species); |
| 67 | + newPet.species = target.value; |
| 68 | + const curr = norm(target.value); |
| 69 | + if (curr !== prev) { |
| 70 | + newPet.breed = ''; |
| 71 | + } |
| 72 | + } |
| 73 | +
|
12 | 74 | let newPet = { |
13 | 75 | name: '', |
| 76 | + species: '', |
14 | 77 | breed: '', |
15 | 78 | age: '', |
16 | | - gender: '', |
| 79 | + ageUnit: 'years', |
| 80 | + gender: 'unknown', |
| 81 | + size: 'medium', |
17 | 82 | profileImageUrl: '', |
18 | 83 | }; |
19 | 84 |
|
|
40 | 105 | function resetForm() { |
41 | 106 | newPet = { |
42 | 107 | name: '', |
| 108 | + species: '', |
43 | 109 | breed: '', |
44 | 110 | age: '', |
45 | | - gender: '', |
| 111 | + ageUnit: 'years', |
| 112 | + gender: 'unknown', |
| 113 | + size: 'medium', |
46 | 114 | profileImageUrl: '', |
47 | 115 | }; |
48 | 116 | formErrors = {}; |
|
74 | 142 | formErrors.name = 'Pet name is required'; |
75 | 143 | } |
76 | 144 |
|
| 145 | + if (!newPet.species) { |
| 146 | + formErrors.species = 'Please enter species'; |
| 147 | + } |
| 148 | +
|
77 | 149 | if (!newPet.breed.trim()) { |
78 | | - formErrors.breed = 'Breed is required'; |
| 150 | + // Breed can be optional for some species; provide hint if suggestions exist |
| 151 | + const suggestions = getBreedSuggestions(newPet.species); |
| 152 | + if (suggestions.length) { |
| 153 | + formErrors.breed = `Consider selecting a type e.g. ${suggestions.slice(0,3).join('|')}`; |
| 154 | + } |
79 | 155 | } |
80 | 156 |
|
81 | 157 | const age = parseInt(newPet.age); |
82 | | - if (!newPet.age || age < 0 || age > 30) { |
83 | | - formErrors.age = 'Please enter a valid age (0-30 years)'; |
| 158 | + if (Number.isNaN(age) || age < 0) { |
| 159 | + formErrors.age = 'Please enter a valid age'; |
84 | 160 | } |
85 | 161 |
|
86 | 162 | if (!newPet.gender) { |
|
96 | 172 | const pet: PetPanelData = { |
97 | 173 | id: Date.now().toString(), |
98 | 174 | name: newPet.name.trim(), |
| 175 | + species: newPet.species || 'other', |
99 | 176 | breed: newPet.breed.trim(), |
100 | 177 | age: parseInt(newPet.age), |
101 | | - gender: newPet.gender as 'male' | 'female', |
| 178 | + ageUnit: normalizeAgeUnit(newPet.ageUnit), |
| 179 | + gender: (newPet.gender as 'male'|'female'|'unknown'), |
| 180 | + size: newPet.size as 'tiny'|'small'|'medium'|'large'|'extra_large', |
102 | 181 | profileImageUrl: newPet.profileImageUrl || '/images/default-pet.png', |
103 | 182 | createdAt: new Date().toISOString(), |
104 | 183 | journalEntries: [], |
|
137 | 216 |
|
138 | 217 | {#if showCreateForm} |
139 | 218 | <div class="mt-2 p-2 rounded" style="background: var(--petalytics-overlay);"> |
| 219 | + <!-- section header --> |
| 220 | + <div class="cli-row px-2 py-1"> |
| 221 | + <span style="color: var(--petalytics-subtle);">#</span> |
| 222 | + <span class="ml-2" style="color: var(--petalytics-gold);">new_pet</span> |
| 223 | + </div> |
140 | 224 | <!-- name --> |
141 | 225 | <div class="cli-row px-2 py-1"> |
142 | 226 | <span class="label">name</span> |
|
146 | 230 | <p class="px-2 text-xs" style="color: var(--petalytics-love);">{formErrors.name}</p> |
147 | 231 | {/if} |
148 | 232 |
|
149 | | - <!-- breed --> |
| 233 | + <!-- species (text + autocomplete) --> |
| 234 | + <div class="cli-row px-2 py-1"> |
| 235 | + <span class="label">species</span> |
| 236 | + <input class="value bg-transparent border-none outline-none input-inline" bind:value={newPet.species} placeholder="e.g. bird" list="species-suggestions" oninput={onSpeciesInput} onkeydown={(e) => handleAutocomplete('species', speciesSuggestions, e)} /> |
| 237 | + </div> |
| 238 | + <datalist id="species-suggestions"> |
| 239 | + {#each speciesSuggestions as s} |
| 240 | + <option value={s}></option> |
| 241 | + {/each} |
| 242 | + </datalist> |
| 243 | + {#if formErrors.species} |
| 244 | + <p class="px-2 text-xs" style="color: var(--petalytics-love);">{formErrors.species}</p> |
| 245 | + {/if} |
| 246 | + |
| 247 | + <!-- breed (text + species-based autocomplete) --> |
150 | 248 | <div class="cli-row px-2 py-1"> |
151 | 249 | <span class="label">breed</span> |
152 | | - <input class="value bg-transparent border-none outline-none input-inline" bind:value={newPet.breed} placeholder="Breed" /> |
| 250 | + <input class="value bg-transparent border-none outline-none input-inline" bind:value={newPet.breed} placeholder="Breed / type" list="breed-suggestions" onkeydown={(e) => handleAutocomplete('breed', getBreedSuggestions(norm(newPet.species)), e)} /> |
153 | 251 | </div> |
| 252 | + <!-- suggestions datalist --> |
| 253 | + <datalist id="breed-suggestions"> |
| 254 | + {#each getBreedSuggestions(newPet.species) as suggestion} |
| 255 | + <option value={suggestion}></option> |
| 256 | + {/each} |
| 257 | + </datalist> |
154 | 258 | {#if formErrors.breed} |
155 | 259 | <p class="px-2 text-xs" style="color: var(--petalytics-love);">{formErrors.breed}</p> |
156 | 260 | {/if} |
157 | 261 |
|
158 | 262 | <!-- age --> |
159 | 263 | <div class="cli-row px-2 py-1"> |
160 | 264 | <span class="label">age</span> |
161 | | - <input class="value bg-transparent border-none outline-none input-inline" type="number" min="0" max="30" bind:value={newPet.age} placeholder="Age" /> |
| 265 | + <div class="value flex items-center justify-end gap-2"> |
| 266 | + <input class="bg-transparent border-none outline-none input-inline w-20 text-right" type="number" min="0" bind:value={newPet.age} placeholder="0" /> |
| 267 | + <input class="bg-transparent border-none outline-none input-inline w-28" bind:value={newPet.ageUnit} placeholder="years|months|weeks" list="age-unit-suggestions" onkeydown={(e) => handleAutocomplete('ageUnit', ageUnitSuggestions, e)} /> |
| 268 | + </div> |
162 | 269 | </div> |
| 270 | + <datalist id="age-unit-suggestions"> |
| 271 | + {#each ageUnitSuggestions as u} |
| 272 | + <option value={u}></option> |
| 273 | + {/each} |
| 274 | + </datalist> |
163 | 275 | {#if formErrors.age} |
164 | 276 | <p class="px-2 text-xs" style="color: var(--petalytics-love);">{formErrors.age}</p> |
165 | 277 | {/if} |
166 | 278 |
|
167 | 279 | <!-- gender --> |
168 | 280 | <div class="cli-row px-2 py-1"> |
169 | 281 | <span class="label">gender</span> |
170 | | - <select class="value bg-transparent border-none outline-none input-inline" bind:value={newPet.gender}> |
171 | | - <option value="">Select gender</option> |
172 | | - <option value="male">male</option> |
173 | | - <option value="female">female</option> |
174 | | - </select> |
| 282 | + <input class="value bg-transparent border-none outline-none input-inline" bind:value={newPet.gender} placeholder="male|female|unknown" list="gender-suggestions" onkeydown={(e) => handleAutocomplete('gender', genderSuggestions, e)} /> |
175 | 283 | </div> |
| 284 | + <datalist id="gender-suggestions"> |
| 285 | + {#each genderSuggestions as g} |
| 286 | + <option value={g}></option> |
| 287 | + {/each} |
| 288 | + </datalist> |
176 | 289 | {#if formErrors.gender} |
177 | 290 | <p class="px-2 text-xs" style="color: var(--petalytics-love);">{formErrors.gender}</p> |
178 | 291 | {/if} |
179 | 292 |
|
| 293 | + <!-- size --> |
| 294 | + <div class="cli-row px-2 py-1"> |
| 295 | + <span class="label">size</span> |
| 296 | + <input class="value bg-transparent border-none outline-none input-inline" bind:value={newPet.size} placeholder="tiny|small|medium|large|extra_large" list="size-suggestions" onkeydown={(e) => handleAutocomplete('size', sizeSuggestions, e)} /> |
| 297 | + </div> |
| 298 | + <datalist id="size-suggestions"> |
| 299 | + {#each sizeSuggestions as s} |
| 300 | + <option value={s}></option> |
| 301 | + {/each} |
| 302 | + </datalist> |
| 303 | + |
180 | 304 | <!-- profile image upload --> |
181 | 305 | <div class="cli-row px-2 py-1" style="align-items: flex-start;"> |
182 | 306 | <span class="label">profile_image</span> |
|
205 | 329 | <!-- Pets list --> |
206 | 330 | <div class="cli-row px-2 py-1"> |
207 | 331 | <span style="color: var(--petalytics-subtle);">#</span> |
208 | | - <span class="ml-2" style="color: var(--petalytics-gold);">pets</span> |
| 332 | + <span class="ml-2" style="color: var(--petalytics-gold);">active_pets</span> |
209 | 333 | </div> |
210 | 334 |
|
211 | 335 | {#if pets.length === 0} |
|
215 | 339 | <div class="cli-row px-2 py-1" role="button" tabindex="0" data-selected={selectedPetId === pet.id} onclick={() => selectPet(pet.id)} onkeydown={(e) => handleActivate(e, () => selectPet(pet.id))}> |
216 | 340 | <span class="label" style="color: var(--petalytics-text);">{pet.name}</span> |
217 | 341 | <span class="value" style="color: var(--petalytics-subtle);"> |
218 | | - {pet.breed} | {pet.age}{pet.age === 1 ? 'y' : 'y'} |
| 342 | + {pet.species || 'pet'} | {pet.breed || '—'} | {pet.age}{pet.ageUnit === 'months' ? 'm' : pet.ageUnit === 'weeks' ? 'w' : 'y'} |
219 | 343 | </span> |
220 | 344 | </div> |
221 | 345 | {/each} |
|
0 commit comments