From a5061d4312da109cfbc61392f979ee51d311dba6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:36:31 +0000 Subject: [PATCH 1/2] Initial plan From 1bc5fd4a79ace0c8d38cae1af0b628873719b7a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:52:43 +0000 Subject: [PATCH 2/2] feat: implement full ObservationTracker project structure - Add .gitignore and MIT LICENSE - Create docs/ with all 11 documentation files (Vision, Requirements, Architecture, UX, DesignSystem, DataModel, Repository, CodingStandards, Milestones, DecisionLog, AIInstructions) - Build complete vanilla-JS SPA under web/: - models/: Observation factory, Category loader - repository/: ObservationRepository (localStorage CRUD) - services/: ObservationService, LocationService, ExportService - components/: NavBar, ObservationCard, ObservationModal, FilterBar, Toast, ConfirmDialog (XSS-safe via textContent/escapeHtml) - screens/: Home, LogObservation, MyObservations, Map, Stats - utils/: date, validation, format helpers - data/: categories.json, species.json reference data - assets/icons/ placeholder - index.html app shell and styles.css design system - Add ios/README.md placeholder for Milestone 4 - Update README.md with project overview and quickstart --- .gitignore | 47 ++ LICENSE | 21 + README.md | 50 +- docs/AIInstructions.md | 68 ++ docs/Architecture.md | 82 +++ docs/CodingStandards.md | 73 ++ docs/DataModel.md | 114 +++ docs/DecisionLog.md | 91 +++ docs/DesignSystem.md | 130 ++++ docs/Milestones.md | 53 ++ docs/Repository.md | 69 ++ docs/Requirements.md | 54 ++ docs/UX.md | 82 +++ docs/Vision.md | 34 + ios/README.md | 17 + web/app.js | 78 ++ web/assets/icons/.gitkeep | 0 web/components/ConfirmDialog.js | 66 ++ web/components/FilterBar.js | 41 ++ web/components/NavBar.js | 44 ++ web/components/ObservationCard.js | 43 ++ web/components/ObservationModal.js | 92 +++ web/components/Toast.js | 38 + web/data/categories.json | 10 + web/data/species.json | 74 ++ web/index.html | 28 + web/models/Category.js | 41 ++ web/models/Observation.js | 84 +++ web/repository/ObservationRepository.js | 104 +++ web/screens/HomeScreen.js | 82 +++ web/screens/LogObservationScreen.js | 235 ++++++ web/screens/MapScreen.js | 95 +++ web/screens/MyObservationsScreen.js | 116 +++ web/screens/StatsScreen.js | 193 +++++ web/services/ExportService.js | 60 ++ web/services/LocationService.js | 42 ++ web/services/ObservationService.js | 145 ++++ web/styles.css | 939 ++++++++++++++++++++++++ web/utils/date.js | 77 ++ web/utils/format.js | 50 ++ web/utils/validation.js | 50 ++ 41 files changed, 3811 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 docs/AIInstructions.md create mode 100644 docs/Architecture.md create mode 100644 docs/CodingStandards.md create mode 100644 docs/DataModel.md create mode 100644 docs/DecisionLog.md create mode 100644 docs/DesignSystem.md create mode 100644 docs/Milestones.md create mode 100644 docs/Repository.md create mode 100644 docs/Requirements.md create mode 100644 docs/UX.md create mode 100644 docs/Vision.md create mode 100644 ios/README.md create mode 100644 web/app.js create mode 100644 web/assets/icons/.gitkeep create mode 100644 web/components/ConfirmDialog.js create mode 100644 web/components/FilterBar.js create mode 100644 web/components/NavBar.js create mode 100644 web/components/ObservationCard.js create mode 100644 web/components/ObservationModal.js create mode 100644 web/components/Toast.js create mode 100644 web/data/categories.json create mode 100644 web/data/species.json create mode 100644 web/index.html create mode 100644 web/models/Category.js create mode 100644 web/models/Observation.js create mode 100644 web/repository/ObservationRepository.js create mode 100644 web/screens/HomeScreen.js create mode 100644 web/screens/LogObservationScreen.js create mode 100644 web/screens/MapScreen.js create mode 100644 web/screens/MyObservationsScreen.js create mode 100644 web/screens/StatsScreen.js create mode 100644 web/services/ExportService.js create mode 100644 web/services/LocationService.js create mode 100644 web/services/ObservationService.js create mode 100644 web/styles.css create mode 100644 web/utils/date.js create mode 100644 web/utils/format.js create mode 100644 web/utils/validation.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6df30c --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Dependencies +node_modules/ + +# Build artifacts +dist/ +build/ + +# Environment files +.env +.env.local +.env.*.local + +# Editor directories and files +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Temporary files +tmp/ +temp/ + +# iOS build artifacts +ios/build/ +ios/DerivedData/ +ios/*.xcworkspace/xcuserdata/ +ios/Pods/ +ios/*.xcarchive diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2099ba5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ObservationTracker Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3230e67..dc6c59a 100644 --- a/README.md +++ b/README.md @@ -1 +1,49 @@ -# ObservationTracker \ No newline at end of file +# ObservationTracker + +A personal nature observation journal — log birds, plants, insects, and more from your browser. + +## Features + +- **Log observations** in under 30 seconds — date, time, category, species, count, location, and notes +- **Browse and filter** your observations by category, date range, or keyword search +- **Map view** showing geolocated observations on an OpenStreetMap (requires GPS coordinates) +- **Statistics** — totals, category breakdown, monthly charts, and top species +- **Export / import** observations as JSON for backup and transfer +- **Offline-first** — works entirely in the browser with no account or backend required + +## Project Structure + +``` +ObservationTracker/ +├── docs/ Project documentation +├── web/ Web application (vanilla JS SPA) +└── ios/ iOS app (planned — see docs/Milestones.md) +``` + +See [`docs/Repository.md`](docs/Repository.md) for the full directory map. + +## Quick Start + +1. Clone or download the repository +2. Open `web/index.html` in a modern browser (Chrome, Firefox, Safari, or Edge) + +No build step or dependencies required. + +## Documentation + +| File | Contents | +|---|---| +| [`docs/Vision.md`](docs/Vision.md) | Why the project exists | +| [`docs/Requirements.md`](docs/Requirements.md) | Functional and non-functional requirements | +| [`docs/Architecture.md`](docs/Architecture.md) | Layer diagram and technology choices | +| [`docs/UX.md`](docs/UX.md) | Screen designs and interaction patterns | +| [`docs/DesignSystem.md`](docs/DesignSystem.md) | Colors, typography, spacing | +| [`docs/DataModel.md`](docs/DataModel.md) | Data schemas and localStorage details | +| [`docs/CodingStandards.md`](docs/CodingStandards.md) | Code style rules | +| [`docs/Milestones.md`](docs/Milestones.md) | Roadmap and planned releases | +| [`docs/DecisionLog.md`](docs/DecisionLog.md) | Architectural decisions with rationale | +| [`docs/AIInstructions.md`](docs/AIInstructions.md) | Guidelines for AI coding assistants | + +## License + +MIT — see [LICENSE](LICENSE). \ No newline at end of file diff --git a/docs/AIInstructions.md b/docs/AIInstructions.md new file mode 100644 index 0000000..7f0e816 --- /dev/null +++ b/docs/AIInstructions.md @@ -0,0 +1,68 @@ +# AI Instructions + +Instructions for AI coding assistants (GitHub Copilot, Cursor, etc.) working on this codebase. + +## Project Context + +ObservationTracker is a client-side SPA for logging nature observations. It uses vanilla JavaScript ES2020+, plain CSS, and localStorage. There is no build step, no bundler, and no backend. See `Architecture.md` for the full layer diagram. + +## Code Conventions + +- Follow all rules in `CodingStandards.md` +- Use 2-space indentation, single quotes, no semicolons +- Prefer `const` over `let`; never use `var` +- Use `async`/`await` instead of `.then()` chains +- Use optional chaining (`?.`) and nullish coalescing (`??`) where appropriate + +## File Patterns + +- Each screen exports a default `render(container)` function +- Each component exports a factory function that returns an HTML string or DOM element +- The repository layer (`web/repository/`) handles all localStorage access +- Services (`web/services/`) contain business logic only — no DOM, no localStorage direct access +- Utils (`web/utils/`) contain pure functions only + +## Styling Rules + +- Use CSS custom properties from the `:root` block in `styles.css` +- Never hardcode color values — always reference a `--color-*` token +- Use BEM naming: `.block`, `.block__element`, `.block--modifier` +- Do not add `!important` + +## What NOT to Do + +- Do not add npm packages or a `package.json` +- Do not add a build/bundle step +- Do not use `document.write()` +- Do not use jQuery or other DOM libraries +- Do not store sensitive user data; do not add any analytics or tracking code +- Do not add backend endpoints or fetch calls to external APIs (except map tile CDN and species CDN if approved) + +## Testing + +Currently there is no automated test suite. Manual testing is done by opening `web/index.html` in a browser. When adding functionality, verify: +1. The happy path works as expected +2. Validation rejects invalid inputs with a clear error message +3. The UI is usable at 375px viewport width +4. No JavaScript errors appear in the browser console + +## Adding a New Screen + +1. Create `web/screens/MyNewScreen.js` with a default-exported `render(container)` function +2. Add the route in `app.js` router map +3. Add the nav item in `web/components/NavBar.js` +4. Document any new data fields in `docs/DataModel.md` + +## Adding a New Component + +1. Create `web/components/MyComponent.js` +2. Export a factory function: `export function myComponent(props) { return '' }` +3. Import and use it in the relevant screen + +## Commit Messages + +Use Conventional Commits format: +- `feat: add photo attachment to observation form` +- `fix: prevent duplicate observations on double-tap` +- `docs: update DataModel.md with photoDataUrl field` +- `chore: update categories.json with new species list` diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..80a6c01 --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,82 @@ +# Architecture + +## Overview + +ObservationTracker v1 is a client-side Single Page Application (SPA) built with vanilla JavaScript, HTML5, and CSS. There is no backend; all data is stored in the browser's `localStorage`. + +## Layer Diagram + +``` +┌──────────────────────────────────────────────────┐ +│ Screens (UI) │ +│ Home │ LogObservation │ MyObservations │ Map │ Stats │ +├──────────────────────────────────────────────────┤ +│ Components (UI) │ +│ NavBar │ ObservationCard │ Modal │ FilterBar │ Chart │ +├──────────────────────────────────────────────────┤ +│ Services │ +│ ObservationService │ LocationService │ ExportService │ +├──────────────────────────────────────────────────┤ +│ Repository │ +│ ObservationRepository │ +├──────────────────────────────────────────────────┤ +│ Storage │ +│ localStorage │ +└──────────────────────────────────────────────────┘ +``` + +## Module Descriptions + +### Screens +Located in `web/screens/`. Each screen is a JavaScript module that exports a `render(container)` function responsible for injecting HTML into the main content area and binding event listeners. + +### Components +Located in `web/components/`. Reusable UI fragments used by screens. Each component exports a factory function that returns a DOM element or HTML string. + +### Services +Located in `web/services/`. Business logic layer. Services call the repository for data and apply domain rules before returning results to screens. + +### Repository +Located in `web/repository/`. Handles all reads and writes to `localStorage`. Serializes/deserializes model objects. Provides simple CRUD operations. + +### Models +Located in `web/models/`. Plain JavaScript objects (POJOs) with factory functions. No framework classes. + +### Utils +Located in `web/utils/`. Stateless helper functions for date formatting, string manipulation, validation, and geolocation. + +### Data +Located in `web/data/`. Static JSON reference files (species lists, category definitions). + +## Routing + +The app uses hash-based routing (`window.location.hash`). The router in `app.js` maps hash fragments to screen modules and calls their `render()` functions on navigation. + +| Route | Screen | +|---|---| +| `#/` or `#/home` | Home | +| `#/log` | LogObservation | +| `#/observations` | MyObservations | +| `#/map` | Map | +| `#/stats` | Statistics | + +## Data Flow + +1. User triggers action in a Screen +2. Screen calls a Service method +3. Service applies business rules and calls Repository +4. Repository reads/writes localStorage +5. Repository returns model object(s) to Service +6. Service transforms if needed and returns to Screen +7. Screen re-renders the updated portion of the DOM + +## Technology Choices + +| Concern | Choice | Rationale | +|---|---|---| +| Language | Vanilla JS (ES2020) | No build step, no dependencies | +| Styling | Plain CSS with custom properties | Simple, no preprocessor needed | +| Storage | localStorage | Offline, no backend, sufficient for personal data volume | +| Mapping | Leaflet.js (CDN) | Lightweight, open-source, works offline with tiles cached | +| Charts | Chart.js (CDN) | Simple, well-documented, small footprint | +| Icons | Inline SVG | No external font dependency | diff --git a/docs/CodingStandards.md b/docs/CodingStandards.md new file mode 100644 index 0000000..a87f491 --- /dev/null +++ b/docs/CodingStandards.md @@ -0,0 +1,73 @@ +# Coding Standards + +## Language + +- Use ES2020+ features (optional chaining, nullish coalescing, `const`/`let`, arrow functions, template literals, destructuring, `async`/`await`) +- No TypeScript in v1 — use JSDoc comments for type hints if needed +- No build step, no bundler — code must run directly in modern browsers without transpilation + +## Module System + +- Use native ES Modules (`import`/`export`) via ` + + diff --git a/web/models/Category.js b/web/models/Category.js new file mode 100644 index 0000000..18e3cc9 --- /dev/null +++ b/web/models/Category.js @@ -0,0 +1,41 @@ +/** + * Category model — thin wrapper around the categories.json data. + * + * @typedef {{ + * id: string, + * label: string, + * icon: string, + * colorToken: string + * }} Category + */ + +/** @type {Category[]|null} */ +let _cache = null + +/** + * Loads and caches the categories from categories.json. + * @returns {Promise} + */ +export async function loadCategories() { + if (_cache) return _cache + const response = await fetch('./data/categories.json') + _cache = await response.json() + return _cache +} + +/** + * Returns the cached categories synchronously, or an empty array if not yet loaded. + * @returns {Category[]} + */ +export function getCategories() { + return _cache ?? [] +} + +/** + * Finds a category by id. + * @param {string} id + * @returns {Category|undefined} + */ +export function getCategoryById(id) { + return (_cache ?? []).find(c => c.id === id) +} diff --git a/web/models/Observation.js b/web/models/Observation.js new file mode 100644 index 0000000..7399603 --- /dev/null +++ b/web/models/Observation.js @@ -0,0 +1,84 @@ +/** + * Observation model factory and helpers. + * + * @typedef {{ + * id: string, + * createdAt: string, + * updatedAt: string, + * date: string, + * time: string, + * category: string, + * species: string, + * count: number, + * location: { name: string, lat: number|null, lng: number|null }, + * notes: string, + * photoDataUrl: string|null + * }} Observation + */ + +/** + * Generates a unique observation id. + * @returns {string} + */ +function generateId() { + const ts = Date.now() + const rand = Math.random().toString(36).slice(2, 8) + return `obs_${ts}_${rand}` +} + +/** + * Returns today's date as a YYYY-MM-DD string in local time. + * @returns {string} + */ +function todayDate() { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +/** + * Returns the current time as an HH:MM string. + * @returns {string} + */ +function currentTime() { + const d = new Date() + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +/** + * Creates a new Observation with defaults. + * @param {Partial} fields + * @returns {Observation} + */ +export function createObservation(fields = {}) { + const now = new Date().toISOString() + return { + id: generateId(), + createdAt: now, + updatedAt: now, + date: todayDate(), + time: currentTime(), + category: 'birds', + species: '', + count: 1, + location: { name: '', lat: null, lng: null }, + notes: '', + photoDataUrl: null, + ...fields, + } +} + +/** + * Returns a copy of the observation with updatedAt refreshed. + * @param {Observation} obs + * @param {Partial} changes + * @returns {Observation} + */ +export function updateObservation(obs, changes) { + return { + ...obs, + ...changes, + id: obs.id, + createdAt: obs.createdAt, + updatedAt: new Date().toISOString(), + } +} diff --git a/web/repository/ObservationRepository.js b/web/repository/ObservationRepository.js new file mode 100644 index 0000000..4c8f8f1 --- /dev/null +++ b/web/repository/ObservationRepository.js @@ -0,0 +1,104 @@ +/** + * ObservationRepository — localStorage CRUD for observations. + * + * Storage key: 'ot_observations' + * Value: JSON array of Observation objects + */ + +const STORAGE_KEY = 'ot_observations' + +/** + * Reads all observations from localStorage. + * @returns {import('../models/Observation.js').Observation[]} + */ +export function getAll() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? JSON.parse(raw) : [] + } catch (err) { + console.error('ObservationRepository.getAll failed:', err) + return [] + } +} + +/** + * Persists the full observations array to localStorage. + * @param {import('../models/Observation.js').Observation[]} observations + */ +function saveAll(observations) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(observations)) + } catch (err) { + console.error('ObservationRepository.saveAll failed:', err) + throw err + } +} + +/** + * Finds an observation by id. + * @param {string} id + * @returns {import('../models/Observation.js').Observation|undefined} + */ +export function getById(id) { + return getAll().find(o => o.id === id) +} + +/** + * Inserts a new observation. Throws if an observation with the same id already exists. + * @param {import('../models/Observation.js').Observation} observation + */ +export function insert(observation) { + const all = getAll() + if (all.some(o => o.id === observation.id)) { + throw new Error(`Observation with id ${observation.id} already exists`) + } + saveAll([...all, observation]) +} + +/** + * Replaces an existing observation by id. Throws if not found. + * @param {import('../models/Observation.js').Observation} observation + */ +export function update(observation) { + const all = getAll() + const idx = all.findIndex(o => o.id === observation.id) + if (idx === -1) { + throw new Error(`Observation with id ${observation.id} not found`) + } + const updated = [...all] + updated[idx] = observation + saveAll(updated) +} + +/** + * Removes an observation by id. Throws if not found. + * @param {string} id + */ +export function remove(id) { + const all = getAll() + const filtered = all.filter(o => o.id !== id) + if (filtered.length === all.length) { + throw new Error(`Observation with id ${id} not found`) + } + saveAll(filtered) +} + +/** + * Replaces all stored observations with the provided array. + * Used during import. + * @param {import('../models/Observation.js').Observation[]} observations + */ +export function replaceAll(observations) { + saveAll(observations) +} + +/** + * Removes all stored observations. + */ +export function clear() { + try { + localStorage.removeItem(STORAGE_KEY) + } catch (err) { + console.error('ObservationRepository.clear failed:', err) + } +} diff --git a/web/screens/HomeScreen.js b/web/screens/HomeScreen.js new file mode 100644 index 0000000..18289e3 --- /dev/null +++ b/web/screens/HomeScreen.js @@ -0,0 +1,82 @@ +/** + * HomeScreen — dashboard with greeting, recent observations, and quick-log button. + */ + +import { listObservations, getStats } from '../services/ObservationService.js' +import { observationCard } from '../components/ObservationCard.js' +import { openObservationModal } from '../components/ObservationModal.js' +import { relativeDate, calculateStreak } from '../utils/date.js' +import { pluralize } from '../utils/format.js' + +/** + * Renders the Home screen into container. + * @param {HTMLElement} container + */ +export default function render(container) { + const all = listObservations() + const stats = getStats() + const recent = all.slice(0, 3) + const streak = calculateStreak(all.map(o => o.date)) + + const today = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) + const todayObs = all.filter(o => o.date === new Date().toISOString().slice(0, 10)).length + + container.innerHTML = ` +
+
+

${today}

+

Welcome back

+
+ +
+
+ ${stats.totalCount} + Total observations +
+
+ ${stats.speciesCount} + Species +
+
+ ${streak} + Day streak 🔥 +
+
+ +
+

${pluralize(todayObs, 'observation', 'observations')} logged today

+ + Log Observation +
+ +
+

Recent

+ ${recent.length > 0 + ? `
${recent.map(observationCard).join('')}
+ View all observations →` + : `
+ +

No observations yet.

+ Log your first observation +
` + } +
+
+ ` + + // Bind card click events + container.querySelectorAll('.observation-card').forEach(card => { + card.addEventListener('click', () => { + const id = card.dataset.id + const obs = all.find(o => o.id === id) + if (obs) { + openObservationModal(obs, { + onEdit: () => { window.location.hash = `#/log?id=${id}` }, + onDelete: () => { window.location.hash = '#/observations' }, + }) + } + }) + card.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') card.click() + }) + }) +} diff --git a/web/screens/LogObservationScreen.js b/web/screens/LogObservationScreen.js new file mode 100644 index 0000000..aecba75 --- /dev/null +++ b/web/screens/LogObservationScreen.js @@ -0,0 +1,235 @@ +/** + * LogObservationScreen — form to create or edit an observation. + * + * Reads an optional `?id=` query from the hash to enter edit mode. + */ + +import { getCategories } from '../models/Category.js' +import { addObservation, editObservation, getObservation } from '../services/ObservationService.js' +import { getCurrentPosition, reverseGeocode } from '../services/LocationService.js' +import { showToast } from '../components/Toast.js' +import { validateObservation } from '../utils/validation.js' +import { escapeHtml } from '../utils/format.js' + +/** + * Parses the id query param from the current hash, if any. + * @returns {string|null} + */ +function getEditId() { + const hash = window.location.hash + const match = hash.match(/[?&]id=([^&]+)/) + return match ? decodeURIComponent(match[1]) : null +} + +/** + * Renders the Log Observation screen into container. + * @param {HTMLElement} container + */ +export default function render(container) { + const editId = getEditId() + const existing = editId ? getObservation(editId) : null + const categories = getCategories() + + const now = new Date() + const defaultDate = now.toISOString().slice(0, 10) + const defaultTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` + + const v = existing ?? {} + + container.innerHTML = ` +
+
+ ← Back +

${existing ? 'Edit Observation' : 'Log Observation'}

+
+ +
+
+
+ + + +
+
+ + + +
+
+ +
+
+ Category +
+ ${categories.map(c => ` + + `).join('')} +
+
+ +
+ +
+ + + + +
+ +
+ + + +
+ +
+ +
+ + +
+ +
+ +
+ + + 0 / 2000 + +
+ +
+ +
+
+
+ ` + + // Hidden lat/lng fields + let capturedLat = v.location?.lat ?? null + let capturedLng = v.location?.lng ?? null + + // Notes character counter + const notesField = container.querySelector('#field-notes') + const notesCount = container.querySelector('#notes-count') + notesCount.textContent = notesField.value.length + notesField.addEventListener('input', () => { + notesCount.textContent = notesField.value.length + }) + + // Category selection highlight + container.querySelectorAll('input[name="category"]').forEach(radio => { + radio.addEventListener('change', () => { + container.querySelectorAll('.category-option').forEach(opt => { + opt.classList.toggle('category-option--selected', opt.querySelector('input').checked) + }) + // Update species autocomplete for new category + updateSpeciesList(radio.value) + }) + }) + + // Species autocomplete + async function updateSpeciesList(categoryId) { + try { + const resp = await fetch('./data/species.json') + const data = await resp.json() + const datalist = container.querySelector('#species-list') + const species = data[categoryId] ?? [] + datalist.innerHTML = species.map(s => `